Skip to content

Commit a848814

Browse files
committed
munet: add Commander.async_spawn()
spawn(), which conducts a send/expect process is blocking due to the abscence of async_=True to pexpect.expect() within the method's main send/expect loop. Some async methods call spawn(), however, which leads to such methods being blocked. This is particularly problematic in cases where multiple VMs requiring custom console expect/send prompts are required (e.g. routers). Since each QEMU VM sets up both console logging and completes an expect/send loop without yielding control to the coroutine of the other QEMU VMs, only one VM can ever have logging configured (and thus advance in an expect/send loop) at any given time. Not only is this inefficient given that all QEMU VMs are running and waiting for input, but this can be fatal if console logging is not set up on each VM in time. If some mandatory console output is missed, then there is no way to recover and the setup of the QEMU VM will time out. However, setting async_=True in pexpect.expect() leads to errors when mixed with PopenSpawn. To bypass this issue, we do not use pexpect.expect() with async_=True, but repeatedly call pexpect.expect() with a timeout set at 0.1 (Until a timeout that we track expires). The end result of this change is that both logging is set up on all QEMU nodes immediately and that independent progress can be made in each VM's expect/send loop simultaneously. Signed-off-by: Liam Brady <[email protected]>
1 parent 2c87e57 commit a848814

File tree

2 files changed

+152
-2
lines changed

2 files changed

+152
-2
lines changed

munet/base.py

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,151 @@ def spawn(
727727
p.close()
728728
raise error from eoferr
729729

730+
async def async_spawn(
731+
self,
732+
cmd,
733+
spawned_re,
734+
expects=(),
735+
sends=(),
736+
use_pty=False,
737+
logfile=None,
738+
logfile_read=None,
739+
logfile_send=None,
740+
trace=None,
741+
**kwargs,
742+
):
743+
"""Create an async spawned send/expect process.
744+
745+
Args:
746+
cmd: list of args to exec/popen with, or an already open socket
747+
spawned_re: what to look for to know when done, `spawn` returns when seen
748+
expects: a list of regex other than `spawned_re` to look for. Commonly,
749+
"ogin:" or "[Pp]assword:"r.
750+
sends: what to send when an element of `expects` matches. So e.g., the
751+
username or password if thats what corresponding expect matched. Can
752+
be the empty string to send nothing.
753+
use_pty: true for pty based expect, otherwise uses popen (pipes/files)
754+
trace: if true then log send/expects
755+
**kwargs - kwargs passed on the _spawn.
756+
757+
Returns:
758+
A pexpect process.
759+
760+
Raises:
761+
pexpect.TIMEOUT, pexpect.EOF as documented in `pexpect`
762+
CalledProcessError if EOF is seen and `cmd` exited then
763+
raises a CalledProcessError to indicate the failure.
764+
"""
765+
if is_file_like(cmd):
766+
assert not use_pty
767+
ac = "*socket*"
768+
p = self._fdspawn(cmd, **kwargs)
769+
else:
770+
p, ac = self._spawn(cmd, use_pty=use_pty, **kwargs)
771+
772+
if logfile:
773+
p.logfile = logfile
774+
if logfile_read:
775+
p.logfile_read = logfile_read
776+
if logfile_send:
777+
p.logfile_send = logfile_send
778+
779+
# for spawned shells (i.e., a direct command an not a console)
780+
# this is wrong and will cause 2 prompts
781+
if not use_pty:
782+
# This isn't very nice looking
783+
p.echo = False
784+
if not is_file_like(cmd):
785+
p.isalive = lambda: p.proc.poll() is None
786+
if not hasattr(p, "close"):
787+
p.close = p.wait
788+
789+
# Do a quick check to see if we got the prompt right away, otherwise we may be
790+
# at a console so we send a \n to re-issue the prompt
791+
index = p.expect([spawned_re, pexpect.TIMEOUT, pexpect.EOF], timeout=0.1)
792+
if index == 0:
793+
assert p.match is not None
794+
self.logger.debug(
795+
"%s: got spawned_re quick: '%s' matching '%s'",
796+
self,
797+
p.match.group(0),
798+
spawned_re,
799+
)
800+
return p
801+
802+
# Now send a CRLF to cause the prompt (or whatever else) to re-issue
803+
p.send("\n")
804+
try:
805+
patterns = [spawned_re, *expects]
806+
807+
self.logger.debug("%s: expecting: %s", self, patterns)
808+
809+
# The timestamp is only used for the case of use_pty != True
810+
timeout = kwargs.get("timeout", 120)
811+
timeout_ts = datetime.datetime.now() + datetime.timedelta(seconds=timeout)
812+
index = None
813+
while True:
814+
if use_pty is True:
815+
index = await p.expect(patterns, async_=True)
816+
else:
817+
# Due to an upstream issue, async_=True cannot be mixed with
818+
# pipes (pexpect.popen_spawn.PopenSpawn). This hack is used
819+
# to bypass that problem.
820+
await asyncio.sleep(0) # Avoid blocking other coroutines
821+
try:
822+
index = p.expect(patterns, timeout=0.1)
823+
except pexpect.TIMEOUT:
824+
# We must declare when a timeout occurs instead of pexpect
825+
if timeout_ts < datetime.datetime.now():
826+
raise
827+
continue
828+
if index == 0:
829+
break
830+
831+
if trace:
832+
assert p.match is not None
833+
self.logger.debug(
834+
"%s: got expect: '%s' matching %d '%s', sending '%s'",
835+
self,
836+
p.match.group(0),
837+
index,
838+
patterns[index],
839+
sends[index - 1],
840+
)
841+
if sends[index - 1]:
842+
p.send(sends[index - 1])
843+
844+
self.logger.debug("%s: expecting again: %s", self, patterns)
845+
self.logger.debug(
846+
"%s: got spawned_re: '%s' matching '%s'",
847+
self,
848+
p.match.group(0),
849+
spawned_re,
850+
)
851+
return p
852+
except pexpect.TIMEOUT:
853+
self.logger.error(
854+
"%s: TIMEOUT looking for spawned_re '%s' expect buffer so far:\n%s",
855+
self,
856+
spawned_re,
857+
indent(p.buffer),
858+
)
859+
raise
860+
except pexpect.EOF as eoferr:
861+
if p.isalive():
862+
raise
863+
rc = p.status
864+
before = indent(p.before)
865+
error = CalledProcessError(rc, ac, output=before)
866+
self.logger.error(
867+
"%s: EOF looking for spawned_re '%s' before EOF:\n%s",
868+
self,
869+
spawned_re,
870+
before,
871+
)
872+
p.close()
873+
raise error from eoferr
874+
730875
async def shell_spawn(
731876
self,
732877
cmd,
@@ -761,7 +906,7 @@ async def shell_spawn(
761906
combined_prompt = r"({}|{})".format(re.escape(PEXPECT_PROMPT), prompt)
762907

763908
assert not is_file_like(cmd) or not use_pty
764-
p = self.spawn(
909+
p = await self.async_spawn(
765910
cmd,
766911
combined_prompt,
767912
expects=expects,

munet/native.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -911,7 +911,12 @@ async def monitor(
911911
logfile_read = open(lfname, "a+", encoding="utf-8")
912912
logfile_read.write("-- start read logging for: '{}' --\n".format(sock))
913913

914-
p = self.spawn(sock, prompt, logfile=logfile, logfile_read=logfile_read)
914+
p = await self.async_spawn(
915+
sock,
916+
prompt,
917+
logfile=logfile,
918+
logfile_read=logfile_read,
919+
)
915920
from .base import ShellWrapper # pylint: disable=C0415
916921

917922
p.send("\n")

0 commit comments

Comments
 (0)