detached 9cff8f2

fix(daemon): preserve sessions during shutdown (#251)

* fix(daemon): preserve sessions during shutdown

* fix(daemon): surface kill failures

* test(app): bound kill reply waits
commit
9cff8f25f289b57ffdbf410f7f1282c94ec44f38
author
bnema · · 2026-09-03 19:43 UTC
committer
GitHub · · 2026-09-03 19:43 UTC
parent
b8794ea
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +313 -66
M internal/app/daemon_integration_test.go +132 -4
@@ -7,6 +7,7 @@ import (
77 "crypto/rand"
88 "encoding/binary"
99 "errors"
10+ "fmt"
1011 "io"
1112 "log/slog"
1213 "net"
@@ -122,19 +123,87 @@ func listRemoteSessions(t *testing.T, dir string) protocol.Sessions {
122123 }
123124
124125 func killAll(dir string) error {
126+ return requestKill(dir, protocol.KillAll)
127+}
128+
129+func killDaemon(dir string) error {
130+ return requestKill(dir, protocol.KillDaemon)
131+}
132+
133+func requestKill(dir string, scope protocol.KillScope) error {
125134 tr, err := ipc.DialContext(context.Background(), dir)
126135 if err != nil {
127136 return err
128137 }
129138 defer func() { _ = tr.Close() }()
130- if err := tr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})}); err != nil {
139+ if err := tr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: scope})}); err != nil {
140+ return err
141+ }
142+ reply, err := receiveKillReply(tr, daemonStopTimeout)
143+ if errors.Is(err, context.DeadlineExceeded) {
131144 return err
132145 }
133- _, err = tr.Recv()
134146 if errors.Is(err, io.EOF) {
135147 return nil
136148 }
137- return err
149+ if err != nil {
150+ return err
151+ }
152+ if reply.Type != wire.MsgError {
153+ return nil
154+ }
155+ em, err := wire.UnmarshalErrorMsg(reply.Payload)
156+ if err != nil {
157+ return fmt.Errorf("decoding kill error: %w", err)
158+ }
159+ return fmt.Errorf("kill failed: %s", em.Text)
160+}
161+
162+func receiveKillReply(tr wire.Transport, timeout time.Duration) (wire.Frame, error) {
163+ type result struct {
164+ frame wire.Frame
165+ err error
166+ }
167+ received := make(chan result, 1)
168+ go func() {
169+ frame, err := tr.Recv()
170+ received <- result{frame: frame, err: err}
171+ }()
172+
173+ timer := time.NewTimer(timeout)
174+ defer timer.Stop()
175+ select {
176+ case reply := <-received:
177+ return reply.frame, reply.err
178+ case <-timer.C:
179+ _ = tr.Close()
180+ reply := <-received
181+ return reply.frame, errors.Join(context.DeadlineExceeded, reply.err)
182+ }
183+}
184+
185+type blockingRecvTransport struct {
186+ closed chan struct{}
187+ once sync.Once
188+}
189+
190+func (*blockingRecvTransport) Send(wire.Frame) error { return nil }
191+
192+func (t *blockingRecvTransport) Recv() (wire.Frame, error) {
193+ <-t.closed
194+ return wire.Frame{}, io.ErrClosedPipe
195+}
196+
197+func (t *blockingRecvTransport) Close() error {
198+ t.once.Do(func() { close(t.closed) })
199+ return nil
200+}
201+
202+func TestReceiveKillReplyTimesOut(t *testing.T) {
203+ tr := &blockingRecvTransport{closed: make(chan struct{})}
204+ _, err := receiveKillReply(tr, 10*time.Millisecond)
205+ require.ErrorIs(t, err, context.DeadlineExceeded)
206+ require.ErrorIs(t, err, io.ErrClosedPipe)
138207 }
139208
140209 func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T) {
@@ -504,6 +573,65 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
504573 }
505574 }
506575
576+func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) {
577+ sz := domain.Size{Cols: 80, Rows: 24}
578+ stateDir := filepath.Join(t.TempDir(), "state")
579+ dir := filepath.Join(t.TempDir(), "runtime")
580+ repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
581+
582+ start := func() <-chan error {
583+ opened, err := persist.OpenOrCreate(stateDir)
584+ require.NoError(t, err)
585+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
586+ _, served := startDaemonInDir(t, dir,
587+ daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}),
588+ daemon.WithCatalogue(opened.Catalogue, opened.Records),
589+ daemon.WithSnapshotRepository(repository),
590+ daemon.WithRecoveryCoordinator(coordinator),
591+ )
592+ return served
593+ }
594+
595+ served := start()
596+ first, _ := attach(t, dir, protocol.IntentNew, "alpha", sz)
597+ require.NoError(t, first.Close())
598+ second, _ := attach(t, dir, protocol.IntentNew, "beta", sz)
599+ require.NoError(t, second.Close())
600+
601+ require.NoError(t, killDaemon(dir))
602+ select {
603+ case err := <-served:
604+ require.NoError(t, err)
605+ case <-time.After(5 * time.Second):
606+ t.Fatal("daemon did not stop")
607+ }
608+
609+ served = start()
610+ sessions := listRemoteSessions(t, dir)
611+ require.Equal(t, []protocol.SessionInfo{
612+ {Name: "alpha", State: protocol.SessionDown},
613+ {Name: "beta", State: protocol.SessionDown},
614+ }, sessions.Sessions)
615+
616+ require.NoError(t, killAll(dir))
617+ select {
618+ case err := <-served:
619+ require.NoError(t, err)
620+ case <-time.After(5 * time.Second):
621+ t.Fatal("restarted daemon did not stop after kill all")
622+ }
623+
624+ served = start()
625+ require.Empty(t, listRemoteSessions(t, dir).Sessions)
626+ require.NoError(t, killDaemon(dir))
627+ select {
628+ case err := <-served:
629+ require.NoError(t, err)
630+ case <-time.After(5 * time.Second):
631+ t.Fatal("empty daemon did not stop")
632+ }
633+}
634+
507635 func TestIntegration_NamedSurvivesReattach(t *testing.T) {
508636 sz := domain.Size{Cols: 80, Rows: 24}
509637 dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
@@ -1313,7 +1441,7 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
13131441 killTr, err := ipc.DialContext(context.Background(), dir)
13141442 require.NoError(t, err)
13151443 defer func() { _ = killTr.Close() }()
1316- require.NoError(t, killTr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})}))
1444+ require.NoError(t, killTr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillAll})}))
13171445
13181446 _, err = killTr.Recv()
13191447 require.ErrorIs(t, err, io.EOF)
@@ -7,6 +7,7 @@ import (
7 "crypto/rand"7 "crypto/rand"
8 "encoding/binary"8 "encoding/binary"
9 "errors"9 "errors"
10+ "fmt"
10 "io"11 "io"
11 "log/slog"12 "log/slog"
12 "net"13 "net"
@@ -122,19 +123,87 @@ func listRemoteSessions(t *testing.T, dir string) protocol.Sessions {
122 }123 }
123 124
124 func killAll(dir string) error {125 func killAll(dir string) error {
126+ return requestKill(dir, protocol.KillAll)
127+}
128+
129+func killDaemon(dir string) error {
130+ return requestKill(dir, protocol.KillDaemon)
131+}
132+
133+func requestKill(dir string, scope protocol.KillScope) error {
125 tr, err := ipc.DialContext(context.Background(), dir)134 tr, err := ipc.DialContext(context.Background(), dir)
126 if err != nil {135 if err != nil {
127 return err136 return err
128 }137 }
129 defer func() { _ = tr.Close() }()138 defer func() { _ = tr.Close() }()
130- if err := tr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})}); err != nil {139+ if err := tr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: scope})}); err != nil {
140+ return err
141+ }
142+ reply, err := receiveKillReply(tr, daemonStopTimeout)
143+ if errors.Is(err, context.DeadlineExceeded) {
131 return err144 return err
132 }145 }
133- _, err = tr.Recv()
134 if errors.Is(err, io.EOF) {146 if errors.Is(err, io.EOF) {
135 return nil147 return nil
136 }148 }
137- return err149+ if err != nil {
150+ return err
151+ }
152+ if reply.Type != wire.MsgError {
153+ return nil
154+ }
155+ em, err := wire.UnmarshalErrorMsg(reply.Payload)
156+ if err != nil {
157+ return fmt.Errorf("decoding kill error: %w", err)
158+ }
159+ return fmt.Errorf("kill failed: %s", em.Text)
160+}
161+
162+func receiveKillReply(tr wire.Transport, timeout time.Duration) (wire.Frame, error) {
163+ type result struct {
164+ frame wire.Frame
165+ err error
166+ }
167+ received := make(chan result, 1)
168+ go func() {
169+ frame, err := tr.Recv()
170+ received <- result{frame: frame, err: err}
171+ }()
172+
173+ timer := time.NewTimer(timeout)
174+ defer timer.Stop()
175+ select {
176+ case reply := <-received:
177+ return reply.frame, reply.err
178+ case <-timer.C:
179+ _ = tr.Close()
180+ reply := <-received
181+ return reply.frame, errors.Join(context.DeadlineExceeded, reply.err)
182+ }
183+}
184+
185+type blockingRecvTransport struct {
186+ closed chan struct{}
187+ once sync.Once
188+}
189+
190+func (*blockingRecvTransport) Send(wire.Frame) error { return nil }
191+
192+func (t *blockingRecvTransport) Recv() (wire.Frame, error) {
193+ <-t.closed
194+ return wire.Frame{}, io.ErrClosedPipe
195+}
196+
197+func (t *blockingRecvTransport) Close() error {
198+ t.once.Do(func() { close(t.closed) })
199+ return nil
200+}
201+
202+func TestReceiveKillReplyTimesOut(t *testing.T) {
203+ tr := &blockingRecvTransport{closed: make(chan struct{})}
204+ _, err := receiveKillReply(tr, 10*time.Millisecond)
205+ require.ErrorIs(t, err, context.DeadlineExceeded)
206+ require.ErrorIs(t, err, io.ErrClosedPipe)
138 }207 }
139 208
140 func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T) {209 func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T) {
@@ -504,6 +573,65 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
504 }573 }
505 }574 }
506 575
576+func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) {
577+ sz := domain.Size{Cols: 80, Rows: 24}
578+ stateDir := filepath.Join(t.TempDir(), "state")
579+ dir := filepath.Join(t.TempDir(), "runtime")
580+ repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
581+
582+ start := func() <-chan error {
583+ opened, err := persist.OpenOrCreate(stateDir)
584+ require.NoError(t, err)
585+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
586+ _, served := startDaemonInDir(t, dir,
587+ daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}),
588+ daemon.WithCatalogue(opened.Catalogue, opened.Records),
589+ daemon.WithSnapshotRepository(repository),
590+ daemon.WithRecoveryCoordinator(coordinator),
591+ )
592+ return served
593+ }
594+
595+ served := start()
596+ first, _ := attach(t, dir, protocol.IntentNew, "alpha", sz)
597+ require.NoError(t, first.Close())
598+ second, _ := attach(t, dir, protocol.IntentNew, "beta", sz)
599+ require.NoError(t, second.Close())
600+
601+ require.NoError(t, killDaemon(dir))
602+ select {
603+ case err := <-served:
604+ require.NoError(t, err)
605+ case <-time.After(5 * time.Second):
606+ t.Fatal("daemon did not stop")
607+ }
608+
609+ served = start()
610+ sessions := listRemoteSessions(t, dir)
611+ require.Equal(t, []protocol.SessionInfo{
612+ {Name: "alpha", State: protocol.SessionDown},
613+ {Name: "beta", State: protocol.SessionDown},
614+ }, sessions.Sessions)
615+
616+ require.NoError(t, killAll(dir))
617+ select {
618+ case err := <-served:
619+ require.NoError(t, err)
620+ case <-time.After(5 * time.Second):
621+ t.Fatal("restarted daemon did not stop after kill all")
622+ }
623+
624+ served = start()
625+ require.Empty(t, listRemoteSessions(t, dir).Sessions)
626+ require.NoError(t, killDaemon(dir))
627+ select {
628+ case err := <-served:
629+ require.NoError(t, err)
630+ case <-time.After(5 * time.Second):
631+ t.Fatal("empty daemon did not stop")
632+ }
633+}
634+
507 func TestIntegration_NamedSurvivesReattach(t *testing.T) {635 func TestIntegration_NamedSurvivesReattach(t *testing.T) {
508 sz := domain.Size{Cols: 80, Rows: 24}636 sz := domain.Size{Cols: 80, Rows: 24}
509 dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))637 dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
@@ -1313,7 +1441,7 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
1313 killTr, err := ipc.DialContext(context.Background(), dir)1441 killTr, err := ipc.DialContext(context.Background(), dir)
1314 require.NoError(t, err)1442 require.NoError(t, err)
1315 defer func() { _ = killTr.Close() }()1443 defer func() { _ = killTr.Close() }()
1316- require.NoError(t, killTr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})}))1444+ require.NoError(t, killTr.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillAll})}))
1317 1445
1318 _, err = killTr.Recv()1446 _, err = killTr.Recv()
1319 require.ErrorIs(t, err, io.EOF)1447 require.ErrorIs(t, err, io.EOF)
M internal/app/run.go +24 -13
@@ -1098,7 +1098,7 @@ func requestDaemonStop(ctx context.Context) error {
10981098 return errors.Join(errors.New("vev: no daemon running"), owner.Release())
10991099 }
11001100 defer func() { _ = transport.Close() }()
1101- if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})}); err != nil {
1101+ if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillDaemon})}); err != nil {
11021102 return fmt.Errorf("vev: requesting daemon stop: %w", err)
11031103 }
11041104 if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
@@ -1568,26 +1568,37 @@ func runKill(ctx context.Context, name string, all, daemon bool) (retErr error)
15681568 }
15691569 defer func() { _ = transport.Close() }()
15701570
1571- if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Name: name, All: all || daemon})}); err != nil {
1571+ scope := protocol.KillSession
1572+ if all {
1573+ scope = protocol.KillAll
1574+ } else if daemon {
1575+ scope = protocol.KillDaemon
1576+ }
1577+ if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Name: name, Scope: scope})}); err != nil {
15721578 return fmt.Errorf("vev: requesting kill: %w", err)
15731579 }
15741580 reply, err := transport.Recv()
1575- if err != nil {
1576- // The daemon may close the connection after killing; treat a clean
1577- // EOF as success.
1578- if errors.Is(err, io.EOF) {
1579- printKillSuccess(name, all, daemon)
1580- return nil
1581- }
1581+ if err != nil && !errors.Is(err, io.EOF) {
15821582 return fmt.Errorf("vev: reading kill reply: %w", err)
15831583 }
1584- if reply.Type == wire.MsgError {
1585- em, err := wire.UnmarshalErrorMsg(reply.Payload)
1586- if err != nil {
1587- return fmt.Errorf("vev: decoding error reply: %w", err)
1584+ if err == nil && reply.Type == wire.MsgError {
1585+ em, decodeErr := wire.UnmarshalErrorMsg(reply.Payload)
1586+ if decodeErr != nil {
1587+ return fmt.Errorf("vev: decoding error reply: %w", decodeErr)
15881588 }
15891589 return fmt.Errorf("vev: %s", em.Text)
15901590 }
1591+ if all || daemon {
1592+ waitCtx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
1593+ defer cancel()
1594+ owner, waitErr := waitForLifecycleAvailability(waitCtx, ipc.SocketDir(), defaultBackoff)
1595+ if waitErr != nil {
1596+ return fmt.Errorf("vev: waiting for daemon ownership transfer: %w", waitErr)
1597+ }
1598+ if releaseErr := owner.Release(); releaseErr != nil {
1599+ return fmt.Errorf("vev: releasing daemon ownership probe: %w", releaseErr)
1600+ }
1601+ }
15911602 printKillSuccess(name, all, daemon)
15921603 return nil
15931604 }
@@ -1098,7 +1098,7 @@ func requestDaemonStop(ctx context.Context) error {
1098 return errors.Join(errors.New("vev: no daemon running"), owner.Release())1098 return errors.Join(errors.New("vev: no daemon running"), owner.Release())
1099 }1099 }
1100 defer func() { _ = transport.Close() }()1100 defer func() { _ = transport.Close() }()
1101- if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})}); err != nil {1101+ if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillDaemon})}); err != nil {
1102 return fmt.Errorf("vev: requesting daemon stop: %w", err)1102 return fmt.Errorf("vev: requesting daemon stop: %w", err)
1103 }1103 }
1104 if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {1104 if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
@@ -1568,26 +1568,37 @@ func runKill(ctx context.Context, name string, all, daemon bool) (retErr error)
1568 }1568 }
1569 defer func() { _ = transport.Close() }()1569 defer func() { _ = transport.Close() }()
1570 1570
1571- if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Name: name, All: all || daemon})}); err != nil {1571+ scope := protocol.KillSession
1572+ if all {
1573+ scope = protocol.KillAll
1574+ } else if daemon {
1575+ scope = protocol.KillDaemon
1576+ }
1577+ if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Name: name, Scope: scope})}); err != nil {
1572 return fmt.Errorf("vev: requesting kill: %w", err)1578 return fmt.Errorf("vev: requesting kill: %w", err)
1573 }1579 }
1574 reply, err := transport.Recv()1580 reply, err := transport.Recv()
1575- if err != nil {1581+ if err != nil && !errors.Is(err, io.EOF) {
1576- // The daemon may close the connection after killing; treat a clean
1577- // EOF as success.
1578- if errors.Is(err, io.EOF) {
1579- printKillSuccess(name, all, daemon)
1580- return nil
1581- }
1582 return fmt.Errorf("vev: reading kill reply: %w", err)1582 return fmt.Errorf("vev: reading kill reply: %w", err)
1583 }1583 }
1584- if reply.Type == wire.MsgError {1584+ if err == nil && reply.Type == wire.MsgError {
1585- em, err := wire.UnmarshalErrorMsg(reply.Payload)1585+ em, decodeErr := wire.UnmarshalErrorMsg(reply.Payload)
1586- if err != nil {1586+ if decodeErr != nil {
1587- return fmt.Errorf("vev: decoding error reply: %w", err)1587+ return fmt.Errorf("vev: decoding error reply: %w", decodeErr)
1588 }1588 }
1589 return fmt.Errorf("vev: %s", em.Text)1589 return fmt.Errorf("vev: %s", em.Text)
1590 }1590 }
1591+ if all || daemon {
1592+ waitCtx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
1593+ defer cancel()
1594+ owner, waitErr := waitForLifecycleAvailability(waitCtx, ipc.SocketDir(), defaultBackoff)
1595+ if waitErr != nil {
1596+ return fmt.Errorf("vev: waiting for daemon ownership transfer: %w", waitErr)
1597+ }
1598+ if releaseErr := owner.Release(); releaseErr != nil {
1599+ return fmt.Errorf("vev: releasing daemon ownership probe: %w", releaseErr)
1600+ }
1601+ }
1591 printKillSuccess(name, all, daemon)1602 printKillSuccess(name, all, daemon)
1592 return nil1603 return nil
1593 }1604 }
M internal/protocol/session.go +13 -3
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 38
16+const Version uint16 = 39
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication.
@@ -210,9 +210,19 @@ type AttachTarget struct {
210210 type Detached struct{ Reason uint8 }
211211 type List struct{}
212212
213+type KillScope uint8
214+
215+const (
216+ KillSession KillScope = 0
217+ // KillDaemon retains the legacy All=true wire value so an older client can
218+ // never turn daemon shutdown into destructive session deletion.
219+ KillDaemon KillScope = 1
220+ KillAll KillScope = 2
221+)
222+
213223 type Kill struct {
214- Name string
215- All bool
224+ Name string
225+ Scope KillScope
216226 }
217227
218228 type SessionState uint8
@@ -13,7 +13,7 @@ import (
13 )13 )
14 14
15 // Version is the negotiated vev session protocol version.15 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 3816+const Version uint16 = 39
17 17
18 // HandshakeTimeout bounds every transport handshake from connect through the18 // HandshakeTimeout bounds every transport handshake from connect through the
19 // first committed publication.19 // first committed publication.
@@ -210,9 +210,19 @@ type AttachTarget struct {
210 type Detached struct{ Reason uint8 }210 type Detached struct{ Reason uint8 }
211 type List struct{}211 type List struct{}
212 212
213+type KillScope uint8
214+
215+const (
216+ KillSession KillScope = 0
217+ // KillDaemon retains the legacy All=true wire value so an older client can
218+ // never turn daemon shutdown into destructive session deletion.
219+ KillDaemon KillScope = 1
220+ KillAll KillScope = 2
221+)
222+
213 type Kill struct {223 type Kill struct {
214- Name string224+ Name string
215- All bool225+ Scope KillScope
216 }226 }
217 227
218 type SessionState uint8228 type SessionState uint8
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(38), Version)
13+ require.Equal(t, uint16(39), Version)
1414 require.Equal(t, (16<<20)-55, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(38), Version)13+ require.Equal(t, uint16(39), Version)
14 require.Equal(t, (16<<20)-55, MaxOutputDataLen)14 require.Equal(t, (16<<20)-55, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/frame_test.go +2 -2
@@ -7,8 +7,8 @@ import (
77 )
88
99 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 38 {
11- t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)
10+ if protocol.Version != 39 {
11+ t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)
1212 }
1313 }
1414
@@ -7,8 +7,8 @@ import (
7 )7 )
8 8
9 func TestProtocolVersion(t *testing.T) {9 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 38 {10+ if protocol.Version != 39 {
11- t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)11+ t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)
12 }12 }
13 }13 }
14 14
M internal/protocol/wire/navigation_test.go +1 -1
@@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002602" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002702" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
159159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
@@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002602" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002702" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
M internal/protocol/wire/wire.go +11 -7
@@ -1331,8 +1331,8 @@ func UnmarshalOutputResetRequest(b []byte) (protocol.OutputResetRequest, error)
13311331 func MarshalKill(m protocol.Kill) []byte {
13321332 w := payloadWriter{}
13331333 w.putString(m.Name)
1334- if m.All {
1335- w.putUint8(1)
1334+ if m.Scope != protocol.KillSession {
1335+ w.putUint8(uint8(m.Scope))
13361336 }
13371337 return w.b
13381338 }
@@ -1344,17 +1344,21 @@ func UnmarshalKill(b []byte) (protocol.Kill, error) {
13441344 if err != nil {
13451345 return protocol.Kill{}, err
13461346 }
1347- var all bool
1347+ var scope protocol.KillScope
13481348 if len(r.b) > 0 {
1349- all, err = r.getBool()
1350- if err != nil {
1351- return protocol.Kill{}, err
1349+ value, readErr := r.getUint8()
1350+ if readErr != nil {
1351+ return protocol.Kill{}, readErr
1352+ }
1353+ scope = protocol.KillScope(value)
1354+ if scope > protocol.KillAll {
1355+ return protocol.Kill{}, errInvalidEnum
13521356 }
13531357 }
13541358 if err := r.done(); err != nil {
13551359 return protocol.Kill{}, err
13561360 }
1357- return protocol.Kill{Name: name, All: all}, nil
1361+ return protocol.Kill{Name: name, Scope: scope}, nil
13581362 }
13591363
13601364 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -1331,8 +1331,8 @@ func UnmarshalOutputResetRequest(b []byte) (protocol.OutputResetRequest, error)
1331 func MarshalKill(m protocol.Kill) []byte {1331 func MarshalKill(m protocol.Kill) []byte {
1332 w := payloadWriter{}1332 w := payloadWriter{}
1333 w.putString(m.Name)1333 w.putString(m.Name)
1334- if m.All {1334+ if m.Scope != protocol.KillSession {
1335- w.putUint8(1)1335+ w.putUint8(uint8(m.Scope))
1336 }1336 }
1337 return w.b1337 return w.b
1338 }1338 }
@@ -1344,17 +1344,21 @@ func UnmarshalKill(b []byte) (protocol.Kill, error) {
1344 if err != nil {1344 if err != nil {
1345 return protocol.Kill{}, err1345 return protocol.Kill{}, err
1346 }1346 }
1347- var all bool1347+ var scope protocol.KillScope
1348 if len(r.b) > 0 {1348 if len(r.b) > 0 {
1349- all, err = r.getBool()1349+ value, readErr := r.getUint8()
1350- if err != nil {1350+ if readErr != nil {
1351- return protocol.Kill{}, err1351+ return protocol.Kill{}, readErr
1352+ }
1353+ scope = protocol.KillScope(value)
1354+ if scope > protocol.KillAll {
1355+ return protocol.Kill{}, errInvalidEnum
1352 }1356 }
1353 }1357 }
1354 if err := r.done(); err != nil {1358 if err := r.done(); err != nil {
1355 return protocol.Kill{}, err1359 return protocol.Kill{}, err
1356 }1360 }
1357- return protocol.Kill{Name: name, All: all}, nil1361+ return protocol.Kill{Name: name, Scope: scope}, nil
1358 }1362 }
1359 1363
1360 // MarshalSessions encodes m into a Sessions message payload: a uint16 count1364 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
M internal/protocol/wire/wire_final_test.go +6 -6
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 38 {
16- t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)
15+ if protocol.Version != 39 {
16+ t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)
1717 }
1818 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1919 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2727
2828 func TestFinalHelloGoldenStrict(t *testing.T) {
2929 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 38, 2}, make([]byte, 16+8)...)
30+ want := append([]byte{0, 39, 2}, make([]byte, 16+8)...)
3131 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3232 want = append(want, 0, 0)
3333 got := MarshalHello(msg)
@@ -318,10 +318,10 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
318318 },
319319 {name: "detached reason", payload: []byte{0xff}, decode: func(b []byte) error { _, err := UnmarshalDetached(b); return err }},
320320 {
321- name: "kill boolean",
321+ name: "kill scope",
322322 payload: func() []byte {
323- b := MarshalKill(protocol.Kill{All: true})
324- b[len(b)-1] = 2
323+ b := MarshalKill(protocol.Kill{Scope: protocol.KillAll})
324+ b[len(b)-1] = 3
325325 return b
326326 }(),
327327 decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },
@@ -12,8 +12,8 @@ import (
12 )12 )
13 13
14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 38 {15+ if protocol.Version != 39 {
16- t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)16+ t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)
17 }17 }
18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
19 if len(payload) < 2 {19 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
27 27
28 func TestFinalHelloGoldenStrict(t *testing.T) {28 func TestFinalHelloGoldenStrict(t *testing.T) {
29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 38, 2}, make([]byte, 16+8)...)30+ want := append([]byte{0, 39, 2}, make([]byte, 16+8)...)
31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
32 want = append(want, 0, 0)32 want = append(want, 0, 0)
33 got := MarshalHello(msg)33 got := MarshalHello(msg)
@@ -318,10 +318,10 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
318 },318 },
319 {name: "detached reason", payload: []byte{0xff}, decode: func(b []byte) error { _, err := UnmarshalDetached(b); return err }},319 {name: "detached reason", payload: []byte{0xff}, decode: func(b []byte) error { _, err := UnmarshalDetached(b); return err }},
320 {320 {
321- name: "kill boolean",321+ name: "kill scope",
322 payload: func() []byte {322 payload: func() []byte {
323- b := MarshalKill(protocol.Kill{All: true})323+ b := MarshalKill(protocol.Kill{Scope: protocol.KillAll})
324- b[len(b)-1] = 2324+ b[len(b)-1] = 3
325 return b325 return b
326 }(),326 }(),
327 decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },327 decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },
M internal/protocol/wire/wire_test.go +8 -7
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 }
137137 got := MarshalHello(msg)
138138 want := []byte{
139- 0x00, 0x26, 0x00, // version, intent, first client ID byte
139+ 0x00, 0x27, 0x00, // version, intent, first client ID byte
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367367 }
368368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(38), protocol.Version)
370+ require.Equal(t, uint16(39), protocol.Version)
371371 }
372372
373373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528528 name: "minimal",
529529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530530 want: []byte{
531- 0x00, 0x26,
531+ 0x00, 0x27,
532532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533533 0x00, // attached
534534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551551 JSON: true,
552552 },
553553 want: []byte{
554- 0x00, 0x26,
554+ 0x00, 0x27,
555555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556556 0x00, // attached
557557 0x01, // self
@@ -796,7 +796,8 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
796796 }{
797797 {name: "named", msg: protocol.Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
798798 {name: "empty", msg: protocol.Kill{Name: ""}, want: []byte{0x00, 0x00}},
799- {name: "all", msg: protocol.Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
799+ {name: "daemon", msg: protocol.Kill{Scope: protocol.KillDaemon}, want: []byte{0x00, 0x00, 0x01}},
800+ {name: "all", msg: protocol.Kill{Scope: protocol.KillAll}, want: []byte{0x00, 0x00, 0x02}},
800801 }
801802
802803 for _, tt := range tests {
@@ -988,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
988989 name: "attached",
989990 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
990991 want: []byte{
991- 0x00, 0x26, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
992+ 0x00, 0x27, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
992993 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
993994 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
994995 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -998,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
998999 name: "control",
9991000 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
10001001 want: []byte{
1001- 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1002+ 0x00, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10021003 0x00, 0x02, 'l', 's', 0x00, 0x00,
10031004 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10041005 },
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 }136 }
137 got := MarshalHello(msg)137 got := MarshalHello(msg)
138 want := []byte{138 want := []byte{
139- 0x00, 0x26, 0x00, // version, intent, first client ID byte139+ 0x00, 0x27, 0x00, // version, intent, first client ID byte
140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367 }367 }
368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369 require.Equal(t, want, MarshalTheme(cleared))369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(38), protocol.Version)370+ require.Equal(t, uint16(39), protocol.Version)
371 }371 }
372 372
373 func TestResizeGoldenAndRoundTrip(t *testing.T) {373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528 name: "minimal",528 name: "minimal",
529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530 want: []byte{530 want: []byte{
531- 0x00, 0x26,531+ 0x00, 0x27,
532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533 0x00, // attached533 0x00, // attached
534 0x00, // self534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551 JSON: true,551 JSON: true,
552 },552 },
553 want: []byte{553 want: []byte{
554- 0x00, 0x26,554+ 0x00, 0x27,
555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556 0x00, // attached556 0x00, // attached
557 0x01, // self557 0x01, // self
@@ -796,7 +796,8 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
796 }{796 }{
797 {name: "named", msg: protocol.Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},797 {name: "named", msg: protocol.Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
798 {name: "empty", msg: protocol.Kill{Name: ""}, want: []byte{0x00, 0x00}},798 {name: "empty", msg: protocol.Kill{Name: ""}, want: []byte{0x00, 0x00}},
799- {name: "all", msg: protocol.Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},799+ {name: "daemon", msg: protocol.Kill{Scope: protocol.KillDaemon}, want: []byte{0x00, 0x00, 0x01}},
800+ {name: "all", msg: protocol.Kill{Scope: protocol.KillAll}, want: []byte{0x00, 0x00, 0x02}},
800 }801 }
801 802
802 for _, tt := range tests {803 for _, tt := range tests {
@@ -988,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
988 name: "attached",989 name: "attached",
989 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},990 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
990 want: []byte{991 want: []byte{
991- 0x00, 0x26, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,992+ 0x00, 0x27, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
992 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',993 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
993 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',994 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,995 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -998,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
998 name: "control",999 name: "control",
999 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},1000 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
1000 want: []byte{1001 want: []byte{
1001- 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1002+ 0x00, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1002 0x00, 0x02, 'l', 's', 0x00, 0x00,1003 0x00, 0x02, 'l', 's', 0x00, 0x00,
1003 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1004 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1004 },1005 },
M internal/usecase/daemon/daemon.go +47 -9
@@ -803,7 +803,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
803803 snapshotDeadline = newSnapshotShutdownDeadline(d.clock)
804804 defer snapshotDeadline.stop()
805805 }
806- d.shutdownAllWithSnapshotDeadline(protocol.ReasonServerShutdown, snapshotDeadline)
806+ d.terminateAllWithSnapshotDeadline(protocol.ReasonServerShutdown, false, snapshotDeadline)
807807 d.waitNotifies()
808808 d.hardCancel()
809809 d.serveCancel()
@@ -823,7 +823,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
823823 d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
824824 }
825825 d.WaitDurableWriters()
826- d.shutdownAllWithSnapshotDeadline(protocol.ReasonServerShutdown, snapshotDeadline)
826+ d.terminateAllWithSnapshotDeadline(protocol.ReasonServerShutdown, false, snapshotDeadline)
827827 d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)
828828 d.waitNotifies()
829829 if err := d.flushCatalogue(); err != nil {
@@ -842,10 +842,14 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
842842 // inserted after the snapshot: route rejects once closing is set, and both run
843843 // under d.mu. killSession (which relocks) runs after the lock is released.
844844 func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
845- return d.shutdownAllWithSnapshotDeadline(reason, nil)
845+ return d.terminateAllWithSnapshotDeadline(reason, false, nil)
846846 }
847847
848-func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
848+func (d *Daemon) purgeAll(reason uint8) (checkpointIncomplete bool) {
849+ return d.terminateAllWithSnapshotDeadline(reason, true, nil)
850+}
851+
852+func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
849853 d.cancelRemoteDiscoveryRefresh()
850854 d.closeMoveLifecycles()
851855 d.mu.Lock()
@@ -853,13 +857,37 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
853857 d.purgeAllParkingLocked()
854858 parkedRetirements := d.purgeAllParkedLocked()
855859 snapshot := d.sessionsSnapshotLocked()
860+ stoppedNames := make([]string, 0, len(d.inactive))
861+ if purge {
862+ for name := range d.inactive {
863+ stoppedNames = append(stoppedNames, name)
864+ }
865+ sort.Strings(stoppedNames)
866+ }
856867 empty := len(snapshot) == 0
857868 d.mu.Unlock()
858869 d.finishParkedAttachmentRetirements(parkedRetirements)
859- d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
870+ if !purge {
871+ // Publish preservation policy for the complete registry snapshot before
872+ // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
873+ // but its disposition must remain daemon shutdown rather than session purge.
874+ for _, s := range snapshot {
875+ s.reserveShutdownTeardown()
876+ }
877+ }
878+ d.log.Info("session termination begin", "reason", reason, "purge", purge, "live_sessions", len(snapshot), "stopped_sessions", len(stoppedNames))
879+ // Purge stopped authority before signaling Serve to exit. Once the final
880+ // live registry entry closes, Serve cancels its context; deleting stopped
881+ // records afterward would be interrupted and retain broken catalogue entries.
882+ for _, name := range stoppedNames {
883+ if err := d.retryStoppedPurge(name); err != nil {
884+ checkpointIncomplete = true
885+ d.log.Error("deleting stopped session during kill all failed", "err", err, "session", name)
886+ }
887+ }
860888 if empty {
861889 d.doneOnce.Do(func() { close(d.done) })
862- return false
890+ return checkpointIncomplete
863891 }
864892 for _, s := range snapshot {
865893 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -869,11 +897,11 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
869897 ephemeral := s.ephemeral
870898 s.mu.Unlock()
871899 s.stopInMemoryLifecycle()
872- if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
900+ if err := d.killSessionWithSnapshotDeadline(s, reason, purge, deadline, nil); err != nil {
873901 checkpointIncomplete = true
874902 d.log.Error("closing session with unpersisted terminal state", "err", err)
875903 }
876- if !ephemeral && deadline != nil {
904+ if !purge && !ephemeral && deadline != nil {
877905 select {
878906 case <-deadline.Done():
879907 checkpointIncomplete = true
@@ -1143,9 +1171,19 @@ func (d *Daemon) handleList(tr ports.ServerConnection) {
11431171 func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {
11441172 defer func() { _ = tr.Close() }()
11451173
1146- if request.All {
1174+ switch request.Scope {
1175+ case protocol.KillAll:
1176+ if d.purgeAll(protocol.ReasonSessionKilled) {
1177+ _ = d.boundedControlSend(tr, serverError(protocol.ErrInternal, "one or more sessions could not be deleted"))
1178+ }
1179+ return
1180+ case protocol.KillDaemon:
11471181 d.shutdownAll(protocol.ReasonServerShutdown)
11481182 return
1183+ case protocol.KillSession:
1184+ default:
1185+ _ = d.boundedControlSend(tr, serverError(protocol.ErrInternal, "invalid kill scope"))
1186+ return
11491187 }
11501188
11511189 d.mu.Lock()
@@ -803,7 +803,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
803 snapshotDeadline = newSnapshotShutdownDeadline(d.clock)803 snapshotDeadline = newSnapshotShutdownDeadline(d.clock)
804 defer snapshotDeadline.stop()804 defer snapshotDeadline.stop()
805 }805 }
806- d.shutdownAllWithSnapshotDeadline(protocol.ReasonServerShutdown, snapshotDeadline)806+ d.terminateAllWithSnapshotDeadline(protocol.ReasonServerShutdown, false, snapshotDeadline)
807 d.waitNotifies()807 d.waitNotifies()
808 d.hardCancel()808 d.hardCancel()
809 d.serveCancel()809 d.serveCancel()
@@ -823,7 +823,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
823 d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)823 d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
824 }824 }
825 d.WaitDurableWriters()825 d.WaitDurableWriters()
826- d.shutdownAllWithSnapshotDeadline(protocol.ReasonServerShutdown, snapshotDeadline)826+ d.terminateAllWithSnapshotDeadline(protocol.ReasonServerShutdown, false, snapshotDeadline)
827 d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)827 d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)
828 d.waitNotifies()828 d.waitNotifies()
829 if err := d.flushCatalogue(); err != nil {829 if err := d.flushCatalogue(); err != nil {
@@ -842,10 +842,14 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
842 // inserted after the snapshot: route rejects once closing is set, and both run842 // inserted after the snapshot: route rejects once closing is set, and both run
843 // under d.mu. killSession (which relocks) runs after the lock is released.843 // under d.mu. killSession (which relocks) runs after the lock is released.
844 func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {844 func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
845- return d.shutdownAllWithSnapshotDeadline(reason, nil)845+ return d.terminateAllWithSnapshotDeadline(reason, false, nil)
846 }846 }
847 847
848-func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {848+func (d *Daemon) purgeAll(reason uint8) (checkpointIncomplete bool) {
849+ return d.terminateAllWithSnapshotDeadline(reason, true, nil)
850+}
851+
852+func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
849 d.cancelRemoteDiscoveryRefresh()853 d.cancelRemoteDiscoveryRefresh()
850 d.closeMoveLifecycles()854 d.closeMoveLifecycles()
851 d.mu.Lock()855 d.mu.Lock()
@@ -853,13 +857,37 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
853 d.purgeAllParkingLocked()857 d.purgeAllParkingLocked()
854 parkedRetirements := d.purgeAllParkedLocked()858 parkedRetirements := d.purgeAllParkedLocked()
855 snapshot := d.sessionsSnapshotLocked()859 snapshot := d.sessionsSnapshotLocked()
860+ stoppedNames := make([]string, 0, len(d.inactive))
861+ if purge {
862+ for name := range d.inactive {
863+ stoppedNames = append(stoppedNames, name)
864+ }
865+ sort.Strings(stoppedNames)
866+ }
856 empty := len(snapshot) == 0867 empty := len(snapshot) == 0
857 d.mu.Unlock()868 d.mu.Unlock()
858 d.finishParkedAttachmentRetirements(parkedRetirements)869 d.finishParkedAttachmentRetirements(parkedRetirements)
859- d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))870+ if !purge {
871+ // Publish preservation policy for the complete registry snapshot before
872+ // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
873+ // but its disposition must remain daemon shutdown rather than session purge.
874+ for _, s := range snapshot {
875+ s.reserveShutdownTeardown()
876+ }
877+ }
878+ d.log.Info("session termination begin", "reason", reason, "purge", purge, "live_sessions", len(snapshot), "stopped_sessions", len(stoppedNames))
879+ // Purge stopped authority before signaling Serve to exit. Once the final
880+ // live registry entry closes, Serve cancels its context; deleting stopped
881+ // records afterward would be interrupted and retain broken catalogue entries.
882+ for _, name := range stoppedNames {
883+ if err := d.retryStoppedPurge(name); err != nil {
884+ checkpointIncomplete = true
885+ d.log.Error("deleting stopped session during kill all failed", "err", err, "session", name)
886+ }
887+ }
860 if empty {888 if empty {
861 d.doneOnce.Do(func() { close(d.done) })889 d.doneOnce.Do(func() { close(d.done) })
862- return false890+ return checkpointIncomplete
863 }891 }
864 for _, s := range snapshot {892 for _, s := range snapshot {
865 // Cancellation and PTY closure must not wait behind a teardown owner that893 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -869,11 +897,11 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
869 ephemeral := s.ephemeral897 ephemeral := s.ephemeral
870 s.mu.Unlock()898 s.mu.Unlock()
871 s.stopInMemoryLifecycle()899 s.stopInMemoryLifecycle()
872- if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {900+ if err := d.killSessionWithSnapshotDeadline(s, reason, purge, deadline, nil); err != nil {
873 checkpointIncomplete = true901 checkpointIncomplete = true
874 d.log.Error("closing session with unpersisted terminal state", "err", err)902 d.log.Error("closing session with unpersisted terminal state", "err", err)
875 }903 }
876- if !ephemeral && deadline != nil {904+ if !purge && !ephemeral && deadline != nil {
877 select {905 select {
878 case <-deadline.Done():906 case <-deadline.Done():
879 checkpointIncomplete = true907 checkpointIncomplete = true
@@ -1143,9 +1171,19 @@ func (d *Daemon) handleList(tr ports.ServerConnection) {
1143 func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {1171 func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {
1144 defer func() { _ = tr.Close() }()1172 defer func() { _ = tr.Close() }()
1145 1173
1146- if request.All {1174+ switch request.Scope {
1175+ case protocol.KillAll:
1176+ if d.purgeAll(protocol.ReasonSessionKilled) {
1177+ _ = d.boundedControlSend(tr, serverError(protocol.ErrInternal, "one or more sessions could not be deleted"))
1178+ }
1179+ return
1180+ case protocol.KillDaemon:
1147 d.shutdownAll(protocol.ReasonServerShutdown)1181 d.shutdownAll(protocol.ReasonServerShutdown)
1148 return1182 return
1183+ case protocol.KillSession:
1184+ default:
1185+ _ = d.boundedControlSend(tr, serverError(protocol.ErrInternal, "invalid kill scope"))
1186+ return
1149 }1187 }
1150 1188
1151 d.mu.Lock()1189 d.mu.Lock()
M internal/usecase/daemon/session.go +41 -12
@@ -42,13 +42,14 @@ type session struct {
4242 // tab, or pane locks. The owner performs teardown without holding teardownMu;
4343 // duplicate daemon-shutdown callers can therefore bound their ownership wait
4444 // with Serve's shared snapshot deadline.
45- teardownMu sync.Mutex
46- teardownActive bool
47- teardownDone chan struct{}
48- moveReservations uint
49- teardownWaiters uint
50- teardownChanged chan struct{}
51- lifecycleStopOnce sync.Once
45+ teardownMu sync.Mutex
46+ teardownActive bool
47+ teardownDone chan struct{}
48+ moveReservations uint
49+ teardownWaiters uint
50+ teardownChanged chan struct{}
51+ shutdownTeardownRequested bool
52+ lifecycleStopOnce sync.Once
5253 // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,
5354 // and clipboard queue state.
5455 // restoreDone remains attached to a restored session after it is published in
@@ -1481,16 +1482,38 @@ func (s *session) signalTeardownChangedLocked() {
14811482 s.teardownChanged = make(chan struct{})
14821483 }
14831484
1485+// reserveShutdownTeardown makes preservation the authoritative terminal
1486+// disposition before shutdown cancels PTYs. PTY EOF may still win teardown
1487+// ownership, but it can no longer reinterpret daemon cancellation as a natural
1488+// final-shell exit and purge the named session.
1489+func (s *session) reserveShutdownTeardown() {
1490+ if s == nil {
1491+ return
1492+ }
1493+ s.teardownMu.Lock()
1494+ s.shutdownTeardownRequested = true
1495+ s.signalTeardownChangedLocked()
1496+ s.teardownMu.Unlock()
1497+}
1498+
14841499 // beginTeardown waits for ownership without holding teardownMu across teardown.
14851500 // A false result means Serve's shared shutdown budget expired while another
14861501 // lifecycle path owned the session. Ordinary callers wait and retry ownership,
14871502 // preserving failed-teardown and stopped-purge retry semantics.
14881503 func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1504+ _, _, acquired := s.beginTeardownRequest(deadline, 0, false)
1505+ return acquired
1506+}
1507+
1508+// beginTeardownRequest acquires teardown ownership and resolves its terminal
1509+// disposition atomically with shutdown reservation. This is the sole boundary
1510+// allowed to turn a teardown trigger into preserve-versus-purge policy.
1511+func (s *session) beginTeardownRequest(deadline *snapshotShutdownDeadline, reason uint8, purge bool) (uint8, bool, bool) {
14891512 for {
14901513 if deadline != nil {
14911514 select {
14921515 case <-deadline.Done():
1493- return false
1516+ return reason, purge, false
14941517 default:
14951518 }
14961519 }
@@ -1501,15 +1524,19 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
15011524 select {
15021525 case <-deadline.Done():
15031526 s.teardownMu.Unlock()
1504- return false
1527+ return reason, purge, false
15051528 default:
15061529 }
15071530 }
15081531 s.teardownActive = true
15091532 s.teardownDone = make(chan struct{})
15101533 s.signalTeardownChangedLocked()
1534+ if s.shutdownTeardownRequested {
1535+ reason = protocol.ReasonServerShutdown
1536+ purge = false
1537+ }
15111538 s.teardownMu.Unlock()
1512- return true
1539+ return reason, purge, true
15131540 }
15141541 s.teardownWaiters++
15151542 s.signalTeardownChangedLocked()
@@ -1531,7 +1558,7 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
15311558 s.signalTeardownChangedLocked()
15321559 s.teardownMu.Unlock()
15331560 if timedOut {
1534- return false
1561+ return reason, purge, false
15351562 }
15361563 }
15371564 }
@@ -1611,7 +1638,9 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
16111638 // later callers observe the completed registry transition instead of
16121639 // manufacturing a newer generation that the owner will quarantine. A
16131640 // shutdown duplicate abandons this wait when the shared budget expires.
1614- if !sess.beginTeardown(deadline) {
1641+ var acquired bool
1642+ reason, purge, acquired = sess.beginTeardownRequest(deadline, reason, purge)
1643+ if !acquired {
16151644 return nil
16161645 }
16171646 defer sess.finishTeardown()
@@ -42,13 +42,14 @@ type session struct {
42 // tab, or pane locks. The owner performs teardown without holding teardownMu;42 // tab, or pane locks. The owner performs teardown without holding teardownMu;
43 // duplicate daemon-shutdown callers can therefore bound their ownership wait43 // duplicate daemon-shutdown callers can therefore bound their ownership wait
44 // with Serve's shared snapshot deadline.44 // with Serve's shared snapshot deadline.
45- teardownMu sync.Mutex45+ teardownMu sync.Mutex
46- teardownActive bool46+ teardownActive bool
47- teardownDone chan struct{}47+ teardownDone chan struct{}
48- moveReservations uint48+ moveReservations uint
49- teardownWaiters uint49+ teardownWaiters uint
50- teardownChanged chan struct{}50+ teardownChanged chan struct{}
51- lifecycleStopOnce sync.Once51+ shutdownTeardownRequested bool
52+ lifecycleStopOnce sync.Once
52 // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,53 // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,
53 // and clipboard queue state.54 // and clipboard queue state.
54 // restoreDone remains attached to a restored session after it is published in55 // restoreDone remains attached to a restored session after it is published in
@@ -1481,16 +1482,38 @@ func (s *session) signalTeardownChangedLocked() {
1481 s.teardownChanged = make(chan struct{})1482 s.teardownChanged = make(chan struct{})
1482 }1483 }
1483 1484
1485+// reserveShutdownTeardown makes preservation the authoritative terminal
1486+// disposition before shutdown cancels PTYs. PTY EOF may still win teardown
1487+// ownership, but it can no longer reinterpret daemon cancellation as a natural
1488+// final-shell exit and purge the named session.
1489+func (s *session) reserveShutdownTeardown() {
1490+ if s == nil {
1491+ return
1492+ }
1493+ s.teardownMu.Lock()
1494+ s.shutdownTeardownRequested = true
1495+ s.signalTeardownChangedLocked()
1496+ s.teardownMu.Unlock()
1497+}
1498+
1484 // beginTeardown waits for ownership without holding teardownMu across teardown.1499 // beginTeardown waits for ownership without holding teardownMu across teardown.
1485 // A false result means Serve's shared shutdown budget expired while another1500 // A false result means Serve's shared shutdown budget expired while another
1486 // lifecycle path owned the session. Ordinary callers wait and retry ownership,1501 // lifecycle path owned the session. Ordinary callers wait and retry ownership,
1487 // preserving failed-teardown and stopped-purge retry semantics.1502 // preserving failed-teardown and stopped-purge retry semantics.
1488 func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {1503 func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1504+ _, _, acquired := s.beginTeardownRequest(deadline, 0, false)
1505+ return acquired
1506+}
1507+
1508+// beginTeardownRequest acquires teardown ownership and resolves its terminal
1509+// disposition atomically with shutdown reservation. This is the sole boundary
1510+// allowed to turn a teardown trigger into preserve-versus-purge policy.
1511+func (s *session) beginTeardownRequest(deadline *snapshotShutdownDeadline, reason uint8, purge bool) (uint8, bool, bool) {
1489 for {1512 for {
1490 if deadline != nil {1513 if deadline != nil {
1491 select {1514 select {
1492 case <-deadline.Done():1515 case <-deadline.Done():
1493- return false1516+ return reason, purge, false
1494 default:1517 default:
1495 }1518 }
1496 }1519 }
@@ -1501,15 +1524,19 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1501 select {1524 select {
1502 case <-deadline.Done():1525 case <-deadline.Done():
1503 s.teardownMu.Unlock()1526 s.teardownMu.Unlock()
1504- return false1527+ return reason, purge, false
1505 default:1528 default:
1506 }1529 }
1507 }1530 }
1508 s.teardownActive = true1531 s.teardownActive = true
1509 s.teardownDone = make(chan struct{})1532 s.teardownDone = make(chan struct{})
1510 s.signalTeardownChangedLocked()1533 s.signalTeardownChangedLocked()
1534+ if s.shutdownTeardownRequested {
1535+ reason = protocol.ReasonServerShutdown
1536+ purge = false
1537+ }
1511 s.teardownMu.Unlock()1538 s.teardownMu.Unlock()
1512- return true1539+ return reason, purge, true
1513 }1540 }
1514 s.teardownWaiters++1541 s.teardownWaiters++
1515 s.signalTeardownChangedLocked()1542 s.signalTeardownChangedLocked()
@@ -1531,7 +1558,7 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1531 s.signalTeardownChangedLocked()1558 s.signalTeardownChangedLocked()
1532 s.teardownMu.Unlock()1559 s.teardownMu.Unlock()
1533 if timedOut {1560 if timedOut {
1534- return false1561+ return reason, purge, false
1535 }1562 }
1536 }1563 }
1537 }1564 }
@@ -1611,7 +1638,9 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1611 // later callers observe the completed registry transition instead of1638 // later callers observe the completed registry transition instead of
1612 // manufacturing a newer generation that the owner will quarantine. A1639 // manufacturing a newer generation that the owner will quarantine. A
1613 // shutdown duplicate abandons this wait when the shared budget expires.1640 // shutdown duplicate abandons this wait when the shared budget expires.
1614- if !sess.beginTeardown(deadline) {1641+ var acquired bool
1642+ reason, purge, acquired = sess.beginTeardownRequest(deadline, reason, purge)
1643+ if !acquired {
1615 return nil1644 return nil
1616 }1645 }
1617 defer sess.finishTeardown()1646 defer sess.finishTeardown()
M internal/usecase/daemon/session_test.go +27 -1
@@ -144,7 +144,7 @@ func TestHandshakeNoSuchSession(t *testing.T) {
144144
145145 func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
146146 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
147- tr, _, _ := newConn(t, wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})})
147+ tr, _, _ := newConn(t, wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillAll})})
148148 d.handleConn(tr)
149149
150150 select {
@@ -154,6 +154,32 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
154154 }
155155 }
156156
157+func TestBeginTeardownRequestResolvesShutdownPreservation(t *testing.T) {
158+ tests := []struct {
159+ name string
160+ reserve bool
161+ wantReason uint8
162+ wantPurge bool
163+ }{
164+ {name: "natural exit purges", wantReason: protocol.ReasonSessionKilled, wantPurge: true},
165+ {name: "shutdown reservation preserves", reserve: true, wantReason: protocol.ReasonServerShutdown},
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ sess := &session{}
171+ if tt.reserve {
172+ sess.reserveShutdownTeardown()
173+ }
174+ reason, purge, acquired := sess.beginTeardownRequest(nil, protocol.ReasonSessionKilled, true)
175+ require.True(t, acquired)
176+ require.Equal(t, tt.wantReason, reason)
177+ require.Equal(t, tt.wantPurge, purge)
178+ sess.finishTeardown()
179+ })
180+ }
181+}
182+
157183 func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) {
158184 p, release := newBlockingPTY(t)
159185 defer release()
@@ -144,7 +144,7 @@ func TestHandshakeNoSuchSession(t *testing.T) {
144 144
145 func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {145 func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
146 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})146 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
147- tr, _, _ := newConn(t, wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{All: true})})147+ tr, _, _ := newConn(t, wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillAll})})
148 d.handleConn(tr)148 d.handleConn(tr)
149 149
150 select {150 select {
@@ -154,6 +154,32 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
154 }154 }
155 }155 }
156 156
157+func TestBeginTeardownRequestResolvesShutdownPreservation(t *testing.T) {
158+ tests := []struct {
159+ name string
160+ reserve bool
161+ wantReason uint8
162+ wantPurge bool
163+ }{
164+ {name: "natural exit purges", wantReason: protocol.ReasonSessionKilled, wantPurge: true},
165+ {name: "shutdown reservation preserves", reserve: true, wantReason: protocol.ReasonServerShutdown},
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ sess := &session{}
171+ if tt.reserve {
172+ sess.reserveShutdownTeardown()
173+ }
174+ reason, purge, acquired := sess.beginTeardownRequest(nil, protocol.ReasonSessionKilled, true)
175+ require.True(t, acquired)
176+ require.Equal(t, tt.wantReason, reason)
177+ require.Equal(t, tt.wantPurge, purge)
178+ sess.finishTeardown()
179+ })
180+ }
181+}
182+
157 func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) {183 func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) {
158 p, release := newBlockingPTY(t)184 p, release := newBlockingPTY(t)
159 defer release()185 defer release()

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