utils/vmbox/lib/serial_exec.py
Krzysztof kuhy Rudnicki 8bb4d3a60d vmbox: bound every wait, so an unbootable guest fails instead of hanging
Testing boot_recovery produced a guest that cannot boot: boot-repair read
/boot/vmlinuz-linux and initramfs-linux.img on this BIOS/GRUB guest as
"orphaned kernel files shadowing the ESP" and deleted them, leaving the
sandbox at a GRUB prompt. That is a legitimate sandbox outcome -- destroying
the guest is what the tool is for -- but vmbox handled it by hanging.

Two independent unbounded waits, both fixed:

- vm_wait_ssh counted only its `sleep 2` calls, ignoring the up-to-5s
  ConnectTimeout each failed probe spends inside ssh. A nominal 150s wait ran
  past 400s (measured, then cut short by the harness). It now works to a wall
  clock deadline: the same guest is now reported unreachable in 156s.

- _run_recover_rc reboots the stopped guest to read the recorded exit code,
  and fell through to an unbounded serial console read: 180s for a prompt
  that never comes plus 300s for a command that never runs, all AFTER the
  verdict had already been printed. Recovery is best-effort by definition, so
  both bounds are now short (60s/30s) and overridable.

serial_exec's timeouts become env-overridable to make that possible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 22:31:39 +02:00

140 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run a command in a vmbox guest over its serial console root shell.
This exists because ssh is not the right channel for the thing vmbox is for.
A shutdown script kills the connection carrying its own result, and the serial
console keeps printing right through the poweroff -- so for the shutdown case
serial is not a fallback, it is the better transport. It also works when sshd
is broken, which makes an unreachable guest debuggable instead of opaque.
Every command is bracketed by explicit markers and waited on until its END
marker appears. Never use a fixed sleep: if the window closes early the next
command is typed while the previous one still runs, the tty echoes that input,
and the echoed text is indistinguishable from output in the transcript.
Usage:
serial_exec.py <console.sock> <command> -> runs, prints output
serial_exec.py <console.sock> --wait-prompt -> just wait for a shell
"""
from __future__ import annotations
import os
import re
import socket
import sys
import time
import uuid
PROMPT = "[root@vmbox"
# Both timeouts are env-overridable so a caller that already knows the guest
# may be unbootable can bound its own wait. Without that, a guest stuck in
# GRUB (no kernel) makes a "quick" console read block for 180+300s -- long
# enough to look like a hang rather than a failure.
PROMPT_TIMEOUT = float(os.environ.get("VMBOX_SERIAL_PROMPT_TIMEOUT", "180"))
RUN_TIMEOUT = float(os.environ.get("VMBOX_SERIAL_RUN_TIMEOUT", "300"))
# Strip ANSI CSI sequences and the OSC 3008 shell-integration blocks that
# systemd's login emits, both of which otherwise corrupt marker matching.
_ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
_OSC = re.compile(r"\x1b?\]3008;[^\x07\\]*(?:\x07|\\)?")
def clean(raw: bytes) -> str:
text = raw.decode("utf-8", "replace")
text = _ANSI.sub("", text)
text = _OSC.sub("", text)
return text.replace("\r", "")
class Console:
"""A line-oriented client for the guest's serial console socket."""
def __init__(self, path: str) -> None:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(path)
self.sock.settimeout(0.4)
self.buf = b""
def read_until(self, needle: str, timeout: float) -> bool:
end = time.time() + timeout
while time.time() < end:
try:
chunk = self.sock.recv(4096)
except TimeoutError:
continue
except OSError:
return False
if not chunk:
return False
self.buf += chunk
if needle in clean(self.buf):
return True
return False
def wait_prompt(self, timeout: float = PROMPT_TIMEOUT) -> bool:
# Nudge with a newline: if the shell is already up we get a fresh
# prompt immediately instead of waiting for unrelated output.
self.sock.sendall(b"\n")
return self.read_until(PROMPT, timeout)
def read_until_count(self, needle: str, count: int, timeout: float) -> bool:
"""Wait until `needle` has been seen `count` times.
A tty echoes what you type, so the marker appears once in the echoed
command line before it ever appears in real output. Waiting for the
first occurrence returns immediately -- before the command has run --
and yields an empty or truncated result that looks like a hang.
"""
end = time.time() + timeout
while time.time() < end:
if clean(self.buf).count(needle) >= count:
return True
try:
chunk = self.sock.recv(4096)
except TimeoutError:
continue
except OSError:
return False
if not chunk:
return False
self.buf += chunk
return clean(self.buf).count(needle) >= count
def run(self, cmd: str, timeout: float = RUN_TIMEOUT) -> str:
# Markers are plain alphanumerics: shell metacharacters like < and >
# are echoed back by the tty and can be reordered or line-wrapped,
# which makes them unreliable to match on.
token = uuid.uuid4().hex[:8].upper()
start, done = f"VMBOXS{token}", f"VMBOXE{token}"
self.buf = b""
# `; echo RC=$?` keeps the guest's exit status, which the ssh path
# cannot report once the machine stops.
self.sock.sendall(f"echo {start}; {cmd}; echo RC=$?; echo {done}\n".encode())
# Twice: once in the echoed command line, once in real output.
if not self.read_until_count(done, 2, timeout):
return f"### TIMEOUT: no end marker after {timeout}s ###"
text = clean(self.buf)
# Take the LAST start marker and the text up to the following end
# marker: everything before that is the echo of what we typed.
body = text.rsplit(start, 1)[-1].split(done, 1)[0]
lines = [ln for ln in body.splitlines() if start not in ln and done not in ln]
return "\n".join(lines).strip()
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
console = Console(sys.argv[1])
if sys.argv[2] == "--wait-prompt":
return 0 if console.wait_prompt() else 1
if not console.wait_prompt():
print("serial_exec: no root prompt on the console", file=sys.stderr)
return 1
print(console.run(" ".join(sys.argv[2:])))
return 0
if __name__ == "__main__":
sys.exit(main())