detached 291cc75

fix(daemon): reserve shutdown preservation before cancelling pane processes

Daemon stop (KillDaemon) could purge a named session's durable record
instead of preserving it. closeMoveLifecycles cancelled the daemon-wide
pane process context as its last step, but terminateAllForShutdown only
called reserveShutdownTeardown for each session afterwards. With the PTYs
already closed, a session's reader could win teardown ownership on EOF
before the reservation was published and treat the cancellation as a
natural final-shell exit (purge=true, reason=session-killed), deleting
the record. The restarted daemon then listed that session as missing, or
restored from a partially written state.

Split the cancellation out of closeMoveLifecycles into
cancelPaneProcesses and call it after every live session has reserved
its shutdown teardown, so PTY EOF during shutdown always resolves to
preserve.

The integration tests asserted that a freshly restarted daemon lists
named sessions as SessionDown. Restoration runs in the background and
publishes each session atomically as SessionDown or SessionUp, so either
is a valid snapshot of a healthy session; assert that instead of an
exact state. Add a deterministic unit test for the shutdown ordering.
commit
291cc757159bab4255dcf76afd3f9f7211d7ec43
author
brice · · 2026-10-03 15:49 UTC
parent
0db7657
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +86 -8
M internal/app/daemon_integration_test.go +20 -6
@@ -412,6 +412,24 @@ func awaitText(t *testing.T, p *typedPump, sz domain.Size, want string) {
412412 _ = awaitScreenText(t, p, sz, want)
413413 }
414414
415+// requireListedAfterRestart asserts that every wanted named session is listed,
416+// in name order, as a healthy durable session. A restarted daemon restores
417+// named sessions in the background, publishing each one atomically from the
418+// stopped registry (SessionDown) to the live one (SessionUp), so a listing taken
419+// right after startup legitimately observes either state per session. Only
420+// SessionBroken, a missing name, or an unexpected extra name is a failure.
421+func requireListedAfterRestart(t *testing.T, sessions []protocol.SessionInfo, names ...string) {
422+ t.Helper()
423+ got := make([]string, 0, len(sessions))
424+ for _, info := range sessions {
425+ got = append(got, info.Name)
426+ require.Contains(t, []protocol.SessionState{protocol.SessionDown, protocol.SessionUp}, info.State,
427+ "session %q must be stopped or restored, not broken", info.Name)
428+ require.False(t, info.Ephemeral, "session %q must be a durable named session", info.Name)
429+ }
430+ require.Equal(t, names, got)
431+}
432+
415433 // awaitDetached consumes typed messages until the daemon signals the session's
416434 // end with the expected Detached reason, ignoring interleaved Output frames.
417435 func awaitDetached(t *testing.T, p *typedPump, reason uint8) {
@@ -789,11 +807,7 @@ func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) {
789807 }
790808
791809 served = start()
792- sessions := listRemoteSessions(t, dir)
793- require.Equal(t, []protocol.SessionInfo{
794- {Name: "alpha", State: protocol.SessionDown},
795- {Name: "beta", State: protocol.SessionDown},
796- }, sessions.Sessions)
810+ requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, "alpha", "beta")
797811
798812 // Kill-all purges every durable record but leaves this daemon serving.
799813 require.NoError(t, killAll(dir))
@@ -943,7 +957,7 @@ func TestIntegration_NamedSessionRestoresPersistedIdentity(t *testing.T) {
943957 daemon.WithSnapshotRepository(repository),
944958 daemon.WithRecoveryCoordinator(coordinator),
945959 )
946- require.Equal(t, []protocol.SessionInfo{{Name: name, State: protocol.SessionDown}}, listRemoteSessions(t, dir).Sessions)
960+ requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, name)
947961 select {
948962 case err := <-served:
949963 t.Fatalf("Serve stopped before the restore: %v", err)
@@ -412,6 +412,24 @@ func awaitText(t *testing.T, p *typedPump, sz domain.Size, want string) {
412 _ = awaitScreenText(t, p, sz, want)412 _ = awaitScreenText(t, p, sz, want)
413 }413 }
414 414
415+// requireListedAfterRestart asserts that every wanted named session is listed,
416+// in name order, as a healthy durable session. A restarted daemon restores
417+// named sessions in the background, publishing each one atomically from the
418+// stopped registry (SessionDown) to the live one (SessionUp), so a listing taken
419+// right after startup legitimately observes either state per session. Only
420+// SessionBroken, a missing name, or an unexpected extra name is a failure.
421+func requireListedAfterRestart(t *testing.T, sessions []protocol.SessionInfo, names ...string) {
422+ t.Helper()
423+ got := make([]string, 0, len(sessions))
424+ for _, info := range sessions {
425+ got = append(got, info.Name)
426+ require.Contains(t, []protocol.SessionState{protocol.SessionDown, protocol.SessionUp}, info.State,
427+ "session %q must be stopped or restored, not broken", info.Name)
428+ require.False(t, info.Ephemeral, "session %q must be a durable named session", info.Name)
429+ }
430+ require.Equal(t, names, got)
431+}
432+
415 // awaitDetached consumes typed messages until the daemon signals the session's433 // awaitDetached consumes typed messages until the daemon signals the session's
416 // end with the expected Detached reason, ignoring interleaved Output frames.434 // end with the expected Detached reason, ignoring interleaved Output frames.
417 func awaitDetached(t *testing.T, p *typedPump, reason uint8) {435 func awaitDetached(t *testing.T, p *typedPump, reason uint8) {
@@ -789,11 +807,7 @@ func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) {
789 }807 }
790 808
791 served = start()809 served = start()
792- sessions := listRemoteSessions(t, dir)810+ requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, "alpha", "beta")
793- require.Equal(t, []protocol.SessionInfo{
794- {Name: "alpha", State: protocol.SessionDown},
795- {Name: "beta", State: protocol.SessionDown},
796- }, sessions.Sessions)
797 811
798 // Kill-all purges every durable record but leaves this daemon serving.812 // Kill-all purges every durable record but leaves this daemon serving.
799 require.NoError(t, killAll(dir))813 require.NoError(t, killAll(dir))
@@ -943,7 +957,7 @@ func TestIntegration_NamedSessionRestoresPersistedIdentity(t *testing.T) {
943 daemon.WithSnapshotRepository(repository),957 daemon.WithSnapshotRepository(repository),
944 daemon.WithRecoveryCoordinator(coordinator),958 daemon.WithRecoveryCoordinator(coordinator),
945 )959 )
946- require.Equal(t, []protocol.SessionInfo{{Name: name, State: protocol.SessionDown}}, listRemoteSessions(t, dir).Sessions)960+ requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, name)
947 select {961 select {
948 case err := <-served:962 case err := <-served:
949 t.Fatalf("Serve stopped before the restore: %v", err)963 t.Fatalf("Serve stopped before the restore: %v", err)
M internal/usecase/daemon/daemon.go +3 -0
@@ -941,9 +941,12 @@ func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdow
941941 // Publish preservation policy for the complete registry snapshot before
942942 // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
943943 // but its disposition must remain daemon shutdown rather than session purge.
944+ // closeMoveLifecycles therefore leaves pane processes running; only this
945+ // ordering, reserve then cancel, keeps a named session durable.
944946 for _, s := range snapshot {
945947 s.reserveShutdownTeardown()
946948 }
949+ d.cancelPaneProcesses()
947950 d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))
948951 for _, s := range snapshot {
949952 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -941,9 +941,12 @@ func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdow
941 // Publish preservation policy for the complete registry snapshot before941 // Publish preservation policy for the complete registry snapshot before
942 // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,942 // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
943 // but its disposition must remain daemon shutdown rather than session purge.943 // but its disposition must remain daemon shutdown rather than session purge.
944+ // closeMoveLifecycles therefore leaves pane processes running; only this
945+ // ordering, reserve then cancel, keeps a named session durable.
944 for _, s := range snapshot {946 for _, s := range snapshot {
945 s.reserveShutdownTeardown()947 s.reserveShutdownTeardown()
946 }948 }
949+ d.cancelPaneProcesses()
947 d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))950 d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))
948 for _, s := range snapshot {951 for _, s := range snapshot {
949 // Cancellation and PTY closure must not wait behind a teardown owner that952 // Cancellation and PTY closure must not wait behind a teardown owner that
M internal/usecase/daemon/move_lifecycle.go +17 -2
@@ -120,8 +120,11 @@ func (r *moveLifecycleReservation) Release() {
120120 }
121121
122122 // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary
123-// routing, then drains reservations without holding either daemon mutex. Pane
124-// process lifetime is cancelled only after the final reservation releases.
123+// routing, then drains reservations without holding either daemon mutex. It
124+// deliberately does not cancel pane processes: the caller must first publish the
125+// shutdown-preservation policy for every live session, then call
126+// cancelPaneProcesses, so a PTY EOF caused by the cancellation can never be
127+// mistaken for a natural final-shell exit that purges a named session.
125128 func (d *Daemon) closeMoveLifecycles() {
126129 if d == nil {
127130 return
@@ -146,6 +149,18 @@ func (d *Daemon) closeMoveLifecycles() {
146149 <-changed
147150 d.moveLifecycleMu.Lock()
148151 }
152+ d.moveLifecycleMu.Unlock()
153+}
154+
155+// cancelPaneProcesses cancels the daemon-wide pane process lifetime, closing
156+// every PTY. It must run only after closeMoveLifecycles has drained the final
157+// move reservation and after every live session has reserved its shutdown
158+// teardown (see terminateAllForShutdown).
159+func (d *Daemon) cancelPaneProcesses() {
160+ if d == nil {
161+ return
162+ }
163+ d.moveLifecycleMu.Lock()
149164 cancel := d.paneProcessCancel
150165 d.moveLifecycleMu.Unlock()
151166 if cancel != nil {
@@ -120,8 +120,11 @@ func (r *moveLifecycleReservation) Release() {
120 }120 }
121 121
122 // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary122 // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary
123-// routing, then drains reservations without holding either daemon mutex. Pane123+// routing, then drains reservations without holding either daemon mutex. It
124-// process lifetime is cancelled only after the final reservation releases.124+// deliberately does not cancel pane processes: the caller must first publish the
125+// shutdown-preservation policy for every live session, then call
126+// cancelPaneProcesses, so a PTY EOF caused by the cancellation can never be
127+// mistaken for a natural final-shell exit that purges a named session.
125 func (d *Daemon) closeMoveLifecycles() {128 func (d *Daemon) closeMoveLifecycles() {
126 if d == nil {129 if d == nil {
127 return130 return
@@ -146,6 +149,18 @@ func (d *Daemon) closeMoveLifecycles() {
146 <-changed149 <-changed
147 d.moveLifecycleMu.Lock()150 d.moveLifecycleMu.Lock()
148 }151 }
152+ d.moveLifecycleMu.Unlock()
153+}
154+
155+// cancelPaneProcesses cancels the daemon-wide pane process lifetime, closing
156+// every PTY. It must run only after closeMoveLifecycles has drained the final
157+// move reservation and after every live session has reserved its shutdown
158+// teardown (see terminateAllForShutdown).
159+func (d *Daemon) cancelPaneProcesses() {
160+ if d == nil {
161+ return
162+ }
163+ d.moveLifecycleMu.Lock()
149 cancel := d.paneProcessCancel164 cancel := d.paneProcessCancel
150 d.moveLifecycleMu.Unlock()165 d.moveLifecycleMu.Unlock()
151 if cancel != nil {166 if cancel != nil {
M internal/usecase/daemon/move_lifecycle_test.go +46 -0
@@ -272,3 +272,49 @@ func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
272272 require.Nil(t, reservation)
273273 awaitDaemonMoveActive(t, d, 0)
274274 }
275+
276+// TestShutdownReservesPreservationBeforeCancellingPaneProcesses guards the
277+// daemon-stop ordering that keeps named sessions durable. Cancelling the
278+// daemon-wide pane lifetime closes every PTY; a session whose reader sees that
279+// EOF before its shutdown-preservation policy is reserved takes the natural
280+// final-shell-exit path and purges its durable record instead of preserving it.
281+// The drain step must therefore leave pane processes running; only the full
282+// shutdown cancels them, after reserving preservation for every live session.
283+func TestShutdownReservesPreservationBeforeCancellingPaneProcesses(t *testing.T) {
284+ tests := []struct {
285+ name string
286+ run func(d *Daemon)
287+ wantCancelled bool
288+ wantReserved bool
289+ }{
290+ {
291+ name: "draining move admission leaves pane processes running",
292+ run: func(d *Daemon) { d.closeMoveLifecycles() },
293+ },
294+ {
295+ name: "full shutdown reserves preservation and cancels pane processes",
296+ run: func(d *Daemon) { d.shutdownAll(protocol.ReasonServerShutdown) },
297+ wantCancelled: true,
298+ wantReserved: true,
299+ },
300+ }
301+
302+ for _, tt := range tests {
303+ t.Run(tt.name, func(t *testing.T) {
304+ d := newTestDaemon(t, nil, stubClock{})
305+ sess := &session{sessionCore: sessionCore{id: "sess-0", name: "work"}}
306+ d.mu.Lock()
307+ require.True(t, d.registerSessionLocked(sess))
308+ d.mu.Unlock()
309+
310+ tt.run(d)
311+
312+ cancelled := d.paneProcessCtx.Err() != nil
313+ sess.teardownMu.Lock()
314+ reserved := sess.shutdownTeardownRequested
315+ sess.teardownMu.Unlock()
316+ require.Equal(t, tt.wantCancelled, cancelled, "pane process cancellation")
317+ require.Equal(t, tt.wantReserved, reserved, "shutdown preservation reservation")
318+ })
319+ }
320+}
@@ -272,3 +272,49 @@ func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
272 require.Nil(t, reservation)272 require.Nil(t, reservation)
273 awaitDaemonMoveActive(t, d, 0)273 awaitDaemonMoveActive(t, d, 0)
274 }274 }
275+
276+// TestShutdownReservesPreservationBeforeCancellingPaneProcesses guards the
277+// daemon-stop ordering that keeps named sessions durable. Cancelling the
278+// daemon-wide pane lifetime closes every PTY; a session whose reader sees that
279+// EOF before its shutdown-preservation policy is reserved takes the natural
280+// final-shell-exit path and purges its durable record instead of preserving it.
281+// The drain step must therefore leave pane processes running; only the full
282+// shutdown cancels them, after reserving preservation for every live session.
283+func TestShutdownReservesPreservationBeforeCancellingPaneProcesses(t *testing.T) {
284+ tests := []struct {
285+ name string
286+ run func(d *Daemon)
287+ wantCancelled bool
288+ wantReserved bool
289+ }{
290+ {
291+ name: "draining move admission leaves pane processes running",
292+ run: func(d *Daemon) { d.closeMoveLifecycles() },
293+ },
294+ {
295+ name: "full shutdown reserves preservation and cancels pane processes",
296+ run: func(d *Daemon) { d.shutdownAll(protocol.ReasonServerShutdown) },
297+ wantCancelled: true,
298+ wantReserved: true,
299+ },
300+ }
301+
302+ for _, tt := range tests {
303+ t.Run(tt.name, func(t *testing.T) {
304+ d := newTestDaemon(t, nil, stubClock{})
305+ sess := &session{sessionCore: sessionCore{id: "sess-0", name: "work"}}
306+ d.mu.Lock()
307+ require.True(t, d.registerSessionLocked(sess))
308+ d.mu.Unlock()
309+
310+ tt.run(d)
311+
312+ cancelled := d.paneProcessCtx.Err() != nil
313+ sess.teardownMu.Lock()
314+ reserved := sess.shutdownTeardownRequested
315+ sess.teardownMu.Unlock()
316+ require.Equal(t, tt.wantCancelled, cancelled, "pane process cancellation")
317+ require.Equal(t, tt.wantReserved, reserved, "shutdown preservation reservation")
318+ })
319+ }
320+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close