[v0.1/P1] Close on shell exit and reconnect the same session after transport loss #22
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#22
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P1.3). Supersedes the lifecycle decision in #6.
Why
Normal shell exit currently moves a window to an unrelated session, while transport loss closes the window. Silent selection can cross privilege, host, or working-directory boundaries, and the disconnect behavior contradicts the update guide.
Scope
Acceptance criteria
Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.Claim check
Mostly accurate; one overstatement.
crates/felis-client/src/event_handler.rs:754-786:AppEvent::SessionExitedfor the current session ends inself.begin_exit_switch(false)(:785);crates/felis-client/src/app_methods.rsbegin_exit_switchdialsLanding::PickExit { anchor, previous }(:976-979);crates/felis-client-core/src/dial.rs:302-309picks viapick_exit_switch_target(&sessions, anchor, previous)and attaches live-only. Only when nothing is left doescontinue_exit_ladder(app_methods.rs:1101-1113) return to origin or close. This is documented as intended:docs/explanation/architecture/session-lifecycle.md:402(table row "switches to another session") and:420-431(the rationale: "keeps its window open ... prefers the session the window was attached to before"). So the issue reverses a recorded design decision, and the report must say so.event_handler.rson_daemon_closed:ClosedOutcome::Exit => { info!("daemon disconnected; exiting"); event_loop.exit(); }(viadaemon_closed_outcome, tested atcrates/felis-client/src/tests.rs:1530). No retry, no indicator.docs/how-to/update-felis.md:64-65: "A window left open across the restart reconnects on its own, and a newer daemon does not need it rebuilt." False today, and after a daemon restart the session is gone anyway (crates/felis-daemon/src/main.rs:31-34: no signal handling, PTY masters close; #32 deferred). This sentence is wrong regardless of what #22 decides and can be fixed on its own.felis-transport), so no privilege boundary is crossed. Host and working-directory boundaries are real: a ctrl-D lands on whatever unrelated project's shell sorts next in the ring.crates/felis-daemon/src/serve/session_task.rs:1201-1211:notify_shell_exitpushesSessionExitedafter the queued grid events and just beforeevict_all, and the pump flushes in order, so the shadow has applied the final rows by the time the client handlesSessionExited. "Painted" is a client-side redraw ordering question only.Verdict
accept-with-changes.
Principle check for the reversal: the exit-driven pick is the client choosing a session for the user on a rule they did not state (principle 4's spirit; the roster order is not something the user sees, since there is no in-window picker), and it makes "a window holds exactly one shell" (principle 1) hold only until that shell exits, after which the window silently becomes another shell's. Closing on exit is what every non-multiplexing terminal does, and the session's own recovery story (post-exit grace,
felis attach) is untouched. Accept the close.Scope changes:
--host. On the local socket a close is daemon death, and until #32 that session no longer exists; a retry ofAttach { id }can only end inUnknownSession(or a fresh autospawned daemon that has never heard of it). Over SSH the relay dies with the connection but the session survives (crates/felis-daemon/src/relay.rs:5-7), and retrying the same id is exactly right. Implement one retry ladder for both carriers (the local case terminates on the firstUnknownSession), but size the schedule for the SSH case and say in the docs that local-carrier loss is daemon loss.felis attach <id>once the daemon is back), not an in-window message: felis has no dialog surface, and a window with no session is the thing principle 1 forbids. During retries the shadow stays visible with an indicator (see approach).Approach
Client (
crates/felis-client)event_handler.rs:754-786SessionExited: keep the transient branch (PipeState::Active→begin_pipe_return) and the in-flight-switch guard; replace the finalelse(pending_switch.cancel(); begin_exit_switch(false)) withif !self.continue_exit_ladder("shell exited") { … } else { event_loop.exit() }, i.e. return-to-origin whenself.originwas set by a cross-carrier retarget (:591-597) or a transient, else exit.begin_exit_switch,Landing::PickExit(felis-client-core/src/dial.rs:228,:251,:286,:302-309),pick_exit_switch_target,SwitchIntent::Exit(switch_intent.rs:31), and theexit_driven/retryfields ofSwitchState::InFlight;SwitchState::carry_exitcollapses to "an in-flight landing that observes the exit closes the window if it lands on the dead session, else proceeds".on_daemon_closedClosedOutcome::Exit: becomesClosedOutcome::Reconnectwhen the close was not a switch, not a transient, and not typed (Evicted,SessionExitedalready handled). NewApp::reconnect_state: Option<Reconnect { attempt, deadline }>;dial_in_background(dial_and_land(carrier, offer, Landing::Attach { id: current, intent: Automatic }))withfelis_transport::retry::RetryPolicy(already used byconnect_carrier_with_retry,connector.rs:218-223) bounded to, say, 6 attempts / 30 s. Landing failure classification:AttachFailed { UnknownSession | SessionExited | SessionEnding }, auth/major refusal (ConnectErrorvariants thatconnect_carrier_with_retryalready treats as non-retryable,connector.rs:1848-1887tests), and budget exhaustion →event_loop.exit()with atracing::error!naming the reason and remedy, and a nonzero process exit.reconnect_stateisSome, set the window title to"<title> — disconnected"and stop the cursor blink (both client-local presentation, principle 3); no daemon change.Client-core:
Reconnectoralready carries carrier+offer; addRetryPolicy::reconnect()beside the existingconnect_carrier_with_retrypolicy so both are in one place.Docs cascade
docs/explanation/architecture/session-lifecycle.md:390-458: rewrite the table row and the two paragraphs after it (close on exit; the only automatic navigation left is return-to-origin for transients and cross-carrier windows; record the rejected alternative, the roster pick, and why: unstated selection rule, host/cwd surprise; "Revisit if" a user asks for a configured exit action, which would be a typedAction, not a heuristic). Update the "Picking the session a chord lands on" section (:595-630) to drop the shell-exit exception. Add a "Transport loss" subsection with the retry bounds and the local-vs-SSH note.docs/how-to/update-felis.md:64-65(remove the reconnect claim; state that windows close on daemon restart and sessions do not survive it until #32), and:41-52.docs/reference/cli.mdwindow/exit-status section (the client's nonzero exit on a terminal reconnect state),docs/reference/ipc.md:636-642(thelive_onlysentence lists "the exit-driven switch" as a user; drop it),docs/reference/spec.mdif a REQ covers the exit behaviour (grepexit-driven),skills/felis/SKILL.mdwhere it describes what a window does on exit,CHANGELOG.md(user-visible behaviour change).Tests
crates/felis-client/src/tests.rs:1691-1720(an_in_flight_landings_retry_becomes_the_exit_ladders,a_spent_retry_budget_is_not_refilled_by_the_exit) are rewritten for the new ladder;:1530-1560gain theReconnectoutcome cases.ConnectError/AttachFailure→ retry or terminal).RowDelta,RowDelta,SessionExited; assert the shadow holds both rows when the exit handler runs.session_task.rs:3640-3665already pins the push.Dependencies
update-felis.mdcan land today.supersededverdicts must not be reported for a window that closed on exit).Risk/effort
L. The switch/landing state machine in
felis-client(switch_state,pending_switch,pipe_state,origin) is the densest code in the client and every transient path (run,pipe, cross-carrier retarget) rides it. Main risk: breaking transient return-to-origin while removing the pick. Mitigation: deletePickExitin one commit with its tests, add reconnect in a second.Labels
Keep
priority/P1,release/v0.1.0: "observable session/window behaviour" is inside the freeze boundary, and the update guide is wrong today. Suggest splitting theupdate-felis.mdcorrection into its owndocs(manual)commit immediately.Review amendments (round 1)
SessionExited) before closing, then close on the next event-loop turn". Rationale to record in the issue and the session-lifecycle explanation twin: the window closes, so a paint has no observer; the pump's in-order flush already guarantees the apply ordering. The client still callsrequest_redrawbefore deferring the close so any frame that does get presented is the final state; the test asserts the shadow ordering, not pixels.event_loop.exit()alone leavesmainreturningOk(())(felis-client/src/main.rs:951).Apprecordsterminal_exit: Option<ExitReason>before callingexit(); afterrun_appreturns,mainmapsSome(reason)to a nonzeroExitCode(and logs the reason and the remedy). Reasons: reconnect budget exhausted,UnknownSession, auth/major refusal. Test: the fake-daemon harness drops the transport and never comes back; the client process exits nonzero within the retry budget.