Conversation
…nect loop
A self-contained zsh file sourced by cli/ghostty-zmx (replacing the bare
exec "$@") that runs the ssh/tsh transport in a supervised reconnect
loop, mirroring autossh semantics for remote zmx projection panes.
When the transport exits (network drop, host unreachable, auth failure,
ssh killed by signal on pty close), the loop reattaches to the surviving
remote zmx session with exponential backoff, instead of leaving the pane
at Ghostty's 'Press any key to close' prompt. It exits cleanly on
intentional close (Cmd-W / window close / Cmd-Q), owner-death (Cmd-Q /
crash where the signal didn't arrive), a spurious duplicate attach, or a
cross-client close (local projection row marked closing).
Decision logic:
- trap HUP TERM INT sets a close flag and kills the foreground ssh child;
the loop exits on the next iteration (intentional close).
- owning-Ghostty liveness is checked before each reconnect via the
poller's elapsed-seconds token pattern (ps -o etime=; dead or reused
pid => exit), catching paths where the signal didn't arrive.
- the remote session state (not the ssh exit code) drives the reconnect
decision: ssh does not exit on user 'exit' (zmx intercepts it as a
detach), so any ssh exit is a disconnect. survived (clients=0) =>
reconnect; gone (missing) => reboot-restore on the next iteration;
live (clients=1) => exit (duplicate).
Helpers are inlined (ppid-chain walk for ghostty-pid discovery,
elapsed-seconds parse, session-state check, projection-row closing check,
scrollback snapshot, reboot-restore) because the wrapper runs zsh -f and
must not source session-manager.zsh (that isolation is load-bearing).
Config (all optional, safe defaults):
GHOSTTY_ZMX_RECONNECT=1 master switch (0 => exec "$@")
GHOSTTY_ZMX_RECONNECT_INITIAL_BACKOFF=1 seconds, first reconnect delay
GHOSTTY_ZMX_RECONNECT_MAX_BACKOFF=30 seconds, exponential cap
GHOSTTY_ZMX_RECONNECT_MAX_ATTEMPTS=0 0 = unlimited (autossh-style)
GHOSTTY_ZMX_RECONNECT_PRE_SNAPSHOT=1 snapshot scrollback before reconnect
The wrapper wiring (replacing exec "$@" with source projection-loop)
lands in a follow-up commit. TERM=dumb for tsh stays in the wrapper before
the source and persists across loop iterations via env inheritance.
…ONNECT=0 kill switch) Replace the bare exec "$@" at the end of cli/ghostty-zmx with sourcing cli/projection-loop when GHOSTTY_ZMX_RECONNECT is unset or 1, so a dropped ssh/tsh transport reattaches to the surviving remote zmx session instead of leaving the pane at Ghostty's close prompt. GHOSTTY_ZMX_RECONNECT=0 restores the original exec "$@" behavior as a kill switch (for bisection or users who prefer manual reconnect). TERM=dumb for tsh (set above the source) stays before the loop and persists across reconnect iterations via env inheritance — moving it into the loop would make every tsh reconnect re-emit OSC probes.
Sources cli/projection-loop with stubs for the transport, session-state
check, owner-alive check, projection-row check, and sleep, then asserts
the reconnect decision matrix from the design:
- rc 255 + survived => reconnect (attempt counter increments)
- rc 255 + gone => reconnect (reboot-restore path fires)
- rc 255 + live => exit (duplicate attach)
- trap during run => exit 0 (trap wins, no reconnect)
- owner dead => exit (no transport run)
- max-attempts => exit after N calls
- rc 1 + survived => reconnect (other non-zero treated like 255)
- projection row closing => exit 0 (cross-client close)
- trap during sleep => exit 0 (top-of-loop check, no new transport)
- rc 0 + survived => reconnect (no rc-0 special-case: ssh doesn't
exit on user exit, so the session-state check drives the decision)
Each case runs in a fresh zsh subshell so the loop's trap + exit() are
isolated and $$ reliably identifies the process the trap is installed on.
The transport stub sends HUP to $$ to simulate a close signal.
Also promotes _gzmx_closing from a local to a process-global in
cli/projection-loop so the trap closure and the loop body share one flag
(a foreground transport that signals a close can set it). This is a
testability fix with no behavioral change in production: the flag is still
scoped to the single loop process.
Opens a projection to the sshd fixture, injects a marker into the remote scrollback, kills the ssh client process (pkill -f 'ssh.*<session>') to simulate a network drop, and asserts the reconnect loop reattaches to the same surviving remote zmx session (clients=0 -> clients=1, same session name, 2 windows retained) with the marker still present in the scrollback. Also adds GHOSTTY_ZMX_RECONNECT_MAX_ATTEMPTS=3 to gzmx_e2e_ghostty_launch in the harness so the loop exits if the fixture is down (prevents CI hangs when the fixture is unreachable across reconnect attempts).
…s a pty
Backgrounding the transport with `&` sets the job's stdin to /dev/null in
zsh, so ssh -t refuses to allocate a pseudo-terminal ("Pseudo-terminal
will not be allocated because stdin is not a terminal") and the remote
zmx attach runs non-interactively and exits immediately. This is the exact
failure mode the original exec "$@" path was designed to avoid.
Redirect the backgrounded job's stdin from /dev/tty so ssh -t sees a
terminal and allocates a pty. The wrapper always has a controlling
terminal (it is a Ghostty surface command), so /dev/tty is available in
production.
The unit tests run the worker without a controlling terminal, so /dev/tty
cannot be opened there. Wrap the worker invocation in `script -q /dev/null`
to allocate a pseudo-terminal for the test environment. (correctness-C1)
… wrapper copy) The reboot-restore block was copied into cli/projection-loop as _gzmx_reboot_restore_check but the original block remained in the wrapper, so it ran twice on the first attach (once in the wrapper before sourcing the loop, once on the loop's first iteration). The loop's copy already runs on the first iteration before the first transport run, which covers the original block's startup responsibility. Delete the wrapper's copy. The loop derives the history file path itself, so no state is lost. Also drop the stale cross-file line-number reference in the loop's comment that claimed the block was moved from specific wrapper lines. (cleanness-C1, cleanness-C3)
_gzmx_session_state matched `name=$session ` with a trailing space, but zmx list is tab-delimited, so the match never fired and the function always returned gone. The survived and live branches were dead code and the duplicate-attach guard was inert. Replace the inline glob+parse with an awk -F '\t' column extraction mirroring session-manager.zsh _ghostty_zmx_session_clients: strip the optional active-session marker (→) and name= prefix from field 1, match the session exactly, and read clients= from field 3. Column-based parsing also prevents a start_dir containing 'clients=' from skewing the field. Add direct unit tests that feed real tab-delimited zmx list output through the un-stubbed function: clients=0 => survived, clients=1 => live, session missing => gone, active-session marker stripped, and start_dir with clients= does not skew the parse. (cleanness-C2, security-SEC-2, correctness-C2, correctness-C8)
…d clobbering live sessions _gzmx_session_state returned gone when the ssh round-trip itself failed (network down, host unreachable) — the exact conditions that caused the transport to exit. This misclassified a transient failure as a remote reboot, driving _gzmx_reboot_restore_check to create a fresh session and inject the scrollback snapshot, clobbering a LIVE preserved session when the network recovered. Return a fourth state, unknown, when the ssh round-trip fails (non-zero rc or empty output). The loop treats unknown as 'reconnect without reboot-restore' — the session may have survived, and the next iteration's session-state check will re-classify once the network is back. Add a _skip_reboot_restore flag so the top-of-loop reboot-restore check is skipped on the iteration following an unknown state. The cold-start reboot-restore (first iteration, before any transport) still runs. Add unit tests: the loop reconnects on unknown with only the cold-start reboot-restore call (not one per iteration), and the un-stubbed _gzmx_session_state returns unknown when ssh fails or returns empty. (coverage-C2)
After wait returns and before _gzmx_ssh_pid is cleared, a delivered signal fires the trap with a pid that may have been reused for an unrelated process. The trap checked only that the pid was non-empty, not that it was still a child of this process. Re-verify the pid is still a direct child of $$ (via ps -o ppid=) before sending the signal. If the pid has been recycled, the ppid will not match and the kill is skipped. (security-SEC-1, correctness-C7)
…top of loop The closing-row check existed only at the top of the loop. A cross-client close during a running transport was detected one iteration late — the loop ran the session-state check (an unnecessary ssh round-trip against a session that was just killed) and reconnected before the next iteration's top-of-loop check exited. Add a closing-row check to the post-exit checks, between the owner-alive check and the session-state check. If the row is closing, exit 0 immediately — no session-state round-trip, no reconnect. Add a unit test: row is present at startup, transport exits 255, row transitions to closing during the run — assert the loop exits 0 after one transport call without running the session-state check. (coverage-C3)
…alls The side-channel ssh calls (session-state, snapshot, reboot-restore) used _add_argv, which lacks -o BatchMode=yes and -o ConnectTimeout. The design and code comments claimed BatchMode was present, but it was not. An unreachable host blocked for the full system TCP timeout (~75s on macOS), and an auth failure could prompt for a password on the pty-less stdin. Build a separate _gzmx_side_argv (the no-pty transport argv + BatchMode + ConnectTimeout=10) for side-channel calls, leaving _add_argv for the layout-row add. Side-channel calls now fail fast on auth issues and time out within 10 seconds on an unreachable host, bounding the pane's linger on a close-during-side-channel. (security-SEC-3, correctness-C3, correctness-C5)
zsh's builtin sleep is not preempted by traps — the trap handler runs but sleep blocks for its full duration. With the default max backoff of 30s, a Cmd-W during backoff could linger up to ~35s (5s SIGHUP delay + 30s remaining sleep), a regression vs the original exec path's immediate exit. Replace the bare sleep with _gzmx_interruptible_sleep, which polls the close flag (_gzmx_closing) in 0.2s increments and returns early when a trap fires. The top-of-loop check on the next iteration exits without starting a new transport. Add a unit test: send HUP mid-sleep and assert _gzmx_interruptible_sleep returns within ~0.4s (0.3s delay + one poll increment), not the full 5s. (correctness-C4)
When GHOSTTY_ZMX_RECONNECT=0, the kill switch jumped straight to exec "$@" without running the reboot-restore check, silently dropping the Cmd-Q/reopen scrollback-restore feature that existed before the loop was introduced. This violated design locked decision #8 ("restores the current exec \"\$@\" behavior") and created a bisection trap: a user disabling the loop to debug a reconnect bug would also lose scrollback restore and might misattribute the missing scrollback. The kill-switch branch now sources projection-loop with a cold-start-only flag that runs the reboot-restore check once (the same cold-start step the loop runs on its first iteration), then execs the transport so the wrapper is replaced by ssh as in the original behavior. The reboot-restore check is self-contained and idempotent (a no-op if the session already exists). Add a unit test that sets GHOSTTY_ZMX_RECONNECT=0 and asserts the reboot-restore check runs before the transport execs. (correctness-r2-M1, coverage-r2-COV-3)
… call _gzmx_snapshot_session omitted the </dev/null stdin redirect that the other two side-channel functions (_gzmx_session_state and _gzmx_reboot_restore_check) apply at their ssh call sites. The design's resolved-decision #2 and the in-code comment state this is a load-bearing invariant: side-channel ssh calls must redirect stdin from /dev/null so they do not hang waiting for stdin/password input. In practice this did not hang today (BatchMode=yes prevents password prompts, ConnectTimeout=10 bounds the connect, and zmx history does not read stdin), but the invariant was violated and the function was one remote-command change away from a stdin read that could block against the wrapper's pty. The inconsistency was also a maintenance trap. Add </dev/null to the ssh invocation in _gzmx_snapshot_session, matching the other two side-channel functions. (correctness-r2-L1)
…boot-restore changes Three comments were left stale by the BatchMode and reboot-restore-move fix commits: 1. _gzmx_session_state header said it uses $_add_argv, but the body invokes $_gzmx_side_argv (switched by the BatchMode fix). Updated to name the correct variable. 2. _gzmx_reboot_restore_check comment pointed at "cli/ghostty-zmx reboot-restore block comment" for the full rationale, but that block was deleted when the logic moved into projection-loop. Dropped the dangling cross-file pointer and inlined the one-sentence rationale (a hang on stdin makes the check falsely report "missing", which then clobbers a LIVE Cmd-Q-preserved session via the zmx run + zmx print injection below). 3. File header described $_add_argv as "used for no-pty ssh side-channels", but after the BatchMode fix the side-channel calls use $_gzmx_side_argv (derived from _add_argv). Reworded to describe _add_argv's actual role: the layout-row add call and the base for the loop's side-channel argv. (cleanness-r2-C1, cleanness-r2-C2, correctness-r2-L2)
…file list) install-dev.sh used an explicit file list for cli/ subcommands but was not updated when cli/projection-loop was added. The stable install.sh uses a glob and was unaffected. Without this fix, the wrapper's 'source projection-loop' fails silently (no error guard), the pane exits with rc=0, and the poller runs the close transaction — the reconnect loop is never exercised. Also harden the wrapper's source with an error guard so a missing file fails loudly (exit 127) instead of silently exiting the pane. Found by e2e/19-reconnect-on-drop.zsh (first live run).
The pkill -f "ssh.*<session>" pattern matched the wrapper parent process too (its ps args contain the full transport argv), sending SIGTERM to the wrapper. The wrapper's trap fired (intentional-close), making the loop exit instead of reconnecting. Fix: find the wrapper pid from remote-projections, then kill its ssh child via pgrep -P + kill -TERM. Falls back to pkill only if the wrapper pid isn't found. Diagnosed by e2e-tester (2nd live run): debug log showed 'projection: exiting reason=intentional-close (trap during run)' with no session-state check ever running.
SIGTERM to the ssh child causes the terminal to send SIGHUP to the foreground process group (the wrapper is in it), triggering the wrapper's trap (closing=1) and making the loop exit with 'intentional-close' instead of reconnecting. SIGKILL kills ssh immediately without triggering the terminal's hangup, so the wrapper's wait returns and the loop can run the session-state check + reconnect. Root cause: the trap-based close detection cannot distinguish a user-initiated close (Cmd-W → SIGHUP from Ghostty) from a child-kill that propagates SIGHUP via the terminal's process group. The test must simulate a drop without sending a signal to the wrapper's process group.
cad0p
marked this pull request as ready for review
July 9, 2026 00:13
…-process false match ghostty_zmx_descendants_matching checked the root pid's args at depth 0. The root is the Ghostty-reported terminal pid, which (when a surface command is set) is the login process whose ps args contain the full command string (including --session <gzr>) because it is all passed as the -c argument. This caused a false match at depth 0, recording the login pid as match_pid in remote-projections instead of the actual zsh -f wrapper. Start the queue with the root's children and only check descendants, so match_pid is always a process below the terminal (the wrapper or ssh transport), never the terminal/login process itself. This also makes the poller's liveness check (kill -0 match_pid) correctly detect when the wrapper dies rather than when the login parent dies. Add a regression test that builds a real process tree where both the root and a child have the needle in their ps args, and asserts the function returns the child, never the root.
…s it) A real network drop sends SIGHUP to the process group when the ssh child dies, firing the trap and setting _gzmx_closing. The loop must reconnect in that case, not exit. The exit decision is now driven by owner liveness (_gzmx_owner_alive), which dies on Cmd-Q/Cmd-W but stays alive on a network drop. The trap still sets the flag and kills the ssh child (so a Cmd-W kills the foreground ssh promptly), and the flag shortens the backoff sleep, but it no longer drives an exit. Removed the top-of-loop and post-exit _gzmx_closing checks; the owner-dead check (already present) is now the sole intentional-close signal. Updated tests: case 5 (trap + owner alive => reconnect), case 5b (trap + owner dead => exit via owner-dead), case 10 (trap during sleep + owner alive => reconnect). 34/34 pass.
…eeded) The SIGKILL workaround in e2e/19 was needed because the wrapper's trap fired on SIGTERM and the loop exited on the trap flag alone. Now that the loop exits on owner liveness (not the trap flag), SIGTERM correctly simulates a network drop: the trap fires, sets _gzmx_closing, and kills the ssh child, but the owner stays alive so the loop reconnects. Updated the comment to reflect this: the trap firing is expected and no longer causes an exit. Verified e2e/19 passes with SIGTERM.
The reconnect scenario asserted clients=1 + window=2 + same session after killing the ssh child, but a regression that makes the wrapper exit (instead of fork+loop) could still pass if the poller re-projected a fresh projection. The poller re-projection would restore clients=1 and window=2 — masking the 'Press any key to close' banner that is the actual user-visible bug. Add kill -0 on the wrapper pid recorded in remote-projections as a hermetic proxy for 'the surface command never exited = no banner'. Verified empirically in both directions: pre-PR (exec ssh) the wrapper pid dies and the banner shows; post-PR (fork + reconnect loop) the pid lives and no banner appears. This closes the gap between 'reconnect happened' and 'the pane never showed the dead-process banner' without bringing screenshot/OCR machinery into CI.
cad0p
force-pushed
the
feat/projection-ssh-reattach
branch
from
July 9, 2026 01:38
f6f1b02 to
7f7d18f
Compare
…tore Two new failing e2e scenarios that catch real bugs found on pcad-dev (the Docker fixture didn't reproduce them because it lacks pam_systemd). Scenario 20 (FAIL): the projection runs 'ssh host "zmx attach gzr-..."' as a NON-interactive command, so XDG_RUNTIME_DIR is not set (pam_systemd only sets it for interactive login shells). zmx's socket-dir resolution (ZMX_DIR > XDG_RUNTIME_DIR > TMPDIR) creates the session in /tmp/zmx-<uid>, but the pane's interactive shell has XDG_RUNTIME_DIR=/run/user/<uid> and queries a different dir. The pane is attached ($ZMX_SESSION confirms it) but 'zmx ls' in the pane does NOT list the gzr-* session — the user-visible symptom of typing 'zmx ls' in a remote projection and seeing unrelated sessions instead of the one the pane is in. Scenario 21 (PASS currently, regression guard): server-side session kill should trigger reboot-scrollback restore (banner + snapshot injection), not silent reconnect. The loop's session-state check returns 'gone' when the side-channel ssh can see the session is missing → reboot-restore runs. On pcad-dev this returned 'unknown' instead (side-channel ssh failure), skipping restore — but the Docker fixture's side-channel sees the session in /tmp (where it was created), so the test passes here. Kept as a regression guard for the restore path itself. Dockerfile change: simulate pam_systemd by exporting XDG_RUNTIME_DIR=/run/user/1000 in the fixture's .zshrc (interactive only). Without this, both interactive and non-interactive shells in the fixture agree on /tmp and the mismatch (scenario 20) cannot reproduce. The /run/user/1000 dir is created + chowned in the image (root), then the .zshrc export makes interactive shells use it. These tests are expected to FAIL until the projection pins ZMX_DIR to a deterministic location (the fix, to follow).
Scenario 21 was a near-duplicate of scenario 08: same sequence (inject marker, snapshot, kill remote session, assert reboot-restore banner + marker restored). The only novelty was 'kill happens while the projection loop is live (no Cmd-Q)' — a thin distinction from scenario 08's 'kill after Cmd-Q'. Folded into scenario 08 as phase 2 (re-inject marker, client-disconnect to snapshot, live-kill, assert reboot-restore runs). Phase 2 passes in the Docker fixture (the side-channel ssh sees the session in /tmp where it was created → 'gone' → restore runs). On real hosts (pcad-dev) a socket-dir mismatch made the loop return 'unknown' instead, skipping restore — the assertion catches that regression. Scenario 20 (socket-dir mismatch) remains the standalone failing test that catches the actual bug. Scenario 08 (with phase 2) is the regression guard for the restore path itself.
The projection runs `ssh host 'zmx attach gzr-...'` as a NON-interactive command. On real Linux hosts, XDG_RUNTIME_DIR is set by pam_systemd for interactive login shells but NOT for non-interactive ssh commands. zmx's socket-dir resolution (ZMX_DIR > XDG_RUNTIME_DIR > TMPDIR) means: - the projection creates the session in TMPDIR (/tmp/zmx-<uid>) - the pane's interactive shell queries XDG_RUNTIME_DIR (/run/user/<uid>/zmx) → mismatch: `zmx ls` in the pane does NOT list the gzr-* session it's attached to, even though $ZMX_SESSION confirms the attach succeeded. Fix: pin ZMX_DIR=$HOME/.local/state/ghostty-zmx/zmx inline before every remote zmx call (projection attach, snapshot, session-state check, reboot- restore, layout-row kill). The remote .zshrc (install-server) also exports ZMX_DIR for interactive SSH shells so the pane queries the same dir. Side effect (intended): ghostty-zmx sessions live in a dedicated dir, invisible to a plain `zmx ls` — enforcing the 'we only manage gzr-*' boundary at the filesystem level. Manual sessions stay in the default dir. Plain $HOME quoting: the prefix is emitted inside the outer single quotes of the projection command string, so the LOCAL zsh does not expand it; ssh passes it through verbatim and the REMOTE shell expands it. (Backslash- escaped \$HOME yields literal '$HOME'; single-quotes crash zmx.) Changes: - session-manager-lib.zsh: ghostty_zmx_zmx_dir() + _prefix() helpers - session-manager.zsh: projection_command_string prepends ZMX_DIR prefix - cli/projection-loop: _gzmx_zmx_dir local, prefixed all 5 remote zmx calls - cli/remote-layout: zmx kill call has ZMX_DIR inline - install-server.sh: remote-env block exports ZMX_DIR with mkdir -p - e2e/lib/harness.zsh: gzmx_e2e_fixture_zmx() returns zmx path WITH ZMX_DIR prefix ($HOME escaped so the remote shell expands it, not the local zsh) - e2e/20-projection-session-visible.zsh: rewritten to verify via debug log + side-channel ssh (Ghostty AppleScript doesn't expose terminal text) Scenario 20 passes 3/3. Scenario 08 (reboot scrollback restore) is blocked by a separate ssh-transport-dies-early bug (rc=255 after ~5s) — the snapshot is taken by the wrapper but deleted by the close transaction before the test can verify it. That bug needs its own investigation.
ghostty_zmx_snapshot_remote_session fetched zmx history via ghostty_zmx_remote_zmx_for_host (the bare zmx path) without the ZMX_DIR prefix. On Cmd-Q, the poller ran 'ssh -T host zmx history <session>' which queried the default socket dir (TMPDIR) instead of the pinned dir ($HOME/.local/state/ghostty-zmx/zmx), returned empty, and the .tmp snapshot file was rm'd — so the snapshot never appeared. Prepend ghostty_zmx_zmx_dir_prefix to the remote zmx call, mirroring cli/projection-loop's _gzmx_zmx_dir and ghostty_zmx_projection_command_string. The prefix is emitted inside the ssh remote command string, so the REMOTE shell expands $HOME. Finding: F14 (impl-defect — missed follow-through from 70e133c) Blocks: e2e Phase 2 (remote scrollback restore)
The split/tab inherit path (ghostty_zmx_inherit_remote_context_if_any) had two gaps that used ghostty_zmx_remote_zmx_for_host without the ZMX_DIR prefix: 1. The _parent_pid lookup ran '$_remote_zmx list' without ZMX_DIR, querying the wrong socket dir and failing to find the parent session (cwd-fallback path never resolved a parent pid). 2. The _remote_cmd ran '$_remote_zmx attach <session>' without ZMX_DIR, creating the new split/tab session in TMPDIR instead of the pinned dir ($HOME/.local/state/ghostty-zmx/zmx) — a mismatch where the pane's interactive shell cannot see the gzr-* session it is attached to. Prepend ghostty_zmx_zmx_dir_prefix to both the list call and the attach command in _remote_cmd, mirroring cli/projection-loop's _gzmx_zmx_dir and ghostty_zmx_projection_command_string. The prefix is emitted inside the ssh remote command string, so the REMOTE shell expands $HOME. Finding: F14 (impl-defect — missed follow-through from 70e133c)
Black-box e2e covering local zmx-* scrollback restore: inject marker via zmx send, Cmd-Q (reaper snapshots), zmx kill (simulate reboot), reopen, assert banner + marker in zmx history. Includes adversarial probes (double-injection guard, stale-snapshot guard). Adds gzmx_e2e_wait_local_clients helper to the harness (mirrors the remote variant for local zmx-* sessions). Phases 2+3 (remote Cmd-Q-then-kill + live-kill) are deferred to the remote-authoritative migration PR — both hit F15 (zmx run <session> true creates an ephemeral session that exits before the injection lands).
The startup orphan sweep was running unconditionally, even when Ghostty had already exited by the time the reaper started. This deleted the poller's snapshot via _ghostty_zmx_forget_snapshot (called from _ghostty_zmx_cleanup_detached_session), causing the snapshot file to disappear before the restore could use it. Guard the sweep with kill -0 $ghosttyPID: if Ghostty is already dead, the exit-handling code after the loop handles cleanup (the poller has already snapshotted for restore).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Auto-reattaches a remote projection pane when its ssh/tsh connection drops (autossh-inspired), instead of leaving the pane showing "Press any key to close this window" — the symptom reported when picking up a sleeping laptop: the ssh transport dies, the pane goes dead, and nothing reconnects to the surviving remote zmx session.
What changed
cli/projection-loop(new): the wrapper now forks ssh as a background child ("$@" </dev/tty &; wait $!) and runs a reconnect loop instead ofexec "$@". When ssh exits, the loop checks owner liveness (Cmd-Q/Cmd-W → exit), cross-client close (→ exit), then the remote session state:survived(clients=0, network drop) → reattach after backoffgone(session missing, remote reboot) → reboot-restore (zmx run+zmx printscrollback injection) then reattachlive(clients=1, duplicate) → exit (don't reconnect)unknown(session-state check failed, network down) → reconnect without reboot-restore (avoids clobbering a live preserved session if the network recovers)cli/ghostty-zmx: theprojectionsubcommand sourcescli/projection-loopand runs_gzmx_run_projection_loopinstead ofexec "$@". TheGHOSTTY_ZMX_RECONNECT=0kill switch preserves the originalexec "$@"behavior (with a cold-start reboot-restore check first, so the kill switch doesn't lose that feature).session-manager-lib.zsh:ghostty_zmx_descendants_matchingskips the root pid (starts the BFS queue with children) to avoid a false match on theloginprocess whose args contain the full command string.Why the wrapper survives (the structural fix)
Pre-PR, the wrapper
execs ssh — so ssh's death IS the wrapper's death, the surface command exits, and Ghostty shows the "Press any key to close" banner. Post-PR, the wrapper forks ssh as a child and blocks inwait; when ssh dies,waitreturns and the loop runs its bookkeeping. Since the surface command (the wrapper) is alive the entire time, Ghostty never shows the banner. Verified empirically: pre-PR the wrapper pid dies + banner shows; post-PR the pid lives + no banner (see E2E 19).Steady-state cost: zero
While connected (99% of the time), the loop is parked in
wait "$_gzmx_ssh_pid"— a single kernel syscall, no polling, no periodic ssh round-trips. The only cost is on actual disconnects: one ssh round-trip per backoff cycle (1s → 2 → 4 → 8 → 30s cap). No standby battery impact — the continuous-drain concern is the pre-existing poller, not this loop.E2E coverage
e2e/19-reconnect-on-drop.zsh(new): kills the ssh child, asserts clients 0→1 (reattach), window count stays 2, same session name, scrollback marker intact, and the wrapper pid survives (kill -0— a hermetic proxy for "the surface command never exited = no banner", verified in both directions against pre-PR code).tests/projection-loop.zsh(new, 34 cases): decision matrix, owner-dead vs trap-flag, closing-row, session-state parse, interruptible sleep, kill-switch reboot-restore.tests/descendants-root-skip.zsh(new): the login-process false-match regression test.All 3 phases (implementation, e2e, bloat-reduction) converged via the release-grade methodology: 3 impl review rounds (4 lenses each), 3 live e2e runs, 4 bloat reviews. Pre-existing e2e failures (scenarios 12, 18 — from PR #36, not regressions) are tracked separately.
Design
Full design:
Goldmine/open-source/github/ghostty-zmx/2026-07-08-projection-ssh-reattach-design.mdKill switch
GHOSTTY_ZMX_RECONNECT=0restores the originalexec "$@"behavior (with cold-start reboot-restore preserved). The E2E harness setsGHOSTTY_ZMX_RECONNECT_MAX_ATTEMPTS=3to bound retries in CI.