fix(smolmachines): bridge host SIGWINCH into the VM PTY (issue #82)
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 41s

`smolvm 0.8.0 machine exec -t` allocates an in-VM PTY but never
forwards the host terminal's window size — the PTY starts at
`0 0` and host resizes (tmux pane resize, terminal window
resize) go unnoticed, so the claude TUI inside a smolmachines
bottle renders for whatever tiny box it last saw and ignores
operator resizes. `docker exec -it` propagates window-size
changes automatically; smolvm doesn't.

Workaround: a small Python wrapper
(`backend/smolmachines/pty_resize.py`) that interposes between
the operator's terminal and `smolvm machine exec`. It spawns
smolvm as a child, traps host SIGWINCH, and on every resize
(plus once at startup) runs a side-channel
`smolvm machine exec --name <M> -- sh -c 'for f in /dev/pts/*;
do stty -F $f cols X rows Y; done'`. The kernel delivers
SIGWINCH to the in-VM foreground process group when the slave
PTY's size changes, so claude picks up the new dimensions
without extra signalling.

`SmolmachinesBottle.claude_argv` prepends
`[sys.executable, -m, claude_bottle.backend.smolmachines.
pty_resize, <machine>, --, ...]` to the existing smolvm argv
in TTY mode. Non-TTY mode (provisioning shell-outs) skips the
wrapper — no PTY to resize.

The wrapper survives the dashboard's
`_build_resume_argv_with_fallback` shell-wrap because the
split-at-`claude` token still finds the right position — the
wrapper's prefix wraps the entire smolvm-exec framing.

Tests:
- `test_smolmachines_pty_resize.py` (new): argv parsing, the
  side-channel command shape (cols/rows / for-loop over
  /dev/pts/*), and `_read_winsize`'s fallback across
  stdin/stdout/stderr including the smolvm-allocated-PTY-
  reports-`0 0` ironic case.
- `test_smolmachines_bottle.py`: updated TTY-mode assertions
  to unwrap the pty_resize prefix; added `TestClaudeArgvNoTTY`
  to lock the non-TTY skip.

636 unit tests pass.

Removable when smolvm grows native SIGWINCH forwarding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:15:11 -04:00
parent a3a9ec065e
commit 3fb305f654
4 changed files with 306 additions and 19 deletions
+12 -1
View File
@@ -18,6 +18,7 @@ minimal Debian VM with no PAM session config."""
from __future__ import annotations
import subprocess
import sys
from typing import Mapping
from .. import Bottle, ExecResult
@@ -88,7 +89,17 @@ class SmolmachinesBottle(Bottle):
claude_tail += ["--append-system-prompt-file", self._prompt_path]
claude_tail += argv
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
return flags
if not tty:
# No PTY allocated — no SIGWINCH to forward, no resize
# bridge needed. Skip the wrapper so non-interactive
# exec paths (e.g., provisioning shell-outs that
# happen to go through this method) stay light.
return flags
return [
sys.executable, "-m",
"claude_bottle.backend.smolmachines.pty_resize",
self.name, "--", *flags,
]
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
"""Run `claude` interactively inside the VM as the `node`