"""Host-side SIGWINCH → in-VM PTY resize bridge (issue #82). smolvm 0.8.0 `machine exec -t` allocates an in-VM PTY but never forwards the host terminal's window size (TIOCSWINSZ) to it. The PTY's initial size is `0 0`, and any host-side resize during the session goes unnoticed — the in-VM claude TUI keeps rendering for whatever (typically tiny) box it last saw, ignoring the operator's tmux pane resize. `docker exec -it` does this forwarding automatically; smolvm doesn't. This module wraps `smolvm machine exec` with a thin parent process that: 1. Spawns the original argv as a child (it gets the inherited TTY, so claude's stdin/stdout/stderr work unchanged). 2. On startup + every host SIGWINCH, reads the host terminal size via TIOCGWINSZ on stdin (or stderr if stdin isn't a TTY — tmux respawn-pane gives us a TTY on stdout/stderr) and pushes it into the VM with a side-channel `smolvm machine exec -- sh -c 'for f in /dev/pts/*; do stty -F $f cols X rows Y; done'`. The kernel delivers SIGWINCH to the foreground process group on the slave end automatically, so claude picks up the new size without extra signalling. 3. Waits on the child and exits with its returncode. The dashboard's tmux pane respawn calls `bottle.agent_argv` which now prepends `[sys.executable, -m, ..., , --, ...]` to the smolvm argv. Foreground handoff (curses endwin → subprocess.run) goes through the same path so behavior is identical. Removable once smolvm grows native SIGWINCH forwarding (upstream follow-up tracked separately).""" from __future__ import annotations import fcntl import signal import struct import subprocess import sys import termios import threading # How long to wait after the main exec starts before pushing the # initial size. Concurrent `smolvm machine exec` invocations race # libkrun's per-exec OCI config write during the main exec's # bringup window; the side-channel firing immediately corrupts # `config.json` and the main exec dies with SIGKILL (rc=137) or # libkrun's "parse error: trailing garbage" depending on # scheduling. Two seconds is well past the bringup window on a # warm VM, well under the operator's "this is unresponsive" # threshold, and short enough that claude's initial render # almost always fires after the size has been set. _STARTUP_SYNC_DELAY_SEC = 2.0 def _read_winsize() -> tuple[int, int] | None: """Return `(rows, cols)` from whichever of stdin / stdout / stderr is a TTY, or None if none are. Different invocation surfaces give us different TTYs: - foreground handoff (curses endwin → subprocess.run): all three are the operator's terminal. - tmux respawn-pane: tmux sets all three to the pane's PTY. - non-TTY (someone piped stdin in tests): none are; the sync just no-ops, which is the right behavior.""" for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()): try: data = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8) except OSError: continue rows, cols, _, _ = struct.unpack("hhhh", data) if rows > 0 and cols > 0: return rows, cols return None def _push_size(machine: str, rows: int, cols: int) -> None: """Side-channel `smolvm machine exec` that sets the size of every PTY in the VM. The shell `for` loop covers the case of multiple concurrent interactive sessions (rare but cheap to handle); `stty -F` returns silently on PTYs that don't apply. Best-effort: swallow failures. A failed resize doesn't break the session — it just leaves the in-VM PTY at its old size. `stdin=DEVNULL` is load-bearing: under tmux, inheriting the pane PTY here means two concurrent smolvm processes (this one and the agent session the wrapper is shepherding) share the PTY's foreground-process-group / input plumbing, and smolvm bails with an internal config-parse error or SIGKILL within ~100ms of the side-channel firing. Outside tmux the same pattern survived, presumably because iTerm's PTY plumbing is more forgiving than tmux's, but the DEVNULL is the right default either way — the side-channel never needs stdin.""" subprocess.run( ["smolvm", "machine", "exec", "--name", machine, "--", "sh", "-c", f"for f in /dev/pts/*; do " f"stty -F \"$f\" cols {cols} rows {rows} 2>/dev/null; " f"done"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) def main(argv: list[str]) -> int: """Entry point. `argv` shape: ` -- `. We don't use argparse — the `--` separator is the contract and everything past it is forwarded verbatim. Keeps the wrapper transparent for callers building argv programmatically.""" if len(argv) < 3 or argv[1] != "--": sys.stderr.write( "usage: python -m bot_bottle.backend.smolmachines.pty_resize " " -- \n" ) return 2 machine = argv[0] inner = argv[2:] def sync(*_args) -> None: size = _read_winsize() if size is None: return _push_size(machine, *size) signal.signal(signal.SIGWINCH, sync) proc = subprocess.Popen(inner) # Initial sync is deferred — see _STARTUP_SYNC_DELAY_SEC. # daemon=True so the timer doesn't block exit when the child # finishes before the delay elapses. timer = threading.Timer(_STARTUP_SYNC_DELAY_SEC, sync) timer.daemon = True timer.start() while True: try: return proc.wait() except KeyboardInterrupt: proc.send_signal(signal.SIGINT) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))