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 Unverified
B5690EEEBB952194The key is unknown
12 files · +313 -66
M internal/app/daemon_integration_test.go +132 -4
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "crypto/rand" | |
| 8 | 8 | "encoding/binary" | |
| 9 | 9 | "errors" | |
| 10 | + | "fmt" | |
| 10 | 11 | "io" | |
| 11 | 12 | "log/slog" | |
| 12 | 13 | "net" | |
| @@ -122,19 +123,87 @@ func listRemoteSessions(t *testing.T, dir string) protocol.Sessions { | |||
| 122 | 123 | } | |
| 123 | 124 | ||
| 124 | 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 | 134 | tr, err := ipc.DialContext(context.Background(), dir) | |
| 126 | 135 | if err != nil { | |
| 127 | 136 | return err | |
| 128 | 137 | } | |
| 129 | 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 | 144 | return err | |
| 132 | 145 | } | |
| 133 | - | _, err = tr.Recv() | |
| 134 | 146 | if errors.Is(err, io.EOF) { | |
| 135 | 147 | return nil | |
| 136 | 148 | } | |
| 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) | |
| 138 | 207 | } | |
| 139 | 208 | ||
| 140 | 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 | 635 | func TestIntegration_NamedSurvivesReattach(t *testing.T) { | |
| 508 | 636 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 509 | 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 | 1441 | killTr, err := ipc.DialContext(context.Background(), dir) | |
| 1314 | 1442 | require.NoError(t, err) | |
| 1315 | 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 | 1446 | _, err = killTr.Recv() | |
| 1319 | 1447 | 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 err | 136 | 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 err | 144 | return err | ||
| 132 | } | 145 | } | ||
| 133 | - | _, err = tr.Recv() | |||
| 134 | if errors.Is(err, io.EOF) { | 146 | if errors.Is(err, io.EOF) { | ||
| 135 | return nil | 147 | return nil | ||
| 136 | } | 148 | } | ||
| 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) | |||
| 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 { | |||
| 1098 | 1098 | return errors.Join(errors.New("vev: no daemon running"), owner.Release()) | |
| 1099 | 1099 | } | |
| 1100 | 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 | 1102 | return fmt.Errorf("vev: requesting daemon stop: %w", err) | |
| 1103 | 1103 | } | |
| 1104 | 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 | 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 | 1578 | return fmt.Errorf("vev: requesting kill: %w", err) | |
| 1573 | 1579 | } | |
| 1574 | 1580 | 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) { | |
| 1582 | 1582 | return fmt.Errorf("vev: reading kill reply: %w", err) | |
| 1583 | 1583 | } | |
| 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) | |
| 1588 | 1588 | } | |
| 1589 | 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 | 1602 | printKillSuccess(name, all, daemon) | |
| 1592 | 1603 | return nil | |
| 1593 | 1604 | } | |
| @@ -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 nil | 1603 | return nil | ||
| 1593 | } | 1604 | } | ||
M internal/protocol/session.go +13 -3
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 38 | |
| 16 | + | const Version uint16 = 39 | |
| 17 | 17 | ||
| 18 | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 19 | 19 | // first committed publication. | |
| @@ -210,9 +210,19 @@ type AttachTarget struct { | |||
| 210 | 210 | type Detached struct{ Reason uint8 } | |
| 211 | 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 | 223 | type Kill struct { | |
| 214 | - | Name string | |
| 215 | - | All bool | |
| 224 | + | Name string | |
| 225 | + | Scope KillScope | |
| 216 | 226 | } | |
| 217 | 227 | ||
| 218 | 228 | 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 = 38 | 16 | + | const Version uint16 = 39 |
| 17 | 17 | ||||
| 18 | // HandshakeTimeout bounds every transport handshake from connect through the | 18 | // 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 string | 224 | + | Name string |
| 215 | - | All bool | 225 | + | Scope KillScope |
| 216 | } | 226 | } | ||
| 217 | 227 | ||||
| 218 | type SessionState uint8 | 228 | type SessionState uint8 | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(38), Version) | |
| 13 | + | require.Equal(t, uint16(39), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -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 ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | 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) | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
| @@ -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) { | |||
| 153 | 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 | 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 | 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 | 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 | 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 | 159 | {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) | |||
| 1331 | 1331 | func MarshalKill(m protocol.Kill) []byte { | |
| 1332 | 1332 | w := payloadWriter{} | |
| 1333 | 1333 | w.putString(m.Name) | |
| 1334 | - | if m.All { | |
| 1335 | - | w.putUint8(1) | |
| 1334 | + | if m.Scope != protocol.KillSession { | |
| 1335 | + | w.putUint8(uint8(m.Scope)) | |
| 1336 | 1336 | } | |
| 1337 | 1337 | return w.b | |
| 1338 | 1338 | } | |
| @@ -1344,17 +1344,21 @@ func UnmarshalKill(b []byte) (protocol.Kill, error) { | |||
| 1344 | 1344 | if err != nil { | |
| 1345 | 1345 | return protocol.Kill{}, err | |
| 1346 | 1346 | } | |
| 1347 | - | var all bool | |
| 1347 | + | var scope protocol.KillScope | |
| 1348 | 1348 | 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 | |
| 1352 | 1356 | } | |
| 1353 | 1357 | } | |
| 1354 | 1358 | if err := r.done(); err != nil { | |
| 1355 | 1359 | return protocol.Kill{}, err | |
| 1356 | 1360 | } | |
| 1357 | - | return protocol.Kill{Name: name, All: all}, nil | |
| 1361 | + | return protocol.Kill{Name: name, Scope: scope}, nil | |
| 1358 | 1362 | } | |
| 1359 | 1363 | ||
| 1360 | 1364 | // 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.b | 1337 | 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{}, err | 1345 | return protocol.Kill{}, err | ||
| 1346 | } | 1346 | } | ||
| 1347 | - | var all bool | 1347 | + | 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{}, err | 1351 | + | 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{}, err | 1359 | return protocol.Kill{}, err | ||
| 1356 | } | 1360 | } | ||
| 1357 | - | return protocol.Kill{Name: name, All: all}, nil | 1361 | + | return protocol.Kill{Name: name, Scope: scope}, nil |
| 1358 | } | 1362 | } | ||
| 1359 | 1363 | ||||
| 1360 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | 1364 | // 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 ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | 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) | |
| 17 | 17 | } | |
| 18 | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 19 | 19 | if len(payload) < 2 { | |
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 27 | 27 | ||
| 28 | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 29 | 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 | 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 | 32 | want = append(want, 0, 0) | |
| 33 | 33 | got := MarshalHello(msg) | |
| @@ -318,10 +318,10 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |||
| 318 | 318 | }, | |
| 319 | 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 | 322 | 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 | |
| 325 | 325 | return b | |
| 326 | 326 | }(), | |
| 327 | 327 | 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] = 2 | 324 | + | b[len(b)-1] = 3 |
| 325 | return b | 325 | 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) { | |||
| 136 | 136 | } | |
| 137 | 137 | got := MarshalHello(msg) | |
| 138 | 138 | want := []byte{ | |
| 139 | - | 0x00, 0x26, 0x00, // version, intent, first client ID byte | |
| 139 | + | 0x00, 0x27, 0x00, // version, intent, first client ID byte | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 142 | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 367 | 367 | } | |
| 368 | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 369 | 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 | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 528 | 528 | name: "minimal", | |
| 529 | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | |
| 530 | 530 | want: []byte{ | |
| 531 | - | 0x00, 0x26, | |
| 531 | + | 0x00, 0x27, | |
| 532 | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 533 | 533 | 0x00, // attached | |
| 534 | 534 | 0x00, // self | |
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 551 | 551 | JSON: true, | |
| 552 | 552 | }, | |
| 553 | 553 | want: []byte{ | |
| 554 | - | 0x00, 0x26, | |
| 554 | + | 0x00, 0x27, | |
| 555 | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 556 | 556 | 0x00, // attached | |
| 557 | 557 | 0x01, // self | |
| @@ -796,7 +796,8 @@ func TestKillGoldenAndRoundTrip(t *testing.T) { | |||
| 796 | 796 | }{ | |
| 797 | 797 | {name: "named", msg: protocol.Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}}, | |
| 798 | 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 | 803 | for _, tt := range tests { | |
| @@ -988,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 988 | 989 | name: "attached", | |
| 989 | 990 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 990 | 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 | 993 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 993 | 994 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 994 | 995 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -998,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 998 | 999 | name: "control", | |
| 999 | 1000 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | |
| 1000 | 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 | 1003 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 1003 | 1004 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1004 | 1005 | }, | |
| @@ -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 byte | 139 | + | 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 ID | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 142 | 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 ID | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 533 | 0x00, // attached | 533 | 0x00, // attached | ||
| 534 | 0x00, // self | 534 | 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 ID | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 556 | 0x00, // attached | 556 | 0x00, // attached | ||
| 557 | 0x01, // self | 557 | 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 { | |||
| 803 | 803 | snapshotDeadline = newSnapshotShutdownDeadline(d.clock) | |
| 804 | 804 | defer snapshotDeadline.stop() | |
| 805 | 805 | } | |
| 806 | - | d.shutdownAllWithSnapshotDeadline(protocol.ReasonServerShutdown, snapshotDeadline) | |
| 806 | + | d.terminateAllWithSnapshotDeadline(protocol.ReasonServerShutdown, false, snapshotDeadline) | |
| 807 | 807 | d.waitNotifies() | |
| 808 | 808 | d.hardCancel() | |
| 809 | 809 | d.serveCancel() | |
| @@ -823,7 +823,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||
| 823 | 823 | d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded) | |
| 824 | 824 | } | |
| 825 | 825 | d.WaitDurableWriters() | |
| 826 | - | d.shutdownAllWithSnapshotDeadline(protocol.ReasonServerShutdown, snapshotDeadline) | |
| 826 | + | d.terminateAllWithSnapshotDeadline(protocol.ReasonServerShutdown, false, snapshotDeadline) | |
| 827 | 827 | d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline) | |
| 828 | 828 | d.waitNotifies() | |
| 829 | 829 | if err := d.flushCatalogue(); err != nil { | |
| @@ -842,10 +842,14 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||
| 842 | 842 | // inserted after the snapshot: route rejects once closing is set, and both run | |
| 843 | 843 | // under d.mu. killSession (which relocks) runs after the lock is released. | |
| 844 | 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 | 853 | d.cancelRemoteDiscoveryRefresh() | |
| 850 | 854 | d.closeMoveLifecycles() | |
| 851 | 855 | d.mu.Lock() | |
| @@ -853,13 +857,37 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 853 | 857 | d.purgeAllParkingLocked() | |
| 854 | 858 | parkedRetirements := d.purgeAllParkedLocked() | |
| 855 | 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 | 867 | empty := len(snapshot) == 0 | |
| 857 | 868 | d.mu.Unlock() | |
| 858 | 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 | 888 | if empty { | |
| 861 | 889 | d.doneOnce.Do(func() { close(d.done) }) | |
| 862 | - | return false | |
| 890 | + | return checkpointIncomplete | |
| 863 | 891 | } | |
| 864 | 892 | for _, s := range snapshot { | |
| 865 | 893 | // 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 | 897 | ephemeral := s.ephemeral | |
| 870 | 898 | s.mu.Unlock() | |
| 871 | 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 | 901 | checkpointIncomplete = true | |
| 874 | 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 | 905 | select { | |
| 878 | 906 | case <-deadline.Done(): | |
| 879 | 907 | checkpointIncomplete = true | |
| @@ -1143,9 +1171,19 @@ func (d *Daemon) handleList(tr ports.ServerConnection) { | |||
| 1143 | 1171 | func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) { | |
| 1144 | 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 | 1181 | d.shutdownAll(protocol.ReasonServerShutdown) | |
| 1148 | 1182 | return | |
| 1183 | + | case protocol.KillSession: | |
| 1184 | + | default: | |
| 1185 | + | _ = d.boundedControlSend(tr, serverError(protocol.ErrInternal, "invalid kill scope")) | |
| 1186 | + | return | |
| 1149 | 1187 | } | |
| 1150 | 1188 | ||
| 1151 | 1189 | 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 run | 842 | // 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) == 0 | 867 | 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 false | 890 | + | 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 that | 893 | // 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.ephemeral | 897 | 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 = true | 901 | 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 = true | 907 | 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 | return | 1182 | 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 { | |||
| 42 | 42 | // tab, or pane locks. The owner performs teardown without holding teardownMu; | |
| 43 | 43 | // duplicate daemon-shutdown callers can therefore bound their ownership wait | |
| 44 | 44 | // 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 | |
| 52 | 53 | // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles, | |
| 53 | 54 | // and clipboard queue state. | |
| 54 | 55 | // restoreDone remains attached to a restored session after it is published in | |
| @@ -1481,16 +1482,38 @@ func (s *session) signalTeardownChangedLocked() { | |||
| 1481 | 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 | 1499 | // beginTeardown waits for ownership without holding teardownMu across teardown. | |
| 1485 | 1500 | // A false result means Serve's shared shutdown budget expired while another | |
| 1486 | 1501 | // lifecycle path owned the session. Ordinary callers wait and retry ownership, | |
| 1487 | 1502 | // preserving failed-teardown and stopped-purge retry semantics. | |
| 1488 | 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 | 1512 | for { | |
| 1490 | 1513 | if deadline != nil { | |
| 1491 | 1514 | select { | |
| 1492 | 1515 | case <-deadline.Done(): | |
| 1493 | - | return false | |
| 1516 | + | return reason, purge, false | |
| 1494 | 1517 | default: | |
| 1495 | 1518 | } | |
| 1496 | 1519 | } | |
| @@ -1501,15 +1524,19 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool { | |||
| 1501 | 1524 | select { | |
| 1502 | 1525 | case <-deadline.Done(): | |
| 1503 | 1526 | s.teardownMu.Unlock() | |
| 1504 | - | return false | |
| 1527 | + | return reason, purge, false | |
| 1505 | 1528 | default: | |
| 1506 | 1529 | } | |
| 1507 | 1530 | } | |
| 1508 | 1531 | s.teardownActive = true | |
| 1509 | 1532 | s.teardownDone = make(chan struct{}) | |
| 1510 | 1533 | s.signalTeardownChangedLocked() | |
| 1534 | + | if s.shutdownTeardownRequested { | |
| 1535 | + | reason = protocol.ReasonServerShutdown | |
| 1536 | + | purge = false | |
| 1537 | + | } | |
| 1511 | 1538 | s.teardownMu.Unlock() | |
| 1512 | - | return true | |
| 1539 | + | return reason, purge, true | |
| 1513 | 1540 | } | |
| 1514 | 1541 | s.teardownWaiters++ | |
| 1515 | 1542 | s.signalTeardownChangedLocked() | |
| @@ -1531,7 +1558,7 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool { | |||
| 1531 | 1558 | s.signalTeardownChangedLocked() | |
| 1532 | 1559 | s.teardownMu.Unlock() | |
| 1533 | 1560 | if timedOut { | |
| 1534 | - | return false | |
| 1561 | + | return reason, purge, false | |
| 1535 | 1562 | } | |
| 1536 | 1563 | } | |
| 1537 | 1564 | } | |
| @@ -1611,7 +1638,9 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1611 | 1638 | // later callers observe the completed registry transition instead of | |
| 1612 | 1639 | // manufacturing a newer generation that the owner will quarantine. A | |
| 1613 | 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 | 1644 | return nil | |
| 1616 | 1645 | } | |
| 1617 | 1646 | 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 wait | 43 | // 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.Mutex | 45 | + | teardownMu sync.Mutex |
| 46 | - | teardownActive bool | 46 | + | teardownActive bool |
| 47 | - | teardownDone chan struct{} | 47 | + | teardownDone chan struct{} |
| 48 | - | moveReservations uint | 48 | + | moveReservations uint |
| 49 | - | teardownWaiters uint | 49 | + | teardownWaiters uint |
| 50 | - | teardownChanged chan struct{} | 50 | + | teardownChanged chan struct{} |
| 51 | - | lifecycleStopOnce sync.Once | 51 | + | 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 in | 55 | // 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 another | 1500 | // 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 false | 1516 | + | 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 false | 1527 | + | return reason, purge, false |
| 1505 | default: | 1528 | default: | ||
| 1506 | } | 1529 | } | ||
| 1507 | } | 1530 | } | ||
| 1508 | s.teardownActive = true | 1531 | 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 true | 1539 | + | 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 false | 1561 | + | 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 of | 1638 | // later callers observe the completed registry transition instead of | ||
| 1612 | // manufacturing a newer generation that the owner will quarantine. A | 1639 | // 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 nil | 1644 | 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) { | |||
| 144 | 144 | ||
| 145 | 145 | func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) { | |
| 146 | 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 | 148 | d.handleConn(tr) | |
| 149 | 149 | ||
| 150 | 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 | 183 | func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) { | |
| 158 | 184 | p, release := newBlockingPTY(t) | |
| 159 | 185 | 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() | ||