client: close the window on shell exit, re-dial on transport loss #156

Merged
natsukium merged 1 commit from fix/close-on-exit-22 into main 2026-09-06 12:06:46 +09:00
Owner

Fixes #22.

A window used to answer its shell exiting by attaching to another session
on the daemon — the one it was on before, else a neighbor in the session
ring — an unstated, uninspectable pick that could land a ctrl-D on an
unrelated project's shell. It now closes, and the session lingers for the
post-exit grace so felis attach <id> reopens one closed by mistake; a
window that arrived by felis window retarget or a transient pipe/run
still returns to its origin.

Transport loss was the opposite mistake: a bare channel close carries no
verdict about the session, yet the window exited on it. It now keeps the
last screen behind a — disconnected title with a stopped cursor and
re-dials the same daemon and session — six attempts over roughly 23 s of
backoff, each attempt bounded at ten seconds so a half-open ssh route
cannot outwait the ladder. Over --host that is the reconnect it sounds
like; against a local daemon the session died with the daemon, so the
first attempt's replacement daemon refuses the re-attach and the window
closes at once. Every ending names the reason and the remedy in the log
and exits nonzero: 1 when the session is gone, 2 when the daemon
refused the window or never answered.

Doc cascade: docs/explanation/architecture/session-lifecycle.md (the
exit and reconnect argument), docs/reference/cli.md (window close and
the reconnect exit codes), docs/reference/ipc.md and
crates/felis-protocol/proto/felis.proto (live_only wording),
docs/reference/spec.md, docs/how-to/attach-over-ssh.md,
docs/how-to/update-felis.md, and CHANGELOG.md.

Verified: just check green (fmt, clippy, nextest, deny, proto-compat);
reviewed by pi luna + pi sol; docs proofread by Gemini gemini-3.8-flash-high

Deferred

  • End-to-end test: a fake daemon drops the transport and never returns,
    asserting the client process exits nonzero within the retry budget.
    Needs a GUI-client process harness that does not exist in the repo, and
    the client cannot run headless on this host. The pieces are covered by
    unit tests: ExitReason -> code/remedy, ReconnectError ->
    ExitReason, and the retry bound.
  • A test asserting no exit path calls pick_exit_switch_target. Written,
    then dropped: it asserted on include_str! of a sibling module rather
    than on behavior. The removal is compile-enforced now that
    Landing::PickExit and pick_exit_switch_target no longer exist.
  • The exit-ordering test never delivers SessionExited or drives the
    event handler, so it pins ShadowScreen::apply rather than the
    ordering it claims. Reported twice by the two pi models against the
    same test; merged and fixed under one finding.
Fixes #22. A window used to answer its shell exiting by attaching to another session on the daemon — the one it was on before, else a neighbor in the session ring — an unstated, uninspectable pick that could land a ctrl-D on an unrelated project's shell. It now closes, and the session lingers for the post-exit grace so `felis attach <id>` reopens one closed by mistake; a window that arrived by `felis window retarget` or a transient `pipe`/`run` still returns to its origin. Transport loss was the opposite mistake: a bare channel close carries no verdict about the session, yet the window exited on it. It now keeps the last screen behind a `— disconnected` title with a stopped cursor and re-dials the same daemon and session — six attempts over roughly 23 s of backoff, each attempt bounded at ten seconds so a half-open `ssh` route cannot outwait the ladder. Over `--host` that is the reconnect it sounds like; against a local daemon the session died with the daemon, so the first attempt's replacement daemon refuses the re-attach and the window closes at once. Every ending names the reason and the remedy in the log and exits nonzero: `1` when the session is gone, `2` when the daemon refused the window or never answered. Doc cascade: `docs/explanation/architecture/session-lifecycle.md` (the exit and reconnect argument), `docs/reference/cli.md` (window close and the reconnect exit codes), `docs/reference/ipc.md` and `crates/felis-protocol/proto/felis.proto` (`live_only` wording), `docs/reference/spec.md`, `docs/how-to/attach-over-ssh.md`, `docs/how-to/update-felis.md`, and `CHANGELOG.md`. Verified: just check green (fmt, clippy, nextest, deny, proto-compat); reviewed by pi luna + pi sol; docs proofread by Gemini gemini-3.8-flash-high ## Deferred - End-to-end test: a fake daemon drops the transport and never returns, asserting the client process exits nonzero within the retry budget. Needs a GUI-client process harness that does not exist in the repo, and the client cannot run headless on this host. The pieces are covered by unit tests: `ExitReason` -> code/remedy, `ReconnectError` -> `ExitReason`, and the retry bound. - A test asserting no exit path calls `pick_exit_switch_target`. Written, then dropped: it asserted on `include_str!` of a sibling module rather than on behavior. The removal is compile-enforced now that `Landing::PickExit` and `pick_exit_switch_target` no longer exist. - The exit-ordering test never delivers `SessionExited` or drives the event handler, so it pins `ShadowScreen::apply` rather than the ordering it claims. Reported twice by the two pi models against the same test; merged and fixed under one finding.
client: close the window on shell exit, re-dial on transport loss
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 48s
bench / Criterion regression gate (pull_request) Failing after 2m22s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m7s
pr / nix flake check (pull_request) Successful in 37s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m43s
pr / wire schema is compatible with the base (pull_request) Successful in 9s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 56s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 6m35s
windows / frontend smoke (Windows) (pull_request) Successful in 1m53s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
fc49a2160b
A window used to answer its shell exiting by attaching to another
session on the daemon: the one it was on before, else the nearest
neighbor in the session ring. That is a selection rule the user never
stated and cannot inspect, since no in-window picker shows the ring, so
a ctrl-D could land the window on an unrelated project's shell, in
another working directory or on another host. Closing is what a window
holding exactly one shell owes that shell's end, and the mistaken exit
stays recoverable through the post-exit grace and `felis attach <id>`.
The origin re-dial survives because a retarget or a transient visit is
an operation the user initiated, so it returns them where they came
from rather than choosing for them.

Transport loss was the opposite mistake: a bare channel close carries no
verdict about the session, yet the window exited on it. It now keeps the
last screen behind a disconnected title, stops the cursor blink, and
re-dials the same carrier and session on a bounded ladder. Over SSH the
relay dies with the connection while the session survives on the remote
daemon, which is the case worth retrying; a local close is the daemon's
own death, so the ladder auto-spawns a replacement daemon the way a
window launch does, and the attach against it is refused at once rather
than spending six attempts on a socket that will never answer. Each
attempt carries its own ten-second bound, because the schedule only
counts the sleeps between attempts and an `ssh` child on a half-open
route can outwait every one of them. Every terminal state closes the
window naming the reason, the remedy, and a nonzero exit status: felis
draws no dialog to say it in, and a window parked on no session is what
principle 1 forbids.

A landing that fails after the transport already died now starts the
ladder too: the close it absorbed while the switch was in flight latches
the connection as lost, so the window cannot settle back onto a carrier
that is gone. The same rule clears an outstanding pipe-region request on
both the reconnect and the exit path, since its reply rode the dead
connection and nothing else would ever release the window's busy state.

Refs #22
natsukium force-pushed fix/close-on-exit-22 from fc49a2160b
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 48s
bench / Criterion regression gate (pull_request) Failing after 2m22s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m7s
pr / nix flake check (pull_request) Successful in 37s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m43s
pr / wire schema is compatible with the base (pull_request) Successful in 9s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 56s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 6m35s
windows / frontend smoke (Windows) (pull_request) Successful in 1m53s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
to a1c3b2e084
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 49s
bench / Criterion regression gate (pull_request) Failing after 2m13s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m16s
pr / nix flake check (pull_request) Successful in 40s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m17s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m3s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / cargo nextest (Windows) (pull_request) Successful in 6m23s
windows / frontend smoke (Windows) (pull_request) Successful in 1m38s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
2026-09-06 04:08:37 +09:00
Compare
natsukium force-pushed fix/close-on-exit-22 from a1c3b2e084
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m13s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m3s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / frontend smoke (Windows) (pull_request) Successful in 1m38s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 49s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m16s
pr / nix flake check (pull_request) Successful in 40s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m17s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
windows / cargo nextest (Windows) (pull_request) Successful in 6m23s
to d09d32c465
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
bench / Criterion regression gate (pull_request) Successful in 2m10s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m9s
pr / nix flake check (pull_request) Successful in 17s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m53s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 57s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 25s
windows / cargo nextest (Windows) (pull_request) Successful in 7m21s
windows / frontend smoke (Windows) (pull_request) Successful in 1m55s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (push) Has been skipped
darwin / build felis (aarch64-darwin) (push) Successful in 13s
windows / cargo nextest (Windows) (push) Successful in 6m41s
windows / frontend smoke (Windows) (push) Successful in 2m18s
windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 2m29s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m30s
pr / nix flake check (push) Successful in 8s
pr / cargo build / clippy / test / deny (push) Successful in 1m50s
pr / wire schema is compatible with the base (push) Successful in 10s
pr / frontend smoke (x86_64-linux) (push) Successful in 7s
windows / cargo clippy (Windows cross) (push) Successful in 13s
pr / publish felis (x86_64-linux) (push) Successful in 13s
2026-09-06 11:56:28 +09:00
Compare
natsukium deleted branch fix/close-on-exit-22 2026-09-06 12:06:46 +09:00
Sign in to join this conversation.
No description provided.