Skip to content

feat(projection): ssh-reattach loop for remote projections - #43

Open
cad0p wants to merge 31 commits into
mainfrom
feat/projection-ssh-reattach
Open

cad0p wants to merge 31 commits into
mainfrom
feat/projection-ssh-reattach

Conversation

@cad0p

@cad0p cad0p commented Jul 8, 2026

Copy link
Copy Markdown
Owner

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 of exec "$@". 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 backoff
    • gone (session missing, remote reboot) → reboot-restore (zmx run + zmx print scrollback injection) then reattach
    • live (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: the projection subcommand sources cli/projection-loop and runs _gzmx_run_projection_loop instead of exec "$@". The GHOSTTY_ZMX_RECONNECT=0 kill switch preserves the original exec "$@" 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_matching skips the root pid (starts the BFS queue with children) to avoid a false match on the login process 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 in wait; when ssh dies, wait returns 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.md

Kill switch

GHOSTTY_ZMX_RECONNECT=0 restores the original exec "$@" behavior (with cold-start reboot-restore preserved). The E2E harness sets GHOSTTY_ZMX_RECONNECT_MAX_ATTEMPTS=3 to bound retries in CI.

cad0p added 19 commits July 8, 2026 14:46
…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
cad0p marked this pull request as ready for review July 9, 2026 00:13
cad0p added 5 commits July 9, 2026 01:38
…-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
cad0p force-pushed the feat/projection-ssh-reattach branch from f6f1b02 to 7f7d18f Compare July 9, 2026 01:38
cad0p added 4 commits July 9, 2026 02:38
…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)
cad0p added 3 commits July 9, 2026 16:42
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant