The b9853ae stdin=DEVNULL fix wasn't sufficient. End-to-end
testing against a live VM in tmux revealed a second crash path:
libkrun spits "load \`config.json\`: parse error: trailing
garbage { \"ociVersion\": \"1.0.2\", ... }" and the main exec
dies (rc=1 or SIGKILL/rc=137, depending on race scheduling).
Root cause: each `smolvm machine exec` writes a per-invocation
OCI config.json to the same smolvm state dir during its bringup.
The wrapper's startup sync() fires within 1ms of Popen-ing the
main exec — both invocations write config.json concurrently,
libkrun loads one mid-write, and gets garbage. Trivial inner
commands (`sh -c "echo hi"`) finished before the overlap
mattered, masking the race in earlier tests. claude's slower
startup hits the race every time, and only inside tmux because
the outside-tmux foreground-handoff path takes a different
bringup sequence that happens to dodge the window.
Fix: schedule the initial sync on a 2-second `threading.Timer`
instead of calling it synchronously. By 2s the main exec is
past its bringup window, so the side-channel's config.json
write doesn't collide. Daemon thread so the timer doesn't
block exit when the child finishes quickly.
Trade-off: the in-VM PTY uses smolvm's default size for the
first ~2s, then snaps to the host pane size when the timer
fires. Verified end-to-end against a live VM in tmux: claude
renders at the default size during bringup, then redraws at
full pane width once the deferred sync lands. Operator-driven
resizes (SIGWINCH) still bridge in real time via the
already-installed signal handler.
Also drop the diagnostic log added in 9c83ea6 — we have the
fix.
Regression test:
`TestStartupSyncDeferred.test_main_schedules_timer_does_not_
call_sync_synchronously` mocks Popen + Timer + _push_size and
asserts `main()` schedules the timer with the documented
delay constant and never invokes _push_size synchronously.
Catches a "let's just inline the sync() call" regression
immediately.
638 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reports the launch still crashes in tmux after b9853ae's
stdin=DEVNULL fix. Re-instrument to capture the next failure mode
(argv, ppid, sync size, child exit, Popen tracebacks).
Removable once the inside-tmux launch is confirmed stable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inside tmux the dashboard's smolmachines launch crashed within
~100ms of the wrapper Popen-ing the main smolvm exec child —
sometimes with rc=137 (SIGKILL), sometimes with smolvm
spitting a runc-style "load `config.json`: cannot parse the
data: parse error: trailing garbage" and exiting 1. The same
wrapper ran fine outside tmux. Diagnostic logs showed the
SIGKILL landed ~100ms after the wrapper kicked off its
initial `sync()` (which fires the side-channel smolvm exec).
Root cause: the side-channel `subprocess.run([smolvm, machine,
exec, --, sh, -c, ...])` did not specify `stdin=`, so it
inherited the wrapper's stdin — the tmux pane PTY. The main
smolvm child (the agent session) also had that PTY as stdin.
Two concurrent smolvm processes sharing the PTY's
foreground-process-group / input plumbing caused smolvm to
abort one of them. iTerm's PTY plumbing apparently tolerated
this; tmux's didn't.
Fix is one line in `_push_size`: `stdin=subprocess.DEVNULL`.
The side-channel never needs stdin — it runs a fire-and-forget
`stty` and exits. Verified end-to-end: pre-fix the wrapper
crashed under `tmux respawn-pane` against a live VM; post-fix
the same invocation completes cleanly.
Also drop the diagnostic log added in 37bd11b — we have the
fix.
Regression test:
`test_side_channel_uses_devnull_stdin` locks the
`stdin=DEVNULL` invariant so a future "let's simplify the
subprocess.run kwargs" refactor surfaces this immediately.
637 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reports launch crashing only inside tmux (works outside).
The wrapper itself runs fine in standalone tmux repros, so the
break is in some interaction we can't see — curses eats stderr,
default tmux remain-on-exit is off, and the pane closes before
the operator can read anything.
Add an always-on per-pid log at ~/.claude-bottle/pty_resize.log:
- start record: argv, cwd, PATH, TMUX status
- sync record: window size observed
- child pid + exit rc
- any KeyboardInterrupt forwarding
- Popen failure traceback if it dies
Append-mode, small overhead, easy to grep + share.
Removable (along with the wrapper itself) once smolvm forwards
SIGWINCH natively.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dashboard's launch path crashed inside tmux but worked
outside it. Root cause: `python -m
claude_bottle.backend.smolmachines.pty_resize` needs the
`claude_bottle` package on `sys.path`, which by default comes
from cwd. The outside-tmux path is `subprocess.run(...)` —
inherits the dashboard process's cwd (the repo root, where
`claude_bottle/` lives), so the import resolves. The
inside-tmux path is `tmux split-window / respawn-pane <argv>`,
and tmux opens the new pane with the pane's OWN cwd, not the
cwd of the process invoking split-window. If the operator
started their tmux pane anywhere outside the repo (typical:
`$HOME`), the wrapper hit `ModuleNotFoundError: No module
named 'claude_bottle'` and tmux closed the pane immediately.
Sidestep the cwd dependence by invoking the wrapper as
`python <absolute-path-to-pty_resize.py>` instead of
`python -m <dotted-path>`. The wrapper has no
`claude_bottle.*` imports — it's stdlib-only — so it runs as
a standalone script anywhere on the filesystem. The absolute
path comes from `pty_resize.__file__` at module-load time.
Tests:
- `test_pty_resize_wrapper_prefix`: updated to assert the
absolute-script-path shape rather than the `-m <dotted>`
shape.
- `test_no_wrapper_when_tty_false`: the substring check now
uses `any("pty_resize" in a for a in argv)` instead of
string-joining (so the absolute path's "pty_resize.py"
filename match still catches a regression).
636 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`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>