detached 2fea442

Merge pull request #144 from bnema/feat/persistent-snatched-and-moves

feat: persist snatched clients and move panes or tabs
commit
2fea442134d1b80c3cef924c0023b368beae9584
author
bnema · · 2026-07-28 17:21 UTC
committer
GitHub · · 2026-07-28 17:21 UTC
merge of
43eb2f4 + 212c6b7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
111 files · +19565 -1267
M README.md +14 -1
@@ -38,6 +38,8 @@ vev kill <name> kill a session (--all kills everything)
38 38
39 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.39 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
40 40
41+Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.
42+
41 ## Keys43 ## Keys
42 44
43 No prefix key; everything is Alt.45 No prefix key; everything is Alt.
@@ -50,7 +52,7 @@ No prefix key; everything is Alt.
50 | Alt+h/j/k/l, Alt+Arrow | focus pane |52 | Alt+h/j/k/l, Alt+Arrow | focus pane |
51 | Alt+a | jump to a session needing attention |53 | Alt+a | jump to a session needing attention |
52 54
53-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.55+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable for navigation, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
54 56
55 ## Remote attach57 ## Remote attach
56 58
@@ -78,6 +80,17 @@ vev cmd list-panes --json
78 80
79 Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage.81 Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage.
80 82
83+Move the focused pane or active tab with these exact forms:
84+
85+```text
86+vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destination-tab-id>
87+vev cmd [-s <source-session>] [--self] move-tab <destination-session>
88+```
89+
90+Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its client follows the moved pane or tab, activates the destination, and snatches any active destination client.
91+
92+Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.
93+
81 ## Configuration94 ## Configuration
82 95
83 Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, palette codes, the floating terminal, status bar scripts: see [docs/configuration.md](docs/configuration.md). Terminal color and VT compatibility notes live in [docs/terminal.md](docs/terminal.md).96 Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, palette codes, the floating terminal, status bar scripts: see [docs/configuration.md](docs/configuration.md). Terminal color and VT compatibility notes live in [docs/terminal.md](docs/terminal.md).
M internal/app/cmd_test.go +70 -7
@@ -6,6 +6,7 @@ import (
6 "io"6 "io"
7 "math"7 "math"
8 "reflect"8 "reflect"
9+ "strconv"
9 "strings"10 "strings"
10 "testing"11 "testing"
11 12
@@ -107,15 +108,77 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
107 }108 }
108 }109 }
109 110
111+func TestMoveCommandHelpUsesExactUsages(t *testing.T) {
112+ for _, tt := range []struct {
113+ slug, usage, desc string
114+ }{
115+ {"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move the focused pane to another live tab"},
116+ {"move-tab", "move-tab <destination-session>", "Move the active tab to another live session"},
117+ } {
118+ t.Run(tt.slug, func(t *testing.T) {
119+ got := cmdHelp(cmdInvocation{slug: tt.slug})
120+ want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc
121+ if got != want {
122+ t.Fatalf("cmdHelp() = %q, want %q", got, want)
123+ }
124+ })
125+ }
126+}
127+
128+func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
129+ for _, tt := range []struct {
130+ slug string
131+ args []string
132+ }{
133+ {slug: "move-pane", args: []string{"work", "t_dest"}},
134+ {slug: "move-tab", args: []string{"work"}},
135+ } {
136+ t.Run(tt.slug, func(t *testing.T) {
137+ parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
138+ require.NoError(t, err)
139+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
140+ require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
141+ stdout: io.Discard,
142+ getenv: func(string) string { return "" },
143+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
144+ }))
145+ require.Len(t, transport.sent, 1)
146+ request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
147+ require.NoError(t, err)
148+ require.Equal(t, tt.slug, request.Slug)
149+ require.Equal(t, tt.args, request.Args)
150+ require.Equal(t, ports.ProtocolVersion, request.Version)
151+ })
152+ }
153+}
154+
155+func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
156+ for _, invocation := range []cmdInvocation{
157+ {slug: "move-pane"},
158+ {slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
159+ {slug: "move-tab"},
160+ {slug: "move-tab", args: []string{"work", "extra"}},
161+ } {
162+ t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
163+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
164+ Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments",
165+ })}}
166+ err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
167+ stdout: io.Discard,
168+ getenv: func(string) string { return "" },
169+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
170+ })
171+ if ExitCode(err) != 2 {
172+ t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
173+ }
174+ })
175+ }
176+}
177+
110 func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {178 func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
111 for _, slug := range []string{179 for _, slug := range []string{
112- "grow-pane-width",180+ "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
113- "shrink-pane-width",181+ "consume-or-expel-pane-left", "consume-or-expel-pane-right",
114- "grow-pane-height",
115- "shrink-pane-height",
116- "equalize-panes",
117- "consume-or-expel-pane-left",
118- "consume-or-expel-pane-right",
119 } {182 } {
120 t.Run(slug, func(t *testing.T) {183 t.Run(slug, func(t *testing.T) {
121 invocation, err := parseArgs([]string{"cmd", "--self", slug})184 invocation, err := parseArgs([]string{"cmd", "--self", slug})
M internal/domain/types.go +7 -1
@@ -92,5 +92,11 @@ type Rect struct {
92 // SessionID uniquely identifies a session.92 // SessionID uniquely identifies a session.
93 type SessionID string93 type SessionID string
94 94
95-// TabID uniquely identifies a tab within a session.95+// TabID identifies a tab by its session-local, user-facing ID.
96 type TabID string96 type TabID string
97+
98+// TabStableID identifies a tab independently of its mutable session position.
99+type TabStableID string
100+
101+// PaneStableID identifies a pane independently of its layout-local PaneID.
102+type PaneStableID string
M internal/usecase/client/reconnect_toast_test.go +4 -2
@@ -1118,14 +1118,16 @@ func TestAttachAttemptReturnsWhileSenderSendIsBlockedAfterCancellation(t *testin
1118 releaseSender()1118 releaseSender()
1119 }1119 }
1120 1120
1121-func TestDetachedResultReplacementAndCleanDetach(t *testing.T) {1121+func TestDetachedResultLegacyReplacementCompatibilityAndCleanDetach(t *testing.T) {
1122+ // Current daemons keep healthy displaced clients connected. Continue decoding
1123+ // ReasonReplaced from older peers so mixed-version detach handling stays clear.
1122 for _, tt := range []struct {1124 for _, tt := range []struct {
1123 name string1125 name string
1124 reason uint81126 reason uint8
1125 wantText string1127 wantText string
1126 }{1128 }{
1127 {name: "clean detach", reason: ports.ReasonDetach},1129 {name: "clean detach", reason: ports.ReasonDetach},
1128- {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},1130+ {name: "legacy replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},
1129 } {1131 } {
1130 t.Run(tt.name, func(t *testing.T) {1132 t.Run(tt.name, func(t *testing.T) {
1131 err := detachedResult(tt.reason)1133 err := detachedResult(tt.reason)
M internal/usecase/command/command.go +15 -0
@@ -50,6 +50,8 @@ type Context interface {
50 ToggleStack() error50 ToggleStack() error
51 ToggleFloatingPane() error51 ToggleFloatingPane() error
52 ClosePane() error52 ClosePane() error
53+ OpenMovePanePicker() error
54+ OpenMoveTabPicker() error
53 FocusPaneLeft() error55 FocusPaneLeft() error
54 FocusPaneRight() error56 FocusPaneRight() error
55 FocusPaneUp() error57 FocusPaneUp() error
@@ -99,6 +101,16 @@ const (
99 TargetPane101 TargetPane
100 )102 )
101 103
104+// CommandScope declares the serialization boundary needed by a command.
105+type CommandScope uint8
106+
107+const (
108+ CommandScopeSession CommandScope = iota
109+ // CommandScopeCrossSession requires dispatch to defer locking to the
110+ // mutation, which resolves and locks both source and destination sessions.
111+ CommandScopeCrossSession
112+)
113+
102 // ControlOptions carries per-invocation output preferences.114 // ControlOptions carries per-invocation output preferences.
103 type ControlOptions struct {115 type ControlOptions struct {
104 JSON bool116 JSON bool
@@ -115,6 +127,8 @@ type ControlContext interface {
115 CreateSessionNamed(name string) error127 CreateSessionNamed(name string) error
116 CloseTab() error128 CloseTab() error
117 ClosePane() error129 ClosePane() error
130+ MovePane(destinationSession, destinationTabID string) error
131+ MoveTab(destinationSession string) error
118 SplitRight() error132 SplitRight() error
119 SplitLeft() error133 SplitLeft() error
120 SplitUp() error134 SplitUp() error
@@ -152,6 +166,7 @@ type Command struct {
152 Scriptable bool166 Scriptable bool
153 PaletteVisible bool167 PaletteVisible bool
154 Target TargetKind168 Target TargetKind
169+ Scope CommandScope
155 Run func(Context, []string) error170 Run func(Context, []string) error
156 Control func(ControlContext, []string, ControlOptions) (ControlResult, error)171 Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
157 }172 }
M internal/usecase/command/context_mock_test.go +88 -0
@@ -878,6 +878,94 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_
878 return _c878 return _c
879 }879 }
880 880
881+// OpenMovePanePicker provides a mock function for the type MockContext
882+func (_mock *MockContext) OpenMovePanePicker() error {
883+ ret := _mock.Called()
884+
885+ if len(ret) == 0 {
886+ panic("no return value specified for OpenMovePanePicker")
887+ }
888+
889+ var r0 error
890+ if returnFunc, ok := ret.Get(0).(func() error); ok {
891+ r0 = returnFunc()
892+ } else {
893+ r0 = ret.Error(0)
894+ }
895+ return r0
896+}
897+
898+// MockContext_OpenMovePanePicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMovePanePicker'
899+type MockContext_OpenMovePanePicker_Call struct {
900+ *mock.Call
901+}
902+
903+// OpenMovePanePicker is a helper method to define mock.On call
904+func (_e *MockContext_Expecter) OpenMovePanePicker() *MockContext_OpenMovePanePicker_Call {
905+ return &MockContext_OpenMovePanePicker_Call{Call: _e.mock.On("OpenMovePanePicker")}
906+}
907+
908+func (_c *MockContext_OpenMovePanePicker_Call) Run(run func()) *MockContext_OpenMovePanePicker_Call {
909+ _c.Call.Run(func(args mock.Arguments) {
910+ run()
911+ })
912+ return _c
913+}
914+
915+func (_c *MockContext_OpenMovePanePicker_Call) Return(err error) *MockContext_OpenMovePanePicker_Call {
916+ _c.Call.Return(err)
917+ return _c
918+}
919+
920+func (_c *MockContext_OpenMovePanePicker_Call) RunAndReturn(run func() error) *MockContext_OpenMovePanePicker_Call {
921+ _c.Call.Return(run)
922+ return _c
923+}
924+
925+// OpenMoveTabPicker provides a mock function for the type MockContext
926+func (_mock *MockContext) OpenMoveTabPicker() error {
927+ ret := _mock.Called()
928+
929+ if len(ret) == 0 {
930+ panic("no return value specified for OpenMoveTabPicker")
931+ }
932+
933+ var r0 error
934+ if returnFunc, ok := ret.Get(0).(func() error); ok {
935+ r0 = returnFunc()
936+ } else {
937+ r0 = ret.Error(0)
938+ }
939+ return r0
940+}
941+
942+// MockContext_OpenMoveTabPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMoveTabPicker'
943+type MockContext_OpenMoveTabPicker_Call struct {
944+ *mock.Call
945+}
946+
947+// OpenMoveTabPicker is a helper method to define mock.On call
948+func (_e *MockContext_Expecter) OpenMoveTabPicker() *MockContext_OpenMoveTabPicker_Call {
949+ return &MockContext_OpenMoveTabPicker_Call{Call: _e.mock.On("OpenMoveTabPicker")}
950+}
951+
952+func (_c *MockContext_OpenMoveTabPicker_Call) Run(run func()) *MockContext_OpenMoveTabPicker_Call {
953+ _c.Call.Run(func(args mock.Arguments) {
954+ run()
955+ })
956+ return _c
957+}
958+
959+func (_c *MockContext_OpenMoveTabPicker_Call) Return(err error) *MockContext_OpenMoveTabPicker_Call {
960+ _c.Call.Return(err)
961+ return _c
962+}
963+
964+func (_c *MockContext_OpenMoveTabPicker_Call) RunAndReturn(run func() error) *MockContext_OpenMoveTabPicker_Call {
965+ _c.Call.Return(run)
966+ return _c
967+}
968+
881 // OpenNotifications provides a mock function for the type MockContext969 // OpenNotifications provides a mock function for the type MockContext
882 func (_mock *MockContext) OpenNotifications() error {970 func (_mock *MockContext) OpenNotifications() error {
883 ret := _mock.Called()971 ret := _mock.Called()
M internal/usecase/command/registry.go +32 -0
@@ -18,6 +18,8 @@ func Registry() []Command {
18 paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }),18 paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }),
19 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),19 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
20 paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }),20 paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }),
21+ movePaneCommand(),
22+ moveTabCommand(),
21 paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }),23 paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }),
22 paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),24 paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),
23 paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),25 paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),
@@ -78,6 +80,36 @@ func paletteControlOne(slug, code, name, desc string, target TargetKind, run fun
78 return cmd80 return cmd
79 }81 }
80 82
83+func movePaneCommand() Command {
84+ return Command{
85+ Slug: "move-pane", Code: "MPN", Name: "Move pane to tab",
86+ Desc: "Move the focused pane to another live tab", Usage: "move-pane <destination-session> <destination-tab-id>",
87+ PaletteVisible: true, Scriptable: true, Target: TargetPane, Scope: CommandScopeCrossSession,
88+ Run: func(ctx Context, _ []string) error { return ctx.OpenMovePanePicker() },
89+ Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
90+ if len(args) != 2 || args[0] == "" || args[1] == "" {
91+ return ControlResult{}, ErrInvalidArguments
92+ }
93+ return ControlResult{}, ctx.MovePane(args[0], args[1])
94+ },
95+ }
96+}
97+
98+func moveTabCommand() Command {
99+ return Command{
100+ Slug: "move-tab", Code: "MTB", Name: "Move tab to session",
101+ Desc: "Move the active tab to another live session", Usage: "move-tab <destination-session>",
102+ PaletteVisible: true, Scriptable: true, Target: TargetTab, Scope: CommandScopeCrossSession,
103+ Run: func(ctx Context, _ []string) error { return ctx.OpenMoveTabPicker() },
104+ Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
105+ if len(args) != 1 || args[0] == "" {
106+ return ControlResult{}, ErrInvalidArguments
107+ }
108+ return ControlResult{}, ctx.MoveTab(args[0])
109+ },
110+ }
111+}
112+
81 func toastCommand() Command {113 func toastCommand() Command {
82 return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {114 return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
83 severity := "info"115 severity := "info"
M internal/usecase/command/registry_test.go +99 -22
@@ -9,7 +9,7 @@ import (
9 9
10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
11 commands := PaletteRegistry()11 commands := PaletteRegistry()
12- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}12+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
13 13
14 if len(commands) != len(wantCodes) {14 if len(commands) != len(wantCodes) {
15 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))15 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -39,6 +39,76 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
39 }39 }
40 }40 }
41 41
42+func TestMoveCommandsExposeExactRegistryContracts(t *testing.T) {
43+ tests := []struct {
44+ code, slug, name, desc, usage string
45+ target TargetKind
46+ args []string
47+ wantCall string
48+ }{
49+ {
50+ code: "MPN", slug: "move-pane", name: "Move pane to tab",
51+ desc: "Move the focused pane to another live tab", usage: "move-pane <destination-session> <destination-tab-id>",
52+ target: TargetPane, args: []string{"work", "t_dest"}, wantCall: "move-pane:work:t_dest",
53+ },
54+ {
55+ code: "MTB", slug: "move-tab", name: "Move tab to session",
56+ desc: "Move the active tab to another live session", usage: "move-tab <destination-session>",
57+ target: TargetTab, args: []string{"work"}, wantCall: "move-tab:work",
58+ },
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.slug, func(t *testing.T) {
62+ cmd, ok := BySlug(tt.slug)
63+ if !ok {
64+ t.Fatalf("BySlug(%q) missing", tt.slug)
65+ }
66+ if cmd.Code != tt.code || cmd.Name != tt.name || cmd.Desc != tt.desc || cmd.Usage != tt.usage {
67+ t.Fatalf("command metadata = %#v", cmd)
68+ }
69+ if !cmd.PaletteVisible || !cmd.Scriptable || cmd.Target != tt.target || cmd.Scope != CommandScopeCrossSession {
70+ t.Fatalf("command visibility/target/scope = palette:%v scriptable:%v target:%v scope:%v", cmd.PaletteVisible, cmd.Scriptable, cmd.Target, cmd.Scope)
71+ }
72+ ctx := &controlSpy{}
73+ if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); err != nil {
74+ t.Fatalf("Control(%v): %v", tt.args, err)
75+ }
76+ if ctx.call != tt.wantCall {
77+ t.Fatalf("Control(%v) call = %q, want %q", tt.args, ctx.call, tt.wantCall)
78+ }
79+ })
80+ }
81+}
82+
83+func TestMoveControlHandlersRequireExactArguments(t *testing.T) {
84+ for _, tt := range []struct {
85+ slug string
86+ args []string
87+ }{
88+ {slug: "move-pane"},
89+ {slug: "move-pane", args: []string{"work"}},
90+ {slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
91+ {slug: "move-pane", args: []string{"", "t_dest"}},
92+ {slug: "move-tab"},
93+ {slug: "move-tab", args: []string{"work", "extra"}},
94+ {slug: "move-tab", args: []string{""}},
95+ } {
96+ t.Run(tt.slug+"/"+strconv.Itoa(len(tt.args)), func(t *testing.T) {
97+ cmd, ok := BySlug(tt.slug)
98+ if !ok {
99+ t.Fatalf("BySlug(%q) missing", tt.slug)
100+ }
101+ ctx := &controlSpy{}
102+ if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); !errors.Is(err, ErrInvalidArguments) {
103+ t.Fatalf("Control(%v) error = %v, want ErrInvalidArguments", tt.args, err)
104+ }
105+ if ctx.call != "" {
106+ t.Fatalf("invalid arguments delegated: %q", ctx.call)
107+ }
108+ })
109+ }
110+}
111+
42 func TestBySlugExactMatch(t *testing.T) {112 func TestBySlugExactMatch(t *testing.T) {
43 tests := []struct {113 tests := []struct {
44 slug string114 slug string
@@ -73,6 +143,7 @@ func TestRegistryControlMetadata(t *testing.T) {
73 "new-session": TargetSession, "toast": TargetSession,143 "new-session": TargetSession, "toast": TargetSession,
74 "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,144 "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,
75 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146+ "move-pane": TargetPane, "move-tab": TargetTab,
76 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
77 }148 }
78 notScriptable := []string{149 notScriptable := []string{
@@ -193,27 +264,31 @@ func (s *controlSpy) CreateTab() error { return s.record("new-t
193 func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }264 func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }
194 func (s *controlSpy) CloseTab() error { return s.record("close-tab") }265 func (s *controlSpy) CloseTab() error { return s.record("close-tab") }
195 func (s *controlSpy) ClosePane() error { return s.record("close-pane") }266 func (s *controlSpy) ClosePane() error { return s.record("close-pane") }
196-func (s *controlSpy) SplitRight() error { return s.record("split-right") }267+func (s *controlSpy) MovePane(session, tab string) error {
197-func (s *controlSpy) SplitLeft() error { return s.record("split-left") }268+ return s.record("move-pane:" + session + ":" + tab)
198-func (s *controlSpy) SplitUp() error { return s.record("split-up") }269+}
199-func (s *controlSpy) SplitDown() error { return s.record("split-down") }270+func (s *controlSpy) MoveTab(session string) error { return s.record("move-tab:" + session) }
200-func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }271+func (s *controlSpy) SplitRight() error { return s.record("split-right") }
201-func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }272+func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
202-func (s *controlSpy) StackPane() error { return s.record("stack-pane") }273+func (s *controlSpy) SplitUp() error { return s.record("split-up") }
203-func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }274+func (s *controlSpy) SplitDown() error { return s.record("split-down") }
204-func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }275+func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }
205-func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }276+func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
206-func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }277+func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
207-func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }278+func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
208-func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }279+func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
209-func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }280+func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }
210-func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }281+func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }
211-func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }282+func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }
212-func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") }283+func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }
213-func (s *controlSpy) NextTab() error { return s.record("next-tab") }284+func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
214-func (s *controlSpy) PrevTab() error { return s.record("previous-tab") }285+func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
215-func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) }286+func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
216-func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) }287+func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") }
288+func (s *controlSpy) NextTab() error { return s.record("next-tab") }
289+func (s *controlSpy) PrevTab() error { return s.record("previous-tab") }
290+func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) }
291+func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) }
217 func (s *controlSpy) Toast(level, message string) error {292 func (s *controlSpy) Toast(level, message string) error {
218 return s.record("toast:" + level + ":" + message)293 return s.record("toast:" + level + ":" + message)
219 }294 }
@@ -298,6 +373,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
298 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},373 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
299 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},374 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
300 {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},375 {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
376+ {code: "MPN", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMovePanePicker().Return(nil).Once() }},
377+ {code: "MTB", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMoveTabPicker().Return(nil).Once() }},
301 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},378 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
302 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},379 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
303 {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},380 {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
A internal/usecase/daemon/attachment_role.go +192 -0
@@ -0,0 +1,192 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+type attachmentRole uint8
6+
7+const (
8+ attachmentDetached attachmentRole = iota
9+ attachmentActive
10+ attachmentSnatched
11+)
12+
13+// attachmentRole derives the attachment's role from the session-owned
14+// registries. There is deliberately no second role field that could drift.
15+func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
16+ if s == nil || ac == nil {
17+ return attachmentDetached
18+ }
19+ s.mu.Lock()
20+ defer s.mu.Unlock()
21+ return s.attachmentRoleLocked(ac)
22+}
23+
24+// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
25+// intermediate state contains the same attachment in both registries.
26+func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
27+ if ac == nil {
28+ return attachmentDetached
29+ }
30+ if s.client == ac {
31+ return attachmentActive
32+ }
33+ if _, ok := s.snatched[ac]; ok {
34+ return attachmentSnatched
35+ }
36+ return attachmentDetached
37+}
38+
39+func (s *session) addSnatchedLocked(ac *attachedClient) {
40+ if ac == nil {
41+ return
42+ }
43+ if s.snatched == nil {
44+ s.snatched = make(map[*attachedClient]struct{})
45+ }
46+ s.snatched[ac] = struct{}{}
47+}
48+
49+type attachmentRoleToken struct {
50+ sess *session
51+ ac *attachedClient
52+ role attachmentRole
53+ generation uint64
54+ transport transportSnapshot
55+ lease *attachmentLease
56+ effect *roleEffectTicket
57+ // rebase marks a cross-session ownership move whose first paint must start a
58+ // dependency-free output chain. The rebase is deliberately deferred until
59+ // after transition publication releases all architecture locks.
60+ rebase bool
61+}
62+
63+// attachmentTokenLocked captures the exact role, transport incarnation, and
64+// active coordinator lease at an architecture commit point. The caller must
65+// hold s.mu; coordinator acquisition follows the canonical session ->
66+// coordinator order.
67+func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
68+ if s == nil || ac == nil {
69+ return attachmentRoleToken{}
70+ }
71+ transport := ac.transportSnapshot()
72+ if transport.transport == nil {
73+ return attachmentRoleToken{}
74+ }
75+ token := attachmentRoleToken{
76+ sess: s,
77+ ac: ac,
78+ role: s.attachmentRoleLocked(ac),
79+ generation: ac.roleGeneration.Load(),
80+ transport: transport,
81+ }
82+ if token.role == attachmentActive {
83+ if rc := s.renderCoordinator(); rc != nil {
84+ token.lease = rc.attachmentLease(ac)
85+ }
86+ }
87+ return token
88+}
89+
90+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
91+ if s == nil || ac == nil || tr == nil {
92+ return attachmentRoleToken{}
93+ }
94+ s.mu.Lock()
95+ transport := ac.transportSnapshot()
96+ if transport.transport != tr {
97+ s.mu.Unlock()
98+ return attachmentRoleToken{}
99+ }
100+ token := attachmentRoleToken{
101+ sess: s,
102+ ac: ac,
103+ role: s.attachmentRoleLocked(ac),
104+ generation: ac.roleGeneration.Load(),
105+ transport: transport,
106+ }
107+ s.mu.Unlock()
108+ if token.role == attachmentActive {
109+ if rc := s.renderCoordinator(); rc != nil {
110+ token.lease = rc.attachmentLease(ac)
111+ }
112+ }
113+ ac.bootstrapRoleCapability(token)
114+ return token
115+}
116+
117+func (t attachmentRoleToken) current() bool {
118+ return t.sess != nil && t.ac != nil &&
119+ t.ac.roleGeneration.Load() == t.generation &&
120+ t.ac.transportSnapshotCurrent(t.transport) &&
121+ t.sess.attachmentRole(t.ac) == t.role
122+}
123+
124+// activeCurrent admits post-transition effects only for the exact published
125+// role, transport incarnation, current-session link, and coordinator lease.
126+func (t attachmentRoleToken) activeCurrent() bool {
127+ if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
128+ return false
129+ }
130+ rc := t.sess.renderCoordinator()
131+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
132+}
133+
134+// activeEffect is the canonical admission check for a client-frame mutation.
135+// Tokens bind role generation, transport incarnation, session ownership, and
136+// coordinator lease, so handlers never rediscover authority from mutable state.
137+func (t attachmentRoleToken) activeEffect() bool {
138+ if t.effect != nil && !t.effect.ended.Load() {
139+ return true
140+ }
141+ return t.activeCurrent()
142+}
143+
144+func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
145+ if sess == nil || ac == nil || lease == nil {
146+ return nil, false
147+ }
148+ token := sess.attachmentToken(ac, ac.transport())
149+ token.lease = lease
150+ return ac.beginRoleEffect(token)
151+}
152+
153+func (t *attachmentRoleToken) endRoleEffect() {
154+ if t == nil || t.effect == nil {
155+ return
156+ }
157+ t.effect.End()
158+ t.effect = nil
159+}
160+
161+// activeEffectSessionLocked is the same admission check while t.sess.mu is
162+// already held at a session-owned mutation boundary.
163+func (t attachmentRoleToken) activeEffectSessionLocked() bool {
164+ if t.effect != nil && !t.effect.ended.Load() {
165+ return true
166+ }
167+ if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
168+ t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
169+ t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
170+ return false
171+ }
172+ rc := t.sess.renderCoordinator()
173+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
174+}
175+
176+func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
177+ if t.ac == nil {
178+ return errAttachmentTransition
179+ }
180+ t.ac.sendMu.Lock()
181+ defer t.ac.sendMu.Unlock()
182+ if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
183+ !t.effect.beginTransportSend(t.transport) {
184+ return errAttachmentTransition
185+ }
186+ err := t.transport.transport.Send(frame)
187+ if err != nil {
188+ t.effect.reportTransportFailure(t.transport)
189+ }
190+ t.effect.endTransportSend()
191+ return err
192+}
A internal/usecase/daemon/attachment_transition.go +227 -0
@@ -0,0 +1,227 @@
1+package daemon
2+
3+import "errors"
4+
5+var errAttachmentTransition = errors.New("attachment transition is no longer valid")
6+
7+type attachmentTransitionRequest struct {
8+ source *session
9+ target *session
10+ next *attachedClient
11+ expectedRole attachmentRole
12+ targetRole attachmentRole
13+ expectedTransport transportSnapshot
14+ sourceToken *attachmentRoleToken
15+ action string
16+ activateTargetTab bool
17+ targetTabIndex int
18+ copySourceEnvironment bool
19+ // preserveRole commits navigation state without replacing attachment
20+ // ownership. It is used for same-session picker selections after the exact
21+ // initiating capability has been frozen and revalidated.
22+ preserveRole bool
23+ expectedSourceTab *tab
24+ // transferExpectedSourceTab permits an installed visible floating pane only
25+ // when the caller atomically transfers this exact tab under its own tab and
26+ // floating-generation fences. Ordinary navigation still rejects visibility.
27+ transferExpectedSourceTab bool
28+ createTargetLocked func() (*session, error)
29+ ready bool
30+ expectedTargetCurrent *attachedClient
31+ expectedTargetTransport transportSnapshot
32+ preflighted bool
33+ roleEffectsFrozen bool
34+ expectedTargetTransportInterrupted bool
35+ // activationBarrier is used when promoting an already connected snatched
36+ // attachment. Its role gate is deadline-drained before sendMu is acquired,
37+ // preserving the gate -> sendMu -> daemon/routing/session publication order.
38+ activationBarrier bool
39+}
40+
41+func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
42+ return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
43+ token.transport.transport == req.expectedTransport.transport &&
44+ token.transport.incarnation == req.expectedTransport.incarnation
45+}
46+
47+// transitionSourceTokenCurrentLocked requires source.mu and, for an active
48+// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
49+// client-originated navigation, delete, or create intent is bound to one exact
50+// role generation, transport incarnation, and coordinator lease.
51+func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
52+ if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
53+ token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
54+ req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
55+ return false
56+ }
57+ if token.role != attachmentActive {
58+ return true
59+ }
60+ return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
61+ sourceCoordinator.leaseCurrentLocked(token.lease, true)
62+}
63+
64+type attachmentTransitionResult struct {
65+ published attachmentRoleToken
66+ displaced attachmentRoleToken
67+ displacedInterrupted bool
68+ cleanups []renderLifecycleCleanup
69+}
70+
71+type attachmentTransitionParticipants struct {
72+ clients []*attachedClient
73+ interrupts []roleTransportInterrupt
74+}
75+
76+// snapshotAttachmentTransition discovers the exact participants while d.mu is
77+// held. It releases every architecture lock before any role effect is ended or
78+// any gate is frozen and drained.
79+func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
80+ d.mu.Lock()
81+ defer d.mu.Unlock()
82+
83+ source := req.source
84+ if source == nil {
85+ source = req.target
86+ }
87+ if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
88+ d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
89+ req.activationBarrier && (req.sourceToken == nil || source != req.target ||
90+ req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
91+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
92+ }
93+ if req.target != nil {
94+ req.target.mu.Lock()
95+ req.expectedTargetCurrent = req.target.client
96+ if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
97+ req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
98+ }
99+ req.target.mu.Unlock()
100+ }
101+ if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
102+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
103+ }
104+ req.preflighted = true
105+ return req, attachmentTransitionParticipants{
106+ clients: []*attachedClient{req.next, req.expectedTargetCurrent},
107+ interrupts: []roleTransportInterrupt{{
108+ ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
109+ }},
110+ }, nil
111+}
112+
113+// freezeAttachmentTransition drains the snapshotted participants without any
114+// architecture lock held. The returned guard remains frozen through atomic
115+// publication and must be released by the caller.
116+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {
117+ if d.afterRoleEffectParticipantsSnapshotted != nil {
118+ d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
119+ }
120+ var drainDeadline *roleEffectDrainDeadline
121+ var drainDone func() <-chan struct{}
122+ if req.activationBarrier {
123+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
124+ drainDone = drainDeadline.Done
125+ }
126+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
127+ if d.afterRoleEffectGateFrozen != nil {
128+ d.afterRoleEffectGateFrozen(req.action, ac)
129+ }
130+ }}, participants.clients...)
131+ if drainDeadline != nil {
132+ drainDeadline.stop()
133+ }
134+ if !frozen.acquired || !frozen.drained {
135+ if req.activationBarrier {
136+ _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
137+ return frozen, false, errSendTimedOut
138+ }
139+ return frozen, false, errAttachmentTransition
140+ }
141+ interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
142+ if d.afterRoleEffectsFrozen != nil {
143+ d.afterRoleEffectsFrozen()
144+ }
145+ return frozen, interrupted, nil
146+}
147+
148+// publishAttachmentTransition revalidates the frozen source, creates an
149+// optional target, and performs publication while d.mu remains held.
150+func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
151+ d.mu.Lock()
152+ defer d.mu.Unlock()
153+
154+ req.roleEffectsFrozen = true
155+ if req.target == nil {
156+ if !d.transitionSourcePreflightLocked(req) {
157+ return attachmentTransitionResult{}, errAttachmentTransition
158+ }
159+ var err error
160+ req.target, err = req.createTargetLocked()
161+ if err != nil {
162+ return attachmentTransitionResult{}, err
163+ }
164+ req.expectedTargetCurrent = nil
165+ }
166+ return d.transitionAttachmentLocked(req)
167+}
168+
169+// prepareActivatedAttachment rebases the first active paint while the caller
170+// still owns the activation barrier (and therefore sendMu).
171+func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
172+ ac := result.published.ac
173+ if ac.output != nil {
174+ ac.output.rebase()
175+ }
176+ ac.captureFrames = nil
177+ d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
178+ // The first paint still requests a reset, but the old panel dependency chain
179+ // has already been rebased atomically under the activation barrier.
180+ result.published.rebase = false
181+}
182+
183+func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
184+ req, participants, err := d.snapshotAttachmentTransition(req)
185+ if err != nil {
186+ return attachmentTransitionResult{}, err
187+ }
188+ if req.sourceToken != nil {
189+ d.endActionRoleEffect(req.sourceToken.effect, req.action)
190+ }
191+
192+ frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)
193+ defer frozen.unfreeze()
194+ if err != nil {
195+ return attachmentTransitionResult{}, err
196+ }
197+ req.expectedTargetTransportInterrupted = interrupted
198+
199+ var releaseActivation func()
200+ if req.activationBarrier {
201+ releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
202+ if err != nil {
203+ return attachmentTransitionResult{}, err
204+ }
205+ defer func() {
206+ if releaseActivation != nil {
207+ releaseActivation()
208+ }
209+ }()
210+ }
211+
212+ result, err := d.publishAttachmentTransition(req)
213+ if err != nil {
214+ return result, err
215+ }
216+ if req.activationBarrier && result.published.role == attachmentActive {
217+ d.prepareActivatedAttachment(&result)
218+ }
219+ if releaseActivation != nil {
220+ releaseActivation()
221+ releaseActivation = nil
222+ }
223+ if result.published.role == attachmentActive {
224+ result.published.ac.clearSnatchedInput()
225+ }
226+ return result, nil
227+}
A internal/usecase/daemon/attachment_transition_prelocked_test.go +170 -0
@@ -0,0 +1,170 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) {
13+ t.Parallel()
14+
15+ type transitionObservation struct {
16+ result attachmentTransitionResult
17+ activeRole attachmentRole
18+ displacedRole attachmentRole
19+ activeGeneration uint64
20+ displacedGeneration uint64
21+ activeSession *session
22+ displacedSession *session
23+ }
24+
25+ setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {
26+ t.Helper()
27+ d := newTestDaemon(t, nil, stubClock{})
28+ sess := &session{id: domain.SessionID("work")}
29+ old := &attachedClient{tr: &closeTrackingTransport{}}
30+ old.setSession(sess)
31+ sess.client = old
32+ d.sessions[sess.id] = sess
33+ next := &attachedClient{tr: &closeTrackingTransport{}}
34+ return d, sess, old, next
35+ }
36+ observe := func(sess *session, old, next *attachedClient, result attachmentTransitionResult) transitionObservation {
37+ return transitionObservation{
38+ result: result,
39+ activeRole: sess.attachmentRole(next),
40+ displacedRole: sess.attachmentRole(old),
41+ activeGeneration: next.roleGeneration.Load(),
42+ displacedGeneration: old.roleGeneration.Load(),
43+ activeSession: next.currentSession(),
44+ displacedSession: old.currentSession(),
45+ }
46+ }
47+
48+ wrapperDaemon, wrapperSession, wrapperOld, wrapperNext := setup(t)
49+ wrapperResult, err := wrapperDaemon.transitionAttachment(attachmentTransitionRequest{
50+ target: wrapperSession,
51+ next: wrapperNext,
52+ expectedRole: attachmentDetached,
53+ targetRole: attachmentActive,
54+ expectedTransport: wrapperNext.transportSnapshot(),
55+ ready: true,
56+ })
57+ require.NoError(t, err)
58+ wrapper := observe(wrapperSession, wrapperOld, wrapperNext, wrapperResult)
59+
60+ prelockedDaemon, prelockedSession, prelockedOld, prelockedNext := setup(t)
61+ frozen := freezeRoleEffectGates(prelockedNext, prelockedOld)
62+ require.True(t, frozen.acquired)
63+ require.True(t, frozen.drained)
64+ defer frozen.unfreeze()
65+
66+ req := attachmentTransitionRequest{
67+ target: prelockedSession,
68+ next: prelockedNext,
69+ expectedRole: attachmentDetached,
70+ targetRole: attachmentActive,
71+ expectedTransport: prelockedNext.transportSnapshot(),
72+ expectedTargetCurrent: prelockedOld,
73+ preflighted: true,
74+ roleEffectsFrozen: true,
75+ ready: true,
76+ }
77+ type prelockedOutcome struct {
78+ result attachmentTransitionResult
79+ err error
80+ }
81+ outcome := make(chan prelockedOutcome, 1)
82+ go func() {
83+ prelockedDaemon.mu.Lock()
84+ prelockedDaemon.notices.routingMu.Lock()
85+ unlockSessions := lockAttachmentSessions(prelockedSession, prelockedSession)
86+ publication, validateErr := prelockedDaemon.validateAttachmentTransitionPrelocked(req)
87+ var result attachmentTransitionResult
88+ if validateErr == nil {
89+ result = prelockedDaemon.publishAttachmentTransitionPrelocked(publication)
90+ publication.unlockCoordinators()
91+ }
92+ unlockSessions()
93+ prelockedDaemon.notices.routingMu.Unlock()
94+ prelockedDaemon.mu.Unlock()
95+ outcome <- prelockedOutcome{result: result, err: validateErr}
96+ }()
97+
98+ var prelockedResult attachmentTransitionResult
99+ select {
100+ case got := <-outcome:
101+ require.NoError(t, got.err)
102+ prelockedResult = got.result
103+ case <-time.After(time.Second):
104+ t.Fatal("prelocked attachment transition recursively acquired an already-held lock")
105+ }
106+ prelocked := observe(prelockedSession, prelockedOld, prelockedNext, prelockedResult)
107+
108+ require.Equal(t, wrapper.activeRole, prelocked.activeRole)
109+ require.Equal(t, wrapper.displacedRole, prelocked.displacedRole)
110+ require.Equal(t, wrapper.activeGeneration, prelocked.activeGeneration)
111+ require.Equal(t, wrapper.displacedGeneration, prelocked.displacedGeneration)
112+ require.Same(t, prelockedSession, prelocked.activeSession)
113+ require.Same(t, prelockedSession, prelocked.displacedSession)
114+ require.Equal(t, wrapper.result.published.role, prelocked.result.published.role)
115+ require.Equal(t, wrapper.result.displaced.role, prelocked.result.displaced.role)
116+ require.NotNil(t, prelocked.result.published.lease)
117+}
118+
119+func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {
120+ t.Parallel()
121+
122+ d := newTestDaemon(t, nil, stubClock{})
123+ source := &session{id: domain.SessionID("source")}
124+ target := &session{id: domain.SessionID("target"), active: 0}
125+ next := &attachedClient{tr: &closeTrackingTransport{}}
126+ next.setSession(source)
127+ source.addSnatchedLocked(next)
128+ old := &attachedClient{tr: &closeTrackingTransport{}}
129+ old.setSession(target)
130+ target.client = old
131+ d.sessions[source.id] = source
132+ d.sessions[target.id] = target
133+
134+ frozen := freezeRoleEffectGates(next, old)
135+ require.True(t, frozen.acquired)
136+ require.True(t, frozen.drained)
137+ defer frozen.unfreeze()
138+
139+ req := attachmentTransitionRequest{
140+ source: source,
141+ target: target,
142+ next: next,
143+ expectedRole: attachmentSnatched,
144+ targetRole: attachmentActive,
145+ expectedTransport: next.transportSnapshot(),
146+ expectedTargetCurrent: old,
147+ preflighted: true,
148+ roleEffectsFrozen: true,
149+ activateTargetTab: true,
150+ targetTabIndex: 1,
151+ }
152+
153+ d.mu.Lock()
154+ d.notices.routingMu.Lock()
155+ unlockSessions := lockAttachmentSessions(source, target)
156+ publication, err := d.validateAttachmentTransitionPrelocked(req)
157+ unlockSessions()
158+ d.notices.routingMu.Unlock()
159+ d.mu.Unlock()
160+
161+ require.ErrorIs(t, err, errAttachmentTransition)
162+ require.Nil(t, publication)
163+ require.Equal(t, attachmentSnatched, source.attachmentRole(next))
164+ require.Equal(t, attachmentDetached, target.attachmentRole(next))
165+ require.Equal(t, attachmentActive, target.attachmentRole(old))
166+ require.Same(t, source, next.currentSession())
167+ require.Equal(t, uint64(0), next.roleGeneration.Load())
168+ require.Equal(t, uint64(0), old.roleGeneration.Load())
169+ require.Equal(t, 0, target.active)
170+}
A internal/usecase/daemon/attachment_transition_publish.go +328 -0
@@ -0,0 +1,328 @@
1+package daemon
2+
3+// transitionSourcePreflightLocked validates an initiating role while its gate
4+// is frozen and d.mu is held. No caller may create or delete a target before
5+// this succeeds.
6+func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
7+ if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
8+ return false
9+ }
10+ d.notices.routingMu.Lock()
11+ defer d.notices.routingMu.Unlock()
12+ req.source.mu.Lock()
13+ defer req.source.mu.Unlock()
14+ coordinator := req.source.renderCoordinator()
15+ if coordinator == nil {
16+ return false
17+ }
18+ coordinator.mu.Lock()
19+ defer coordinator.mu.Unlock()
20+ if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
21+ return false
22+ }
23+ return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
24+}
25+
26+func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
27+ if token.sess == nil || token.ac == nil || token.role != attachmentActive {
28+ return false
29+ }
30+ d.mu.Lock()
31+ defer d.mu.Unlock()
32+ if d.closing || d.sessions[token.sess.id] != token.sess {
33+ return false
34+ }
35+ d.notices.routingMu.Lock()
36+ defer d.notices.routingMu.Unlock()
37+ token.sess.mu.Lock()
38+ defer token.sess.mu.Unlock()
39+ coordinator := token.sess.renderCoordinator()
40+ if coordinator == nil {
41+ return false
42+ }
43+ coordinator.mu.Lock()
44+ defer coordinator.mu.Unlock()
45+ req := attachmentTransitionRequest{
46+ source: token.sess, next: token.ac, expectedRole: token.role,
47+ expectedTransport: token.transport,
48+ }
49+ return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
50+}
51+
52+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
53+ return transitionSourceTabCurrentForRequestLocked(source, expected, false)
54+}
55+
56+func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
57+ if expected == nil {
58+ return true
59+ }
60+ if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
61+ return false
62+ }
63+ expected.mu.Lock()
64+ defer expected.mu.Unlock()
65+ return transferExpected || expected.floating.state != floatingVisible
66+}
67+
68+// transitionAttachmentLocked is the publication half of transitionAttachment.
69+// Caller holds d.mu after the lock-free gate freeze/drain has completed.
70+func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
71+ d.notices.routingMu.Lock()
72+ defer d.notices.routingMu.Unlock()
73+ return d.transitionAttachmentRoutedLocked(req)
74+}
75+
76+// attachmentPublication is the validated, coordinator-locked input to the
77+// non-failing ownership and lease publication step. Its caller continues to
78+// own d.mu, notices.routingMu, and the ordered source/target session locks.
79+type attachmentPublication struct {
80+ req attachmentTransitionRequest
81+ source *session
82+ old *attachedClient
83+ sourceCoordinator *renderCoordinator
84+ targetCoordinator *renderCoordinator
85+ releaseCoordinators func()
86+ displacedTransport transportSnapshot
87+ nextGeneration uint64
88+ displacedGeneration uint64
89+}
90+
91+func (p *attachmentPublication) unlockCoordinators() {
92+ p.releaseCoordinators()
93+}
94+
95+// validateAttachmentTransitionPrelocked performs every fallible transition
96+// check without acquiring d.mu, notices.routingMu, or either session lock.
97+// The caller holds those locks in that order, with session locks ordered by ID.
98+// On success the returned validation retains the ordered coordinator locks
99+// through publishAttachmentTransitionPrelocked.
100+func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
101+ source := req.source
102+ if source == nil {
103+ source = req.target
104+ }
105+ if !req.preflighted || !req.roleEffectsFrozen ||
106+ !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
107+ source == nil || req.target == nil || req.next == nil || d.closing ||
108+ d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
109+ req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
110+ req.preserveRole && req.sourceToken == nil {
111+ return nil, errAttachmentTransition
112+ }
113+
114+ var targetCoordinator *renderCoordinator
115+ if req.targetRole == attachmentActive {
116+ targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target)
117+ }
118+ sourceCoordinator := source.renderCoordinator()
119+ if source.attachmentRoleLocked(req.next) != req.expectedRole ||
120+ !req.next.transportSnapshotCurrent(req.expectedTransport) {
121+ return nil, errAttachmentTransition
122+ }
123+
124+ old := req.target.client
125+ if old != req.expectedTargetCurrent || source != req.target && old == req.next {
126+ return nil, errAttachmentTransition
127+ }
128+ publication := &attachmentPublication{
129+ req: req, source: source, old: old,
130+ sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
131+ releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
132+ }
133+ invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
134+ !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
135+ invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
136+ invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
137+ (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
138+ invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs))
139+ if invalid {
140+ publication.unlockCoordinators()
141+ return nil, errAttachmentTransition
142+ }
143+ return publication, nil
144+}
145+
146+// applyTargetStateLocked commits navigation metadata before role ownership, as
147+// part of the same locked publication. It reports role-preserving transitions.
148+func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
149+ req := publication.req
150+ if req.targetRole == attachmentActive {
151+ d.demoteParkedActiveForSessionLocked(req.target)
152+ }
153+ if req.copySourceEnvironment {
154+ req.target.terminal = publication.source.terminal
155+ req.target.env = copyEnvironment(publication.source.env)
156+ }
157+ if req.activateTargetTab {
158+ req.target.active = req.targetTabIndex
159+ }
160+ return req.preserveRole
161+}
162+
163+// publishAttachmentOwnershipLocked atomically changes session membership,
164+// generations, and currentSession while all role gates remain frozen.
165+func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
166+ req := publication.req
167+ if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
168+ publication.displacedTransport = publication.old.transportSnapshot()
169+ }
170+ if publication.source != req.target {
171+ if publication.source.client == req.next {
172+ publication.source.client = nil
173+ }
174+ delete(publication.source.snatched, req.next)
175+ }
176+ if req.targetRole == attachmentActive {
177+ delete(req.target.snatched, req.next)
178+ if publication.old != nil && publication.old != req.next {
179+ req.target.addSnatchedLocked(publication.old)
180+ }
181+ req.target.client = req.next
182+ } else {
183+ req.target.addSnatchedLocked(req.next)
184+ }
185+ publication.nextGeneration = req.next.roleGeneration.Add(1)
186+ if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
187+ publication.displacedGeneration = publication.old.roleGeneration.Add(1)
188+ }
189+ req.next.setSession(req.target)
190+}
191+
192+// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
193+// constructs the exact published tokens after ownership has committed.
194+func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
195+ req := publication.req
196+ result := attachmentTransitionResult{}
197+ if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {
198+ result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
199+ }
200+ var lease *attachmentLease
201+ if publication.targetCoordinator != nil {
202+ var cleanup renderLifecycleCleanup
203+ cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)
204+ result.cleanups = append(result.cleanups, cleanup)
205+ }
206+ result.published = attachmentRoleToken{
207+ sess: req.target,
208+ ac: req.next,
209+ role: req.targetRole,
210+ generation: publication.nextGeneration,
211+ transport: req.expectedTransport,
212+ lease: lease,
213+ rebase: publication.source != req.target && req.expectedRole == attachmentActive,
214+ }
215+ if publication.displacedGeneration != 0 {
216+ result.displaced = attachmentRoleToken{
217+ sess: req.target,
218+ ac: publication.old,
219+ role: attachmentSnatched,
220+ generation: publication.displacedGeneration,
221+ transport: publication.displacedTransport,
222+ }
223+ result.displacedInterrupted = req.expectedTargetTransportInterrupted
224+ }
225+ // Membership, currentSession, lease, generation, and both exact admission
226+ // capabilities become visible as one frozen publication. The gates remain
227+ // frozen until every architecture lock has been released.
228+ if req.roleEffectsFrozen {
229+ req.next.publishFrozenRoleCapability(result.published)
230+ if result.displaced.ac != nil {
231+ result.displaced.ac.publishFrozenRoleCapability(result.displaced)
232+ }
233+ }
234+ return result
235+}
236+
237+// publishAttachmentTransitionPrelocked performs only non-failing metadata,
238+// ownership, generation, and coordinator-lease publication. The caller retains
239+// every lock required by validateAttachmentTransitionPrelocked.
240+func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
241+ if d.applyTargetStateLocked(publication) {
242+ return attachmentTransitionResult{published: *publication.req.sourceToken}
243+ }
244+ publishAttachmentOwnershipLocked(publication)
245+ return buildAttachmentPostcommitPlanLocked(publication)
246+}
247+
248+// transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for
249+// the composable prelocked validation/publication seam. Caller holds d.mu and
250+// notices.routingMu; this function acquires ordered source/target session locks.
251+func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
252+ source := req.source
253+ if source == nil {
254+ source = req.target
255+ }
256+ if source == nil || req.target == nil {
257+ return attachmentTransitionResult{}, errAttachmentTransition
258+ }
259+ unlockSessions := lockAttachmentSessions(source, req.target)
260+ defer unlockSessions()
261+
262+ publication, err := d.validateAttachmentTransitionPrelocked(req)
263+ if err != nil {
264+ return attachmentTransitionResult{}, err
265+ }
266+ defer publication.unlockCoordinators()
267+ return d.publishAttachmentTransitionPrelocked(publication), nil
268+}
269+
270+func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
271+ if role == attachmentActive || role == attachmentSnatched {
272+ return true
273+ }
274+ return expected && role == attachmentDetached
275+}
276+
277+// lockAttachmentSessions gives every two-session transition one stable order.
278+// Session IDs are immutable and unique while their lifecycles are registered.
279+func lockAttachmentSessions(a, b *session) func() {
280+ if a == b {
281+ a.mu.Lock()
282+ return a.mu.Unlock
283+ }
284+ first, second := a, b
285+ if first.id > second.id {
286+ first, second = second, first
287+ }
288+ first.mu.Lock()
289+ second.mu.Lock()
290+ return func() {
291+ second.mu.Unlock()
292+ first.mu.Unlock()
293+ }
294+}
295+
296+// lockAttachmentCoordinators follows the same immutable session-ID order as
297+// lockAttachmentSessions. Callers already hold the ordered session locks.
298+func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
299+ if aCoordinator == nil && bCoordinator == nil {
300+ return func() {}
301+ }
302+ if a == b || aCoordinator == bCoordinator {
303+ coordinator := aCoordinator
304+ if coordinator == nil {
305+ coordinator = bCoordinator
306+ }
307+ coordinator.mu.Lock()
308+ return coordinator.mu.Unlock
309+ }
310+ first, second := aCoordinator, bCoordinator
311+ if a.id > b.id {
312+ first, second = second, first
313+ }
314+ if first != nil {
315+ first.mu.Lock()
316+ }
317+ if second != nil {
318+ second.mu.Lock()
319+ }
320+ return func() {
321+ if second != nil {
322+ second.mu.Unlock()
323+ }
324+ if first != nil {
325+ first.mu.Unlock()
326+ }
327+ }
328+}
M internal/usecase/daemon/attention.go +9 -1
@@ -47,6 +47,10 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
47 // happened — it also covers "no target exists", which is routine and not an47 // happened — it also covers "no target exists", which is routine and not an
48 // error. Only a failure to reach a target that does exist is a genuine error.48 // error. Only a failure to reach a target that does exist is a genuine error.
49 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {49 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
50+ return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{})
51+}
52+
53+func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error {
50 if sess == nil || ac == nil {54 if sess == nil || ac == nil {
51 return nil55 return nil
52 }56 }
@@ -62,7 +66,11 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
62 if !ok {66 if !ok {
63 return nil67 return nil
64 }68 }
65- return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})69+ pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
70+ if token.ac == nil {
71+ return d.switchToTarget(sess, ac, pickerTarget)
72+ }
73+ return d.switchActiveTargetForRole(token, pickerTarget)
66 }74 }
67 75
68 func oldestAttentionTab(sess *session) (int, bool) {76 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +274 -306
@@ -39,10 +39,16 @@ type attachedClient struct {
39 overlays *overlayRuntime39 overlays *overlayRuntime
40 overlayOnce sync.Once40 overlayOnce sync.Once
41 clientID [16]byte41 clientID [16]byte
42- resumeCapable bool42+ // roleGeneration is the wire-facing capability generation. roleEffects is
43- resumeToken uint6443+ // the linearization gate for every role-bound observable operation. A role
44- parked bool44+ // transition freezes and drains this gate before changing either generation
45- echoAck atomic.Uint6445+ // or the session/coordinator registries.
46+ roleGeneration atomic.Uint64
47+ roleEffects roleEffectGate
48+ resumeCapable bool
49+ resumeToken uint64
50+ parked bool
51+ echoAck atomic.Uint64
46 // prepareFailureFallback prevents a direct fallback paint from recursively52 // prepareFailureFallback prevents a direct fallback paint from recursively
47 // reporting the same failed prepare through its notice repaint. It is only53 // reporting the same failed prepare through its notice repaint. It is only
48 // needed while no render coordinator is installed.54 // needed while no render coordinator is installed.
@@ -56,20 +62,33 @@ type attachedClient struct {
56 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so62 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
57 // snapshots cannot leak when an attachment switches tabs or sessions.63 // snapshots cannot leak when an attachment switches tabs or sessions.
58 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held64 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
59- size domain.Size65+ // initialSnatchedMu elects exactly one reset-panel sender per role generation.
60- keys *keys.Router66+ // A Welcome handshake and displaced-client cleanup may both discover the
61- sess Guarded[*session]67+ // same post-transition snatched role; the loser waits for the elected send.
62- mouseScan mouse.Scanner68+ initialSnatchedMu sync.Mutex
63- themeMu sync.Mutex69+ initialSnatchedGeneration uint64
64- clientTheme themeui.Theme70+ initialSnatchedAttempt *initialSnatchedPanelAttempt
65- appliedTheme appliedTheme71+ size domain.Size
66- lastCursor cursorOut72+ keys *keys.Router
67- renderStages renderStageHooks // optional render and handoff observability hooks73+ sess Guarded[*session]
74+ mouseScan mouse.Scanner
75+ themeMu sync.Mutex
76+ clientTheme themeui.Theme
77+ appliedTheme appliedTheme
78+ lastCursor cursorOut
79+ renderStages renderStageHooks // optional render and handoff observability hooks
68 // previousSession is guarded independently. It is retained through temporary80 // previousSession is guarded independently. It is retained through temporary
69 // setSession(nil) hand-offs and cleared only on terminal teardown.81 // setSession(nil) hand-offs and cleared only on terminal teardown.
70 previousSession Guarded[*session]82 previousSession Guarded[*session]
71- linkMu sync.Mutex83+ // snatchedInputMu serializes the restricted input parser with its delayed
72- sendMu sync.Mutex84+ // standalone-ESC callback. It is independent of routing and transport locks;
85+ // role activation and terminal close clear it only after releasing those locks.
86+ snatchedInputMu sync.Mutex
87+ snatchedInputPending []byte
88+ snatchedInputDrain bool
89+ snatchedInputESC pendingByteTimer
90+ linkMu sync.Mutex
91+ sendMu sync.Mutex
73 }92 }
74 93
75 type cursorOut struct {94 type cursorOut struct {
@@ -209,12 +228,14 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
209 return nil228 return nil
210 }229 }
211 ac.linkMu.Lock()230 ac.linkMu.Lock()
212- defer ac.linkMu.Unlock()
213 if ac.tr != tr {231 if ac.tr != tr {
232+ ac.linkMu.Unlock()
214 return nil233 return nil
215 }234 }
216 ac.tr = nil235 ac.tr = nil
217 ac.transportIncarnation++236 ac.transportIncarnation++
237+ ac.linkMu.Unlock()
238+ ac.clearSnatchedInput()
218 return tr239 return tr
219 }240 }
220 241
@@ -232,14 +253,6 @@ func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) b
232 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation253 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
233 }254 }
234 255
235-func (ac *attachedClient) closeTransport() error {
236- tr := ac.transport()
237- if tr == nil {
238- return nil
239- }
240- return tr.Close()
241-}
242-
243 func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {256 func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
244 if tr == nil {257 if tr == nil {
245 return nil258 return nil
@@ -263,22 +276,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
263 ac.output.ack(state)276 ac.output.ack(state)
264 }277 }
265 278
266-// send serialises a frame onto the client's transport.
267-func (ac *attachedClient) send(f ports.Frame) error {
268- _, err := ac.sendTransport(f)
269- return err
270-}
271-
272-func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) {
273- ac.sendMu.Lock()
274- defer ac.sendMu.Unlock()
275- tr := ac.transport()
276- if tr == nil {
277- return nil, errors.New("client transport is nil")
278- }
279- return tr, tr.Send(f)
280-}
281-
282 var errTransportReplaced = errors.New("client transport was replaced")279 var errTransportReplaced = errors.New("client transport was replaced")
283 280
284 // sendExpectedTransport writes only when expected is still the attachment's281 // sendExpectedTransport writes only when expected is still the attachment's
@@ -295,6 +292,21 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
295 return expected.transport.Send(f)292 return expected.transport.Send(f)
296 }293 }
297 294
295+func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error {
296+ ac.sendMu.Lock()
297+ defer ac.sendMu.Unlock()
298+ if ticket == nil || ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) ||
299+ expected.transport == nil || !ticket.beginTransportSend(expected) {
300+ return errAttachmentTransition
301+ }
302+ err := expected.transport.Send(f)
303+ if err != nil {
304+ ticket.reportTransportFailure(expected)
305+ }
306+ ticket.endTransportSend()
307+ return err
308+}
309+
298 // boundedSend sends f to ac with a deadline watchdog: if the send (including310 // boundedSend sends f to ac with a deadline watchdog: if the send (including
299 // waiting on sendMu behind a wedged paint) does not complete within311 // waiting on sendMu behind a wedged paint) does not complete within
300 // detachNotifyTimeout, the transport is force-closed, failing the in-flight312 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -372,12 +384,18 @@ func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transpor
372 }384 }
373 }385 }
374 386
375-// notifyDetachedAsync delivers a best-effort Detached notice off the caller's387+type detachedAttachmentSnapshot struct {
376-// goroutine and then closes the transport. Session teardown (killSession) must388+ ac *attachedClient
377-// complete regardless of client state, so the notice is both asynchronous and389+ transport transportSnapshot
378-// deadline-bounded; Serve waits for pending notices before force-closing390+}
379-// connections so a graceful notice is not raced by the hard close.391+
380-func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {392+// notifyDetachedSnapshotAsync sends and closes only the transport incarnation
393+// captured by atomic session teardown. It cannot touch a later link installed
394+// on the same attachedClient object.
395+func (d *Daemon) notifyDetachedSnapshotAsync(snapshot detachedAttachmentSnapshot, reason uint8) {
396+ if snapshot.ac == nil || snapshot.transport.transport == nil {
397+ return
398+ }
381 done := make(chan struct{})399 done := make(chan struct{})
382 d.mu.Lock()400 d.mu.Lock()
383 // Prune completed entries so the slice stays bounded by the number of401 // Prune completed entries so the slice stays bounded by the number of
@@ -395,8 +413,14 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
395 413
396 go func() {414 go func() {
397 defer close(done)415 defer close(done)
398- d.boundedSend(ac, frameDetached(reason))416+ _, _ = d.boundedSendWith(snapshot.transport.transport, func() error {
399- _ = ac.closeTransport()417+ return snapshot.ac.sendExpectedTransport(snapshot.transport, frameDetached(reason))
418+ })
419+ revoked := snapshot.ac.revokeTransport(snapshot.transport.transport)
420+ if revoked == nil {
421+ revoked = snapshot.transport.transport
422+ }
423+ _ = snapshot.ac.closeCapturedTransport(revoked)
400 d.log.Info("client detached", "reason", reason)424 d.log.Info("client detached", "reason", reason)
401 }()425 }()
402 }426 }
@@ -424,13 +448,39 @@ type attachClientOptions struct {
424 maxOutputInFlight uint8448 maxOutputInFlight uint8
425 }449 }
426 450
427-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {451+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
428- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts)452+ d.mu.Lock()
429- cleanup.finish()453+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
430- return ac, old454+ d.mu.Unlock()
455+ result, err := d.transitionAttachment(attachmentTransitionRequest{
456+ target: sess,
457+ next: ac,
458+ expectedRole: attachmentDetached,
459+ targetRole: attachmentActive,
460+ expectedTransport: ac.transportSnapshot(),
461+ ready: false,
462+ })
463+ if err != nil {
464+ if tr != nil {
465+ _ = tr.Close()
466+ }
467+ return nil, nil, err
468+ }
469+ d.finishAttachedClient(sess, ac, opts)
470+ d.deferAttachmentTransitionCleanups(result)
471+ return ac, result.displaced.ac, nil
472+}
473+
474+func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
475+ d.touchMRU(sess)
476+ d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
477+ d.applyHostTheme(sess, ac, themeui.Theme{}, true)
431 }478 }
432 479
433-func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) {480+// prepareAttachedClientLocked allocates one detached attachment. Caller holds
481+// d.mu only to allocate its resume token; role publication happens later after
482+// the caller releases every architecture lock.
483+func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
434 resumeToken := uint64(0)484 resumeToken := uint64(0)
435 if opts.resumeCapable {485 if opts.resumeCapable {
436 resumeToken = d.nextResumeTokenLocked()486 resumeToken = d.nextResumeTokenLocked()
@@ -444,54 +494,8 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
444 resumeToken: resumeToken,494 resumeToken: resumeToken,
445 }495 }
446 ac.initOverlays()496 ac.initOverlays()
447- ac.setSession(sess)
448 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)497 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
449- // Daemon attachment callers hold d.mu, serialising this preparation with498+ return ac
450- // other publications. routingMu makes the whole replacement atomic with
451- // global notice selection: a selected old client cannot become stale before
452- // its toast is published, and a new client is never missed after publication.
453- d.notices.routingMu.Lock()
454- sess.mu.Lock()
455- old := sess.client
456- name := sess.name
457- sess.mu.Unlock()
458- _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
459- sess.mu.Lock()
460- sess.client = ac
461- sess.mu.Unlock()
462- d.notices.routingMu.Unlock()
463- d.touchMRU(sess)
464- d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
465- d.applyHostTheme(sess, ac, themeui.Theme{}, true)
466- return ac, old, cleanup
467-}
468-
469-// handoffCoordinator prepares a cross-session ownership transfer before the
470-// destination publishes sess.client. Callers may hold d.mu, but this function
471-// acquires no daemon or session locks while it holds sendMu.
472-func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
473- cleanups := make([]renderLifecycleCleanup, 0, 2)
474- if rc := from.renderCoordinator(); rc != nil {
475- cleanups = append(cleanups, rc.beginDetach(current))
476- }
477- if current.renderStages.handoffRebase != nil {
478- current.renderStages.handoffRebase()
479- }
480- current.sendMu.Lock()
481- current.output.rebase()
482- // Capture snapshots are attachment-owned. Discard them while the attachment
483- // is exclusively held so the transfer releases panes no longer reachable
484- // from the target session.
485- current.captureFrames = nil
486- current.sendMu.Unlock()
487- _, cleanup := d.attachCoordinatorDeferred(target, old, current, true)
488- return append(cleanups, cleanup)
489-}
490-
491-func finishRenderLifecycleCleanups(cleanups []renderLifecycleCleanup) {
492- for _, cleanup := range cleanups {
493- cleanup.finish()
494- }
495 }499 }
496 500
497 // attachCoordinator is the sole direct attachment handoff. It creates at501 // attachCoordinator is the sole direct attachment handoff. It creates at
@@ -504,37 +508,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
504 }508 }
505 509
506 func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {510 func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
507- sess.mu.Lock()511+ rc := d.ensureRenderCoordinator(sess)
508- rc := sess.renderCoordinator()
509- if rc == nil {
510- rc = newRenderCoordinator(renderCoordinatorOptions{
511- clock: d.clock,
512- observer: d.runtimeObserver,
513- wake: func(w renderWake) {
514- // Never reread sess.client here: a wake is bound to both its
515- // attachment and its coordinator incarnation. The second check in
516- // paint occurs under sendMu, closing a park/resume race after this
517- // unlocked coordinator validation.
518- if w.lease != nil && rc.wakeCurrent(w) {
519- d.paint(sess, w.lease.attachment, w.reset, w.lease)
520- }
521- },
522- ackReady: func() bool {
523- sess.mu.Lock()
524- attached := sess.client
525- sess.mu.Unlock()
526- if attached == nil {
527- return false
528- }
529- attached.sendMu.Lock()
530- ready := !attached.output.atCapacity()
531- attached.sendMu.Unlock()
532- return ready
533- },
534- })
535- sess.installRenderCoordinator(rc)
536- }
537- sess.mu.Unlock()
538 var cleanup renderLifecycleCleanup512 var cleanup renderLifecycleCleanup
539 if old != nil {513 if old != nil {
540 cleanup = rc.beginReplace(old, current, ready)514 cleanup = rc.beginReplace(old, current, ready)
@@ -544,6 +518,51 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
544 return rc, cleanup518 return rc, cleanup
545 }519 }
546 520
521+// ensureRenderCoordinator publishes the session's single coordinator without
522+// binding an attachment lease. Centralized attachment transitions bind the
523+// lease later while their ordered session locks are still held.
524+func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
525+ sess.mu.Lock()
526+ defer sess.mu.Unlock()
527+ return d.ensureRenderCoordinatorPrelocked(sess)
528+}
529+
530+// ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
531+// for transactions that already hold sess.mu.
532+func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
533+ if rc := sess.renderCoordinator(); rc != nil {
534+ return rc
535+ }
536+ var rc *renderCoordinator
537+ rc = newRenderCoordinator(renderCoordinatorOptions{
538+ clock: d.clock,
539+ observer: d.runtimeObserver,
540+ wake: func(w renderWake) {
541+ // Never reread sess.client here: a wake is bound to both its
542+ // attachment and its coordinator incarnation. The second check in
543+ // paint occurs under sendMu, closing a park/resume race after this
544+ // unlocked coordinator validation.
545+ if w.lease != nil && rc.wakeCurrent(w) {
546+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
547+ }
548+ },
549+ ackReady: func() bool {
550+ sess.mu.Lock()
551+ attached := sess.client
552+ sess.mu.Unlock()
553+ if attached == nil {
554+ return false
555+ }
556+ attached.sendMu.Lock()
557+ ready := attached.output == nil || !attached.output.atCapacity()
558+ attached.sendMu.Unlock()
559+ return ready
560+ },
561+ })
562+ sess.installRenderCoordinator(rc)
563+ return rc
564+}
565+
547 // resetScreenDefaultColors clears the known default foreground/background566 // resetScreenDefaultColors clears the known default foreground/background
548 // colors on every tab in sess. Called once a client has been detached, this567 // colors on every tab in sess. Called once a client has been detached, this
549 // makes child OSC 10/11 queries go back to being swallowed (Known=false)568 // makes child OSC 10/11 queries go back to being swallowed (Known=false)
@@ -559,43 +578,51 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
559 d.applyHostTheme(sess, nil, themeui.Theme{}, true)578 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
560 }579 }
561 580
562-// retireReplacedClient owns old-link retirement and obsolete render-worker581+// firstPaintForTransition is the only active post-transition paint entry
563-// joins outside the replacement handshake. If a render already owns sendMu,582+// point. It rejects a stale role capability before rebasing output and carries
564-// close immediately interrupts it; no Detached frame can safely overtake that583+// the exact coordinator lease through every resize and render effect.
565-// render. Otherwise the old link gets its bounded protocol notice first.584+func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
566-func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) {585+ if !token.activeCurrent() {
567- if old == nil {586+ return false
568- d.attachmentCleanupWg.Go(cleanup.finish)
569- return
570 }587 }
571- oldTransport := old.transportSnapshot().transport588+ ticket, admitted := token.ac.beginRoleEffect(token)
572- blockedRender := !old.sendMu.TryLock()589+ if !admitted {
573- if !blockedRender {590+ return false
574- old.sendMu.Unlock()
575 }591 }
576-592+ token.effect = ticket
577- d.attachmentCleanupWg.Go(func() {593+ defer token.endRoleEffect()
578- if !blockedRender {594+ if d.afterRoleEffectAdmitted != nil {
579- // A healthy idle client observes the required replacement notice. Its595+ d.afterRoleEffectAdmitted(token)
580- // watchdog still bounds a peer that stops draining after this check.596+ }
581- d.boundedSend(old, frameDetached(ports.ReasonReplaced))597+ if token.rebase {
598+ if d.beforeFirstPaintSendWait != nil {
599+ d.beforeFirstPaintSendWait(token)
582 }600 }
583- _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))601+ token.ac.sendMu.Lock()
584- d.unregisterPreview(old)602+ if token.ac.renderStages.handoffRebase != nil {
585- old.clearPreviousSession()603+ token.ac.renderStages.handoffRebase()
586- old.setSession(nil)604+ }
587- d.log.Info("client displaced")605+ if token.ac.output != nil {
588- })606+ token.ac.output.rebase()
589- // Join independently: the close above releases a blocked render while the607+ }
590- // new attachment is already fully published.608+ // Captures belong to the old session even when pane-local IDs happen
591- d.attachmentCleanupWg.Go(cleanup.finish)609+ // to be reused by the destination.
610+ token.ac.captureFrames = nil
611+ token.ac.sendMu.Unlock()
612+ }
613+ d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
614+ return true
592 }615 }
593 616
594 // firstPaint guarantees the freshly attached client sees the full screen: if617 // firstPaint guarantees the freshly attached client sees the full screen: if
595 // the tab size differs from the client's it resizes first, then immediately618 // the tab size differs from the client's it resizes first, then immediately
596-// emits a full redraw. Attach must not wait for the resize-idle fallback619+// emits a full redraw. It is retained for direct test/headless setup; active
597-// timer.620+// role transitions use firstPaintForTransition with their exact lease.
598 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {621 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
622+ d.firstPaintWithLease(sess, ac, clientSize, nil)
623+}
624+
625+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) {
599 // Global notices raised while nothing was attached surface on this client.626 // Global notices raised while nothing was attached surface on this client.
600 // Drained before the early return below so a session without an active tab627 // Drained before the early return below so a session without an active tab
601 // cannot swallow the queue.628 // cannot swallow the queue.
@@ -610,134 +637,30 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
610 637
611 outerResizeAccepted := false638 outerResizeAccepted := false
612 if clientSize.Valid() && wsz != tabSize(clientSize) {639 if clientSize.Valid() && wsz != tabSize(clientSize) {
613- outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)640+ if lease == nil {
641+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
642+ } else {
643+ outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true)
644+ }
614 }645 }
615 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)646 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
616 // Activation can synchronously resize a retained floating pane. An accepted647 // Activation can synchronously resize a retained floating pane. An accepted
617 // synchronous outer request already includes that pane and emits the reset,648 // synchronous outer request already includes that pane and emits the reset,
618 // but activation still performs its warmup work.649 // but activation still performs its warmup work.
619- activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)650+ activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)
620 if !outerResizeAccepted && !activationResized {651 if !outerResizeAccepted && !activationResized {
621- d.invalidateRenderNow(sess, ac, true, "client.go")652+ if lease == nil {
622- }653+ d.invalidateRenderNow(sess, ac, true, "client.go")
623-}654+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
624-655+ rc.fireCurrent(false)
625-// runConnLoop is the per-connection input router: it pumps client messages
626-// until detach, EOF, or a transport error.
627-func (d *Daemon) runConnLoop(ac *attachedClient) {
628- tr := ac.transport()
629- if tr == nil {
630- return
631- }
632- for {
633- if !ac.currentTransportIs(tr) {
634- return
635- }
636- sess := ac.currentSession()
637- if sess == nil {
638- return
639- }
640- f, err := tr.Recv()
641- if err != nil {
642- d.clientGone(sess, ac, tr, false)
643- return
644- }
645- if !ac.currentTransportIs(tr) {
646- return
647- }
648- sess = ac.currentSession()
649- if sess == nil {
650- return
651- }
652- switch f.Type {
653- case ports.MsgInput:
654- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
655- d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)
656- }
657- case ports.MsgResize:
658- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
659- d.resize(sess, ac, rz.Size)
660- }
661- case ports.MsgTheme:
662- if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
663- d.applyTheme(sess, ac, th)
664- }
665- case ports.MsgImagePush:
666- if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
667- d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
668- }
669- case ports.MsgClientNotice:
670- if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
671- d.handleClientNotice(sess, ac, notice)
672- } else {
673- d.log.Warn("malformed client notice", "err", derr)
674- }
675- case ports.MsgDetach:
676- d.clientGone(sess, ac, tr, true)
677- return
678- case ports.MsgAck:
679- if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
680- ac.ackOutputState(ack.AckedStateNum)
681- if rc := sess.renderCoordinator(); rc != nil {
682- rc.notifyAck()
683- }
684- }
685- case ports.MsgPing:
686- _ = ac.send(framePong())
687- default:
688- // Unknown/out-of-band client messages are ignored so a newer
689- // client can add message types without breaking an older daemon.
690 }656 }
691 }657 }
692 }658 }
693 659
694-// clientGone detaches ac if it is still the session's current client. The
695-// session remains registered and headless after the client is gone.
696-func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
697- if failed != nil && !ac.currentTransportIs(failed) {
698- return // stale connection loop; a newer transport owns this client
699- }
700- if !d.detachIfCurrent(sess, ac) {
701- return // already displaced by a newer client; nothing to do
702- }
703- if rc := sess.renderCoordinator(); rc != nil {
704- rc.noteDetach(ac)
705- }
706- d.unregisterPreview(ac)
707- sess.mu.Lock()
708- ephemeral := sess.ephemeral
709- sess.mu.Unlock()
710- if !ephemeral {
711- d.refreshSessionCwd(sess)
712- }
713- d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
714- oldTr := failed
715- if oldTr == nil {
716- oldTr = ac.transport()
717- }
718- if !explicit && d.parkAttachment(sess, ac) {
719- _ = ac.closeCapturedTransport(oldTr)
720- d.log.Info("client parked", "session", sess.name)
721- return
722- }
723-
724- d.resetScreenDefaultColors(sess)
725- ac.clearPreviousSession()
726- if explicit {
727- // Synchronous so the ack is delivered before the transport closes
728- // (the client is actively awaiting it), but deadline-bounded so a
729- // wedged client cannot pin this conn handler and hang Serve's
730- // connWg.Wait.
731- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
732- }
733- _ = ac.closeCapturedTransport(oldTr)
734- d.log.Info("client detached", "session", sess.name, "explicit", explicit)
735-}
736-
737 // detachIfCurrent clears the client iff ac is the current one, reporting660 // detachIfCurrent clears the client iff ac is the current one, reporting
738 661
739-func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {662+func themeFromMessage(msg ports.Theme) themeui.Theme {
740- clientTheme := themeui.Theme{663+ return themeui.Theme{
741 Foreground: msg.Foreground,664 Foreground: msg.Foreground,
742 Background: msg.Background,665 Background: msg.Background,
743 Palette: msg.Palette,666 Palette: msg.Palette,
@@ -749,6 +672,10 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
749 SchemeKnown: msg.SchemeKnown,672 SchemeKnown: msg.SchemeKnown,
750 Light: msg.Light,673 Light: msg.Light,
751 }674 }
675+}
676+
677+func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
678+ clientTheme := themeFromMessage(msg)
752 ac.setClientTheme(clientTheme)679 ac.setClientTheme(clientTheme)
753 680
754 if !d.applyHostTheme(sess, ac, clientTheme, false) {681 if !d.applyHostTheme(sess, ac, clientTheme, false) {
@@ -757,6 +684,24 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
757 d.invalidateRender(sess, ac, true, "client.go")684 d.invalidateRender(sess, ac, true, "client.go")
758 }685 }
759 686
687+func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool {
688+ if !token.activeEffect() {
689+ return false
690+ }
691+ clientTheme := themeFromMessage(msg)
692+ token.ac.setClientTheme(clientTheme)
693+ if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) {
694+ return false
695+ }
696+ if !token.activeEffect() {
697+ return false
698+ }
699+ if rc := token.sess.renderCoordinator(); rc != nil {
700+ return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
701+ }
702+ return false
703+}
704+
760 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {705 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
761 d.requestTransactionalResize(sess, ac, sz, false)706 d.requestTransactionalResize(sess, ac, sz, false)
762 }707 }
@@ -767,32 +712,78 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
767 // publishing sess.client. Never retain sess.mu while touching notice or overlay712 // publishing sess.client. Never retain sess.mu while touching notice or overlay
768 // state, and retain the routingMu -> sess.mu order used by attachment paths.713 // state, and retain the routingMu -> sess.mu order used by attachment paths.
769 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {714 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
715+ if sess == nil || ac == nil {
716+ return
717+ }
718+ tr := ac.transport()
719+ token := sess.attachmentToken(ac, tr)
720+ if token.lease == nil {
721+ // Direct test/headless callers retain pointer-based routing. Production
722+ // client frames always carry the exact lease through the role path below.
723+ d.handleClientNoticeDirect(sess, ac, notice)
724+ return
725+ }
726+ d.handleClientNoticeForRole(token, notice)
727+}
728+
729+func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) {
770 d.notices.routingMu.Lock()730 d.notices.routingMu.Lock()
771- defer d.notices.routingMu.Unlock()731+ if !token.activeEffect() {
732+ d.notices.routingMu.Unlock()
733+ return
734+ }
735+ if d.notices.beforeClientNoticeMutation != nil {
736+ d.notices.beforeClientNoticeMutation()
737+ }
738+ if !token.activeEffect() {
739+ d.notices.routingMu.Unlock()
740+ return
741+ }
742+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
743+ d.notices.routingMu.Unlock()
744+ if repaint && token.activeEffect() {
745+ d.repaintForNotice(token.ac)
746+ }
747+}
772 748
749+func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
750+ d.notices.routingMu.Lock()
773 sess.mu.Lock()751 sess.mu.Lock()
774 current := sess.client == ac752 current := sess.client == ac
775 sess.mu.Unlock()753 sess.mu.Unlock()
776 if !current {754 if !current {
755+ d.notices.routingMu.Unlock()
777 return756 return
778 }757 }
779 if d.notices.beforeClientNoticeMutation != nil {758 if d.notices.beforeClientNoticeMutation != nil {
780 d.notices.beforeClientNoticeMutation()759 d.notices.beforeClientNoticeMutation()
781 }760 }
761+ repaint := d.mutateClientNotice(sess, ac, notice)
762+ d.notices.routingMu.Unlock()
763+ if repaint {
764+ d.repaintForNotice(ac)
765+ }
766+}
782 767
768+func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
783 switch notice.Action {769 switch notice.Action {
784 case ports.ClientNoticeClipboardFallback:770 case ports.ClientNoticeClipboardFallback:
785- d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")771+ return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
786 case ports.ClientNoticeClipboardTooLarge:772 case ports.ClientNoticeClipboardTooLarge:
787- d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")773+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
788 case ports.ClientNoticeLinkDegraded:774 case ports.ClientNoticeLinkDegraded:
789- d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")775+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
790 case ports.ClientNoticeLinkConnected:776 case ports.ClientNoticeLinkConnected:
791- d.dismissToast(ac, domain.NoticeConnection, sess.id)777+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
778+ default:
779+ return false
792 }780 }
793 }781 }
794 782
795-func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) {783+// recordClientNotice mutates notice history and the selected attachment while
784+// routingMu keeps replacement from changing that selection. Rendering is left
785+// to the caller after it releases routingMu.
786+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
796 n := d.notices.record(domain.Notification{787 n := d.notices.record(domain.Notification{
797 Code: code,788 Code: code,
798 Severity: sev,789 Severity: sev,
@@ -800,7 +791,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai
800 Time: d.clock.Now(),791 Time: d.clock.Now(),
801 SessionID: sess.id,792 SessionID: sess.id,
802 })793 })
803- d.showToast(ac, n)794+ return d.publishToast(ac, n)
804 }795 }
805 796
806 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The797 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
@@ -809,29 +800,6 @@ func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domai
809 return d.requestTransactionalResize(sess, ac, sz, true)800 return d.requestTransactionalResize(sess, ac, sz, true)
810 }801 }
811 802
812-// detachOnSendError drops a client whose transport failed, leaving the session
813-// registered and headless.
814-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
815- if failed != nil && !ac.currentTransportIs(failed) {
816- return
817- }
818- if d.detachIfCurrent(sess, ac) {
819- if rc := sess.renderCoordinator(); rc != nil {
820- rc.noteDetach(ac)
821- }
822- d.unregisterPreview(ac)
823- if d.parkAttachment(sess, ac) {
824- _ = ac.closeCapturedTransport(failed)
825- d.log.Warn("parked client after send error", "session", sess.name)
826- return
827- }
828- d.resetScreenDefaultColors(sess)
829- ac.clearPreviousSession()
830- _ = ac.closeCapturedTransport(failed)
831- d.log.Warn("detached client after send error", "session", sess.name)
832- }
833-}
834-
835 // killSession removes a session and tears down its resources. It is803 // killSession removes a session and tears down its resources. It is
836 // idempotent: only the caller that wins the registry delete acts. When the804 // idempotent: only the caller that wins the registry delete acts. When the
837 // registry empties it marks the daemon closing (atomically with the805 // registry empties it marks the daemon closing (atomically with the
A internal/usecase/daemon/client_frame_routing.go +203 -0
@@ -0,0 +1,203 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// runConnLoop is the per-connection input router: it pumps client messages
6+// until detach, EOF, or a transport error. Role is resolved after every Recv so
7+// a transport displaced while blocked in Recv immediately adopts its restricted
8+// snatched routing rather than executing one stale active frame.
9+func (d *Daemon) runConnLoop(ac *attachedClient) {
10+ tr := ac.transport()
11+ if tr == nil {
12+ return
13+ }
14+ for {
15+ if !ac.currentTransportIs(tr) {
16+ return
17+ }
18+ sess := ac.currentSession()
19+ if sess == nil {
20+ return
21+ }
22+ f, err := tr.Recv()
23+ if err != nil {
24+ token := sess.attachmentToken(ac, tr)
25+ if token.role == attachmentSnatched {
26+ d.parkOrDropSnatchedAttachment(token)
27+ } else {
28+ d.clientGone(sess, ac, tr, false)
29+ }
30+ return
31+ }
32+ if !ac.currentTransportIs(tr) {
33+ return
34+ }
35+ sess = ac.currentSession()
36+ if sess == nil {
37+ return
38+ }
39+ token := sess.attachmentToken(ac, tr)
40+ switch token.role {
41+ case attachmentActive:
42+ if d.handleActiveClientFrame(token, f) {
43+ return
44+ }
45+ case attachmentSnatched:
46+ if d.handleSnatchedClientFrame(token, f) {
47+ return
48+ }
49+ default:
50+ return
51+ }
52+ }
53+}
54+
55+// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
56+// it separate makes the restricted snatched protocol an explicit allow-list.
57+func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
58+ if !token.activeEffect() {
59+ return false
60+ }
61+ if d.afterActiveFrameDispatch != nil {
62+ d.afterActiveFrameDispatch(token)
63+ }
64+ ticket, admitted := token.ac.beginRoleEffect(token)
65+ if !admitted {
66+ return false
67+ }
68+ token.effect = ticket
69+ defer token.endRoleEffect()
70+ if d.afterRoleEffectAdmitted != nil {
71+ d.afterRoleEffectAdmitted(token)
72+ }
73+ switch f.Type {
74+ case ports.MsgInput:
75+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
76+ d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
77+ }
78+ case ports.MsgResize:
79+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
80+ d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
81+ }
82+ case ports.MsgTheme:
83+ if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
84+ d.applyThemeForRole(token, th)
85+ }
86+ case ports.MsgImagePush:
87+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
88+ d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
89+ }
90+ case ports.MsgClientNotice:
91+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
92+ d.handleClientNoticeForRole(token, notice)
93+ } else {
94+ d.log.Warn("malformed client notice", "err", derr)
95+ }
96+ case ports.MsgDetach:
97+ if token.activeEffect() {
98+ d.clientGoneForRole(token, true)
99+ return true
100+ }
101+ case ports.MsgAck:
102+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
103+ d.ackActiveOutput(token, ack.AckedStateNum)
104+ }
105+ case ports.MsgPing:
106+ if err := token.sendActiveControl(framePong()); err != nil {
107+ token.endRoleEffect()
108+ d.detachOnRoleSendError(token, token.transport.transport)
109+ return true
110+ }
111+ default:
112+ // Unknown/out-of-band client messages are ignored so a newer
113+ // client can add message types without breaking an older daemon.
114+ }
115+ return false
116+}
117+
118+func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
119+ ac := token.ac
120+ if token.effect == nil || token.effect.ended.Load() {
121+ return false
122+ }
123+ ac.sendMu.Lock()
124+ ac.output.ack(state)
125+ ac.sendMu.Unlock()
126+ if rc := token.sess.renderCoordinator(); rc != nil {
127+ rc.notifyAckForLease(token.lease)
128+ }
129+ return true
130+}
131+
132+// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
133+// quit actions are introduced separately; until then ordinary input is ignored.
134+func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
135+ if !token.current() {
136+ return false
137+ }
138+ ticket, admitted := token.ac.beginRoleEffect(token)
139+ if !admitted {
140+ return false
141+ }
142+ token.effect = ticket
143+ defer token.endRoleEffect()
144+ if d.afterRoleEffectAdmitted != nil {
145+ d.afterRoleEffectAdmitted(token)
146+ }
147+ switch f.Type {
148+ case ports.MsgInput:
149+ if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
150+ return true
151+ }
152+ case ports.MsgResize:
153+ if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
154+ ac := token.ac
155+ ac.sendMu.Lock()
156+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
157+ ac.sendMu.Unlock()
158+ return false
159+ }
160+ ac.size = resize.Size
161+ ac.sendMu.Unlock()
162+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
163+ token.endRoleEffect()
164+ d.parkOrDropSnatchedAttachment(token)
165+ return true
166+ }
167+ }
168+ case ports.MsgTheme:
169+ if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
170+ ac := token.ac
171+ ac.sendMu.Lock()
172+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
173+ ac.sendMu.Unlock()
174+ return false
175+ }
176+ clientTheme := themeFromMessage(theme)
177+ ac.setClientTheme(clientTheme)
178+ ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
179+ ac.sendMu.Unlock()
180+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
181+ token.endRoleEffect()
182+ d.parkOrDropSnatchedAttachment(token)
183+ return true
184+ }
185+ }
186+ case ports.MsgAck:
187+ if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
188+ token.ac.ackOutputState(ack.AckedStateNum)
189+ }
190+ case ports.MsgPing:
191+ if err := d.sendSnatchedControl(token, framePong()); err != nil {
192+ token.endRoleEffect()
193+ d.parkOrDropSnatchedAttachment(token)
194+ return true
195+ }
196+ case ports.MsgDetach:
197+ _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
198+ token.endRoleEffect()
199+ d.dropSnatchedAttachment(token)
200+ return true
201+ }
202+ return false
203+}
A internal/usecase/daemon/client_transport_errors.go +124 -0
@@ -0,0 +1,124 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// clientGone detaches ac if it is still the session's current client. The
6+// session remains registered and headless after the client is gone.
7+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ if failed != nil && !ac.currentTransportIs(failed) {
9+ return // stale connection loop; a newer transport owns this client
10+ }
11+ if !d.detachIfCurrent(sess, ac) {
12+ return // already displaced by a newer client; nothing to do
13+ }
14+ d.finishClientGone(sess, ac, failed, explicit)
15+}
16+
17+func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
18+ if token.effect == nil {
19+ return false
20+ }
21+ token.effect.bindActionEnd(d, "detach")
22+ token.effect.End()
23+ if !d.detachIfRoleCurrent(token) {
24+ return false
25+ }
26+ d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
27+ return true
28+}
29+
30+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
31+ if rc := sess.renderCoordinator(); rc != nil {
32+ rc.noteDetach(ac)
33+ }
34+ d.unregisterPreview(ac)
35+ sess.mu.Lock()
36+ ephemeral := sess.ephemeral
37+ sess.mu.Unlock()
38+ if !ephemeral {
39+ d.refreshSessionCwd(sess)
40+ }
41+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
42+ oldTr := failed
43+ if oldTr == nil {
44+ oldTr = ac.transport()
45+ }
46+ if !explicit && d.parkAttachment(sess, ac) {
47+ _ = ac.closeCapturedTransport(oldTr)
48+ d.log.Info("client parked", "session", sess.name)
49+ return
50+ }
51+
52+ d.resetScreenDefaultColors(sess)
53+ ac.clearPreviousSession()
54+ if explicit {
55+ // Synchronous so the ack is delivered before the transport closes
56+ // (the client is actively awaiting it), but deadline-bounded so a
57+ // wedged client cannot pin this conn handler and hang Serve's
58+ // connWg.Wait.
59+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
60+ }
61+ _ = ac.closeCapturedTransport(oldTr)
62+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
63+}
64+
65+// detachOnSendError drops a client whose transport failed, leaving the session
66+// registered and headless.
67+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
68+ if failed != nil && !ac.currentTransportIs(failed) {
69+ return
70+ }
71+ if d.detachIfCurrent(sess, ac) {
72+ d.finishSendErrorDetach(sess, ac, failed)
73+ }
74+}
75+
76+func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
77+ d.detachOnRoleSendErrorUntil(token, failed, nil)
78+}
79+
80+func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
81+ if failed != token.transport.transport {
82+ return
83+ }
84+ if d.detachIfRoleCurrentUntil(token, done) {
85+ d.finishSendErrorDetach(token.sess, token.ac, failed)
86+ }
87+}
88+
89+// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
90+// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
91+// the returned launch function must be invoked immediately after ticket End.
92+func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
93+ d.attachmentCleanupWg.Add(1)
94+ return func() {
95+ go func() {
96+ defer d.attachmentCleanupWg.Done()
97+ if d.afterRoleSendErrorCleanup != nil {
98+ defer d.afterRoleSendErrorCleanup()
99+ }
100+ if d.beforeRoleSendErrorCleanup != nil {
101+ d.beforeRoleSendErrorCleanup(token)
102+ }
103+ deadline := newRoleEffectDrainDeadline(d.clock)
104+ defer deadline.stop()
105+ d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
106+ }()
107+ }
108+}
109+
110+func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
111+ if rc := sess.renderCoordinator(); rc != nil {
112+ rc.noteDetach(ac)
113+ }
114+ d.unregisterPreview(ac)
115+ if d.parkAttachment(sess, ac) {
116+ _ = ac.closeCapturedTransport(failed)
117+ d.log.Warn("parked client after send error", "session", sess.name)
118+ return
119+ }
120+ d.resetScreenDefaultColors(sess)
121+ ac.clearPreviousSession()
122+ _ = ac.closeCapturedTransport(failed)
123+ d.log.Warn("detached client after send error", "session", sess.name)
124+}
M internal/usecase/daemon/clipboard.go +166 -17
@@ -4,6 +4,7 @@
4 package daemon4 package daemon
5 5
6 import (6 import (
7+ "errors"
7 "os"8 "os"
8 9
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
@@ -25,12 +26,27 @@ var (
25 clipPasteCloseMarker = []byte("\x1b[201~")26 clipPasteCloseMarker = []byte("\x1b[201~")
26 )27 )
27 28
28-// handleSequencedImagePush routes an ImagePush through the same input-sequence29+func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {
29-// surface as ordinary keystrokes. Echo prediction is still conservative; the30+ if !token.activeEffect() {
30-// sequence is carried so resume/dedup plumbing can order image pushes with31+ return
31-// surrounding input when that state reader grows beyond ordinary input.32+ }
32-func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {33+ d.handleImagePushForRole(token, ip)
33- d.handleImagePush(sess, ip)34+}
35+
36+func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) {
37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() {
38+ return
39+ }
40+ path, err := d.writeClipboardImageForRole(token, ip)
41+ if err != nil {
42+ d.log.Error("writing clipboard image failed", "err", err)
43+ return
44+ }
45+ if !token.activeEffect() {
46+ _ = os.Remove(path)
47+ return
48+ }
49+ d.injectClipboardPathForRole(token, path)
34 }50 }
35 51
36 // handleImagePush writes an ImagePush's bytes to a temp file and injects the52 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
@@ -58,6 +74,33 @@ func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
58 // writeClipboardImage writes ip's bytes to a new 0600 temp file and records74 // writeClipboardImage writes ip's bytes to a new 0600 temp file and records
59 // the path on sess for best-effort cleanup at session end (killSession).75 // the path on sess for best-effort cleanup at session end (killSession).
60 func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {76 func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
77+ path, err := d.createClipboardImage(ip)
78+ if err != nil {
79+ return "", err
80+ }
81+ sess.mu.Lock()
82+ sess.clipFiles = append(sess.clipFiles, path)
83+ sess.mu.Unlock()
84+ return path, nil
85+}
86+
87+func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) {
88+ path, err := d.createClipboardImage(ip)
89+ if err != nil {
90+ return "", err
91+ }
92+ token.sess.mu.Lock()
93+ if !token.activeEffectSessionLocked() {
94+ token.sess.mu.Unlock()
95+ _ = os.Remove(path)
96+ return "", errAttachmentTransition
97+ }
98+ token.sess.clipFiles = append(token.sess.clipFiles, path)
99+ token.sess.mu.Unlock()
100+ return path, nil
101+}
102+
103+func (d *Daemon) createClipboardImage(ip ports.ImagePush) (string, error) {
61 dir := d.tempDir104 dir := d.tempDir
62 if dir == "" {105 if dir == "" {
63 dir = os.TempDir()106 dir = os.TempDir()
@@ -76,9 +119,6 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
76 _ = os.Remove(path)119 _ = os.Remove(path)
77 return "", err120 return "", err
78 }121 }
79- sess.mu.Lock()
80- sess.clipFiles = append(sess.clipFiles, path)
81- sess.mu.Unlock()
82 return path, nil122 return path, nil
83 }123 }
84 124
@@ -86,6 +126,17 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
86 // the same PTY write path as ordinary input, wrapped in bracketed-paste126 // the same PTY write path as ordinary input, wrapped in bracketed-paste
87 // markers iff the pane's Screen reports bracketed-paste mode enabled.127 // markers iff the pane's Screen reports bracketed-paste mode enabled.
88 func (d *Daemon) injectClipboardPath(sess *session, path string) {128 func (d *Daemon) injectClipboardPath(sess *session, path string) {
129+ d.injectClipboardPathToTarget(sess, path, nil)
130+}
131+
132+func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {
133+ if !token.activeEffect() {
134+ return
135+ }
136+ d.injectClipboardPathToTarget(token.sess, path, &token)
137+}
138+
139+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {
89 tb := sess.activeTab()140 tb := sess.activeTab()
90 if tb == nil {141 if tb == nil {
91 return142 return
@@ -109,6 +160,9 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
109 wrapped = append(wrapped, clipPasteCloseMarker...)160 wrapped = append(wrapped, clipPasteCloseMarker...)
110 data = wrapped161 data = wrapped
111 }162 }
163+ if token != nil && !token.activeEffect() {
164+ return
165+ }
112 d.writeToPane(sess, p, data)166 d.writeToPane(sess, p, data)
113 }167 }
114 168
@@ -132,8 +186,9 @@ func clipboardExt(mime string) string {
132 }186 }
133 187
134 type clipboardForward struct {188 type clipboardForward struct {
135- ac *attachedClient189+ owner paneEffectLease
136- seq []byte190+ token attachmentRoleToken
191+ seq []byte
137 }192 }
138 193
139 // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set194 // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
@@ -145,11 +200,12 @@ type clipboardForward struct {
145 //200 //
146 // Requests are queued on the session and drained by one worker so clipboard201 // Requests are queued on the session and drained by one worker so clipboard
147 // writes keep arrival order without making the PTY reader wait on client I/O.202 // writes keep arrival order without making the PTY reader wait on client I/O.
148-func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {203+func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
149 seq := scopy.OSC52FromBase64(b64)204 seq := scopy.OSC52FromBase64(b64)
150- if seq == nil {205+ if seq == nil || !owner.Current() {
151 return206 return
152 }207 }
208+ sess := owner.owner.session
153 209
154 sess.mu.Lock()210 sess.mu.Lock()
155 if sess.ctx != nil {211 if sess.ctx != nil {
@@ -161,11 +217,31 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
161 }217 }
162 }218 }
163 ac := sess.client219 ac := sess.client
220+ sess.mu.Unlock()
164 if ac == nil {221 if ac == nil {
222+ return
223+ }
224+ transport := ac.transport()
225+ token := sess.attachmentToken(ac, transport)
226+ if !token.activeCurrent() {
227+ return
228+ }
229+
230+ sess.mu.Lock()
231+ if sess.ctx != nil {
232+ select {
233+ case <-sess.ctx.Done():
234+ sess.mu.Unlock()
235+ return
236+ default:
237+ }
238+ }
239+ if sess.client != ac || ac.roleGeneration.Load() != token.generation ||
240+ !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess {
165 sess.mu.Unlock()241 sess.mu.Unlock()
166 return242 return
167 }243 }
168- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})244+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq})
169 if !sess.clipboardWorkerRunning {245 if !sess.clipboardWorkerRunning {
170 sess.clipboardWorkerRunning = true246 sess.clipboardWorkerRunning = true
171 go d.clipboardWorker(sess)247 go d.clipboardWorker(sess)
@@ -173,15 +249,88 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
173 sess.mu.Unlock()249 sess.mu.Unlock()
174 }250 }
175 251
252+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) {
253+ token := item.token
254+ if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
255+ return token.transport.transport, errAttachmentTransition
256+ }
257+ expected := token.transport
258+ ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport)
259+ send := func() error {
260+ ac := token.ac
261+ ac.sendMu.Lock()
262+ defer ac.sendMu.Unlock()
263+ if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
264+ return errAttachmentTransition
265+ }
266+ if !beginClipboardOwnerSend(item.owner, ticket, expected) {
267+ return errAttachmentTransition
268+ }
269+ frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())
270+ var err error
271+ if owned {
272+ err = ownedTransport.SendSynchronous(frame)
273+ } else {
274+ err = expected.transport.Send(frame)
275+ }
276+ if err != nil {
277+ ticket.reportTransportFailure(expected)
278+ }
279+ ticket.endTransportSend()
280+ return err
281+ }
282+ if owned {
283+ return expected.transport, send()
284+ }
285+ return d.boundedSendWith(expected.transport, send)
286+}
287+
288+// beginClipboardOwnerSend validates the source pane generation after sendMu
289+// admission and marks the transport interval while ownership publication is
290+// excluded. Publication therefore orders either before this check (the send is
291+// dropped) or after transport admission (the send belongs to the old owner).
292+func beginClipboardOwnerSend(lease paneEffectLease, ticket *roleEffectTicket, expected transportSnapshot) bool {
293+ if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
294+ return false
295+ }
296+ owner := lease.owner
297+ if owner.floatingSlotGeneration != 0 {
298+ owner.tab.mu.Lock()
299+ defer owner.tab.mu.Unlock()
300+ }
301+ lease.pane.mu.Lock()
302+ defer lease.pane.mu.Unlock()
303+ if lease.pane.owner.Load() != owner {
304+ return false
305+ }
306+ if owner.floatingSlotGeneration != 0 &&
307+ (owner.tab.floating.pane != lease.pane || owner.tab.floating.generation != owner.floatingSlotGeneration ||
308+ (owner.tab.floating.state != floatingHidden && owner.tab.floating.state != floatingVisible)) {
309+ return false
310+ }
311+ return ticket.beginTransportSend(expected)
312+}
313+
176 func (d *Daemon) clipboardWorker(sess *session) {314 func (d *Daemon) clipboardWorker(sess *session) {
177 for {315 for {
178 item, ok := nextClipboardForward(sess)316 item, ok := nextClipboardForward(sess)
179 if !ok {317 if !ok {
180 return318 return
181 }319 }
182- failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)320+ if !item.owner.Current() {
183- if err != nil {321+ continue
184- d.detachOnSendError(sess, item.ac, failed)322+ }
323+ ticket, admitted := item.token.ac.beginRoleEffect(item.token)
324+ if !admitted {
325+ continue
326+ }
327+ failed, err := d.boundedSendClipboardForward(item, ticket)
328+ if errors.Is(err, errSendTimedOut) {
329+ _ = item.token.ac.closeCapturedTransport(failed)
330+ }
331+ ticket.End()
332+ if err != nil && item.owner.Current() {
333+ d.detachOnRoleSendError(item.token, failed)
185 }334 }
186 }335 }
187 }336 }
M internal/usecase/daemon/clipboard_test.go +220 -13
@@ -3,10 +3,12 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6+ "errors"
6 "io"7 "io"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "strings"10 "strings"
11+ "sync"
10 "testing"12 "testing"
11 "time"13 "time"
12 14
@@ -200,6 +202,25 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
200 return p202 return p
201 }203 }
202 204
205+func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206+ rc := d.attachCoordinator(sess, nil, ac, true)
207+ token := sess.attachmentToken(ac, tr)
208+ token.lease = rc.attachmentLease(ac)
209+ ac.publishRoleCapability(token)
210+}
211+
212+func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
213+ t.Helper()
214+ tb := sess.activeTab()
215+ tb.mu.Lock()
216+ p := tb.terminalTargetLocked()
217+ tb.mu.Unlock()
218+ if p == nil {
219+ t.Fatal("clipboard owner has no terminal target")
220+ }
221+ return p.effectLease()
222+}
223+
203 func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {224 func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
204 d := newTestDaemon(t, nil, stubClock{})225 d := newTestDaemon(t, nil, stubClock{})
205 p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))226 p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
@@ -211,16 +232,25 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
211 ac := &attachedClient{tr: tr, output: newOutputStateStream()}232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
212 ac.initOverlays()233 ac.initOverlays()
213 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}234 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
235+ publishTiledPaneOwners(sess, win)
214 ac.setSession(sess)236 ac.setSession(sess)
215 d.sessions[sess.id] = sess237 d.sessions[sess.id] = sess
238+ publishActiveClipboardCapability(d, sess, ac, tr)
216 239
217 d.sessWg.Add(1)240 d.sessWg.Add(1)
218 d.ptyReader(sess, win, win.focusedPane())241 d.ptyReader(sess, win, win.focusedPane())
219 242
220- f := awaitFrame(t, sends, ports.MsgOutput)243+ var clipboardOutput string
221- out, err := ports.UnmarshalOutput(f.Payload)244+ for range 3 {
222- require.NoError(t, err)245+ f := awaitFrame(t, sends, ports.MsgOutput)
223- require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")246+ out, err := ports.UnmarshalOutput(f.Payload)
247+ require.NoError(t, err)
248+ clipboardOutput = string(out.Data)
249+ if strings.Contains(clipboardOutput, "\x1b]52;c;aGVsbG8=\x07") {
250+ break
251+ }
252+ }
253+ require.Contains(t, clipboardOutput, "\x1b]52;c;aGVsbG8=\x07")
224 }254 }
225 255
226 func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {256 func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
@@ -236,6 +266,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
236 ac := &attachedClient{tr: tr, output: newOutputStateStream()}266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
237 ac.initOverlays()267 ac.initOverlays()
238 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}268 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
269+ publishTiledPaneOwners(sess, win)
239 ac.setSession(sess)270 ac.setSession(sess)
240 d.sessions[sess.id] = sess271 d.sessions[sess.id] = sess
241 272
@@ -260,6 +291,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
260 ac := &attachedClient{tr: tr, output: newOutputStateStream()}291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
261 ac.initOverlays()292 ac.initOverlays()
262 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}293 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
294+ publishTiledPaneOwners(sess, win)
263 ac.setSession(sess)295 ac.setSession(sess)
264 d.sessions[sess.id] = sess296 d.sessions[sess.id] = sess
265 297
@@ -281,6 +313,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
281 defer cancel()313 defer cancel()
282 win := newTestTabWithContext(p, sctx, cancel)314 win := newTestTabWithContext(p, sctx, cancel)
283 sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}315 sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
316+ publishTiledPaneOwners(sess, win)
284 d.sessions[sess.id] = sess317 d.sessions[sess.id] = sess
285 318
286 d.sessWg.Add(1)319 d.sessWg.Add(1)
@@ -289,22 +322,196 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
289 })322 })
290 }323 }
291 324
325+type staleClipboardErrorTransport struct {
326+ mu sync.Mutex
327+ sends int
328+}
329+
330+func (t *staleClipboardErrorTransport) Send(ports.Frame) error {
331+ t.mu.Lock()
332+ t.sends++
333+ t.mu.Unlock()
334+ return errors.New("stale clipboard send")
335+}
336+
337+func (*staleClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
338+func (*staleClipboardErrorTransport) Close() error { return nil }
339+
340+func (t *staleClipboardErrorTransport) sendCount() int {
341+ t.mu.Lock()
342+ defer t.mu.Unlock()
343+ return t.sends
344+}
345+
346+func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
347+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
348+ oldTransport := &staleClipboardErrorTransport{}
349+ ac.replaceTransport(oldTransport)
350+ publishActiveClipboardCapability(d, sess, ac, oldTransport)
351+
352+ sess.mu.Lock()
353+ sess.clipboardWorkerRunning = true
354+ sess.mu.Unlock()
355+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
356+
357+ sourceTab := sess.activeTab()
358+ p := sourceTab.focusedPane()
359+ destination := &session{id: "destination", name: "destination"}
360+ publishPaneOwner(p, destination, &tab{}, 0)
361+ publishPaneOwner(p, sess, sourceTab, 0)
362+
363+ d.clipboardWorker(sess)
364+
365+ require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")
366+ require.Same(t, ac, sess.client, "stale clipboard send handling detached the former owner's client")
367+}
368+
369+type movingClipboardErrorTransport struct {
370+ started chan struct{}
371+ release chan struct{}
372+ closed chan struct{}
373+ startedOnce sync.Once
374+ closeOnce sync.Once
375+}
376+
377+func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
378+ return &movingClipboardErrorTransport{
379+ started: make(chan struct{}),
380+ release: make(chan struct{}),
381+ closed: make(chan struct{}),
382+ }
383+}
384+
385+func (t *movingClipboardErrorTransport) Send(ports.Frame) error {
386+ t.startedOnce.Do(func() { close(t.started) })
387+ <-t.release
388+ return errors.New("clipboard send failed after pane move")
389+}
390+
391+func (*movingClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
392+
393+func (t *movingClipboardErrorTransport) Close() error {
394+ t.closeOnce.Do(func() { close(t.closed) })
395+ return nil
396+}
397+
398+func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing.T) {
399+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
400+ oldTransport := &staleClipboardErrorTransport{}
401+ ac.replaceTransport(oldTransport)
402+ publishActiveClipboardCapability(d, sess, ac, oldTransport)
403+
404+ sess.mu.Lock()
405+ sess.clipboardWorkerRunning = true
406+ sess.mu.Unlock()
407+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
408+
409+ ac.sendMu.Lock()
410+ sendMuLocked := true
411+ defer func() {
412+ if sendMuLocked {
413+ ac.sendMu.Unlock()
414+ }
415+ }()
416+ workerDone := make(chan struct{})
417+ go func() {
418+ d.clipboardWorker(sess)
419+ close(workerDone)
420+ }()
421+ require.Eventually(t, func() bool {
422+ return ac.roleEffects.inFlightCount() == 1
423+ }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424+
425+ sourceTab := sess.activeTab()
426+ p := sourceTab.focusedPane()
427+ destination := &session{id: "destination", name: "destination"}
428+ publishPaneOwner(p, destination, &tab{}, 0)
429+ publishPaneOwner(p, sess, sourceTab, 0)
430+ ac.sendMu.Unlock()
431+ sendMuLocked = false
432+ awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
433+
434+ require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")
435+ require.Same(t, ac, sess.client)
436+}
437+
438+func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
439+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
440+ oldTransport := newMovingClipboardErrorTransport()
441+ ac.replaceTransport(oldTransport)
442+ publishActiveClipboardCapability(d, sess, ac, oldTransport)
443+
444+ sess.mu.Lock()
445+ sess.clipboardWorkerRunning = true
446+ sess.mu.Unlock()
447+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
448+
449+ workerDone := make(chan struct{})
450+ go func() {
451+ d.clipboardWorker(sess)
452+ close(workerDone)
453+ }()
454+ awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
455+
456+ sourceTab := sess.activeTab()
457+ p := sourceTab.focusedPane()
458+ destination := &session{id: "destination", name: "destination"}
459+ publishPaneOwner(p, destination, &tab{}, 0)
460+ publishPaneOwner(p, sess, sourceTab, 0)
461+ close(oldTransport.release)
462+ awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
463+
464+ require.Same(t, ac, sess.client, "a send error from the pane's retired owner detached its client")
465+ select {
466+ case <-oldTransport.closed:
467+ t.Fatal("a send error from the pane's retired owner closed its client transport")
468+ default:
469+ }
470+}
471+
472+func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
473+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
474+ oldTransport := &staleClipboardErrorTransport{}
475+ old.replaceTransport(oldTransport)
476+ rc := d.attachCoordinator(sess, nil, old, true)
477+ token := sess.attachmentToken(old, oldTransport)
478+ token.lease = rc.attachmentLease(old)
479+ old.publishRoleCapability(token)
480+
481+ sess.mu.Lock()
482+ sess.clipboardWorkerRunning = true
483+ sess.mu.Unlock()
484+ d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
485+
486+ newTransport := &closeTrackingTransport{}
487+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
488+ next.initOverlays()
489+ _, err := d.transitionAttachment(attachmentTransitionRequest{
490+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
491+ expectedTransport: next.transportSnapshot(), ready: true,
492+ })
493+ require.NoError(t, err)
494+
495+ d.clipboardWorker(sess)
496+ require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport")
497+ require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement")
498+ require.Same(t, next, sess.client, "stale clipboard send handling detached the current client")
499+ require.False(t, newTransport.Closed())
500+}
501+
292 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {502 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
293- d := newTestDaemon(t, nil, stubClock{})503+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
294 tr := newBlockingClipboardTransport()504 tr := newBlockingClipboardTransport()
295- ac := &attachedClient{tr: tr, output: newOutputStateStream()}505+ ac.replaceTransport(tr)
296- ac.initOverlays()506+ publishActiveClipboardCapability(d, sess, ac, tr)
297- sctx, cancel := context.WithCancel(context.Background())507+ owner := clipboardOwnerLease(t, sess)
298- defer cancel()
299- sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
300- ac.setSession(sess)
301 508
302 first := base64.StdEncoding.EncodeToString([]byte("first"))509 first := base64.StdEncoding.EncodeToString([]byte("first"))
303 second := base64.StdEncoding.EncodeToString([]byte("second"))510 second := base64.StdEncoding.EncodeToString([]byte("second"))
304- d.forwardClipboardAsync(sess, first)511+ d.forwardClipboardAsync(owner, first)
305 require.Equal(t, "first", <-tr.started)512 require.Equal(t, "first", <-tr.started)
306 513
307- d.forwardClipboardAsync(sess, second)514+ d.forwardClipboardAsync(owner, second)
308 select {515 select {
309 case got := <-tr.started:516 case got := <-tr.started:
310 t.Fatalf("second clipboard send started before first completed: %q", got)517 t.Fatalf("second clipboard send started before first completed: %q", got)
M internal/usecase/daemon/control.go +72 -7
@@ -52,6 +52,19 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
52 if sess == nil {52 if sess == nil {
53 return commandFailure(code, text)53 return commandFailure(code, text)
54 }54 }
55+ if cmd.Scope == command.CommandScopeCrossSession {
56+ // Cross-session moves intentionally skip sess.dispatchMu: movePane and
57+ // moveTab acquire both session dispatch locks through lockMoveDispatch in
58+ // global order. Pre-locking the source here could recreate the
59+ // opposite-direction deadlock covered by
60+ // TestHandleCommandOppositeMoveCommandsDoNotDeadlock.
61+ tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
62+ if code != 0 {
63+ return commandFailure(code, text)
64+ }
65+ return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
66+ }
67+
55 sess.dispatchMu.Lock()68 sess.dispatchMu.Lock()
56 defer sess.dispatchMu.Unlock()69 defer sess.dispatchMu.Unlock()
57 70
@@ -72,6 +85,8 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
72 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)85 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
73 case errors.Is(err, errSessionNameInUse):86 case errors.Is(err, errSessionNameInUse):
74 return commandFailure(ports.ErrNameTaken, err.Error())87 return commandFailure(ports.ErrNameTaken, err.Error())
88+ case isMoveCommandError(err):
89+ return moveCommandFailure(err)
75 case errors.Is(err, layout.ErrNotInSplit):90 case errors.Is(err, layout.ErrNotInSplit):
76 return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")91 return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")
77 case errors.Is(err, layout.ErrTooSmall):92 case errors.Is(err, layout.ErrTooSmall):
@@ -86,8 +101,8 @@ func commandFailure(code uint16, text string) ports.CommandResult {
86 }101 }
87 102
88 // resolveTargetSession applies explicit-name, stable-ID, then unique-session103 // resolveTargetSession applies explicit-name, stable-ID, then unique-session
89-// resolution. A name paired with IDs is advisory only when no live session has104+// resolution. A name paired with complete stable IDs is always advisory because
90-// that name (the VEV value can survive a rename).105+// a process keeps its original VEV session component after relocation.
91 func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {106 func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
92 d.mu.Lock()107 d.mu.Lock()
93 defer d.mu.Unlock()108 defer d.mu.Unlock()
@@ -106,13 +121,18 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
106 if request.TargetTab == "" || request.TargetPane == "" {121 if request.TargetTab == "" || request.TargetPane == "" {
107 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"122 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
108 }123 }
124+ var match *session
109 for _, sess := range d.sessions {125 for _, sess := range d.sessions {
110- if sess.containsStableIDs(request.TargetTab, request.TargetPane) {126+ if !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
111- if named != nil && named != sess {127+ continue
112- return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session"128+ }
113- }129+ if match != nil {
114- return sess, 0, ""130+ return nil, ports.ErrAmbiguousTarget, "target tab and pane IDs are ambiguous"
115 }131 }
132+ match = sess
133+ }
134+ if match != nil {
135+ return match, 0, ""
116 }136 }
117 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"137 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
118 }138 }
@@ -402,6 +422,51 @@ func (e controlExec) CloseTab() error {
402 func (e controlExec) ClosePane() error {422 func (e controlExec) ClosePane() error {
403 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})423 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
404 }424 }
425+
426+func (e controlExec) MovePane(destinationSession, destinationTabID string) error {
427+ destination := e.d.liveSessionByName(destinationSession)
428+ if destination == nil {
429+ return errMoveStaleTarget
430+ }
431+ if e.sess == nil || e.tab == nil || e.target.pane == nil {
432+ return errMovePaneInvalid
433+ }
434+ return e.d.movePane(movePaneRequest{
435+ Source: sessionMoveLocator(e.sess),
436+ SourceTabID: domain.TabStableID(e.tab.stableID),
437+ SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
438+ Destination: sessionMoveLocator(destination),
439+ DestinationTabID: domain.TabStableID(destinationTabID),
440+ })
441+}
442+
443+func (e controlExec) MoveTab(destinationSession string) error {
444+ destination := e.d.liveSessionByName(destinationSession)
445+ if destination == nil {
446+ return errMoveStaleTarget
447+ }
448+ if e.sess == nil || e.tab == nil {
449+ return errMovePaneInvalid
450+ }
451+ return e.d.moveTab(moveTabRequest{
452+ Source: sessionMoveLocator(e.sess),
453+ SourceTabID: domain.TabStableID(e.tab.stableID),
454+ Destination: sessionMoveLocator(destination),
455+ })
456+}
457+
458+func (d *Daemon) liveSessionByName(name string) *session {
459+ d.mu.Lock()
460+ defer d.mu.Unlock()
461+ return d.findByNameLocked(name)
462+}
463+
464+func sessionMoveLocator(sess *session) moveSessionLocator {
465+ sess.mu.Lock()
466+ name := sess.name
467+ sess.mu.Unlock()
468+ return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: name}
469+}
405 func (e controlExec) split(direction layout.Direction) error {470 func (e controlExec) split(direction layout.Direction) error {
406 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})471 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
407 }472 }
M internal/usecase/daemon/control_test.go +373 -4
@@ -169,10 +169,10 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
169 {name: "missing stable IDs", arrange: func(d *Daemon) {169 {name: "missing stable IDs", arrange: func(d *Daemon) {
170 addControlSession(d, "work", "t_work", "p_work")170 addControlSession(d, "work", "t_work", "p_work")
171 }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},171 }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
172- {name: "cross-session IDs rejected", arrange: func(d *Daemon) {172+ {name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) {
173- addControlSession(d, "one", "t_one", "p_one")173+ addControlSession(d, "one", "t_shared", "p_shared")
174- addControlSession(d, "two", "t_two", "p_two")174+ addControlSession(d, "two", "t_shared", "p_shared")
175- }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget},175+ }, request: ports.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: ports.ErrAmbiguousTarget},
176 {name: "self requires both IDs", arrange: func(d *Daemon) {176 {name: "self requires both IDs", arrange: func(d *Daemon) {
177 addControlSession(d, "work", "t_work", "p_work")177 addControlSession(d, "work", "t_work", "p_work")
178 }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget},178 }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget},
@@ -707,6 +707,375 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
707 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")707 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
708 }708 }
709 709
710+func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
711+ d := newTestDaemon(t, nil, stubClock{})
712+ source := addControlSession(d, "work", "t_active", "p_active")
713+ inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
714+ inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
715+ publishTiledPaneOwners(source, inactive)
716+ source.mu.Lock()
717+ source.tabs = append(source.tabs, inactive)
718+ source.active = 0
719+ source.mu.Unlock()
720+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
721+
722+ result := sendCommand(t, d, ports.CommandRequest{
723+ Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
724+ })
725+ require.True(t, result.OK, result.Text)
726+
727+ source.mu.Lock()
728+ require.Len(t, source.tabs, 1)
729+ require.Equal(t, "t_inactive", source.tabs[0].stableID)
730+ source.mu.Unlock()
731+ destination.mu.Lock()
732+ destTab := destination.tabs[0]
733+ destination.mu.Unlock()
734+ destTab.mu.Lock()
735+ var moved *pane
736+ for _, candidate := range destTab.panes {
737+ if candidate.stableID == "p_active" {
738+ moved = candidate
739+ }
740+ }
741+ require.NotNil(t, moved)
742+ destTab.mu.Unlock()
743+}
744+
745+func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
746+ d := newTestDaemon(t, nil, stubClock{})
747+ source := addControlSession(d, "work", "t_active", "p_active")
748+ inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
749+ inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
750+ publishTiledPaneOwners(source, inactive)
751+ source.mu.Lock()
752+ source.tabs = append(source.tabs, inactive)
753+ source.active = 0
754+ source.mu.Unlock()
755+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
756+
757+ result := sendCommand(t, d, ports.CommandRequest{
758+ Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
759+ TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
760+ })
761+ require.True(t, result.OK, result.Text)
762+
763+ active := source.tabs[0]
764+ active.mu.Lock()
765+ require.Len(t, active.panes, 1)
766+ require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
767+ active.mu.Unlock()
768+ destination.mu.Lock()
769+ destTab := destination.tabs[0]
770+ destination.mu.Unlock()
771+ destTab.mu.Lock()
772+ var moved *pane
773+ for _, candidate := range destTab.panes {
774+ if candidate.stableID == "p_inactive" {
775+ moved = candidate
776+ }
777+ }
778+ require.NotNil(t, moved)
779+ destTab.mu.Unlock()
780+}
781+
782+func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
783+ d := newTestDaemon(t, nil, stubClock{})
784+ source := addControlSession(d, "work", "t_first", "p_first")
785+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
786+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
787+ publishTiledPaneOwners(source, second)
788+ source.mu.Lock()
789+ source.tabs = append(source.tabs, second)
790+ source.active = 1
791+ source.mu.Unlock()
792+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
793+
794+ result := sendCommand(t, d, ports.CommandRequest{
795+ Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work",
796+ })
797+ require.True(t, result.OK, result.Text)
798+
799+ source.mu.Lock()
800+ require.Len(t, source.tabs, 1)
801+ require.Equal(t, "t_first", source.tabs[0].stableID)
802+ source.mu.Unlock()
803+ destination.mu.Lock()
804+ require.Len(t, destination.tabs, 2)
805+ require.Equal(t, "t_second", destination.tabs[1].stableID)
806+ destination.mu.Unlock()
807+}
808+
809+func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
810+ d := newTestDaemon(t, nil, stubClock{})
811+ source := addControlSession(d, "work", "t_first", "p_first")
812+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
813+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
814+ publishTiledPaneOwners(source, second)
815+ source.mu.Lock()
816+ source.tabs = append(source.tabs, second)
817+ source.active = 0
818+ source.mu.Unlock()
819+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
820+
821+ result := sendCommand(t, d, ports.CommandRequest{
822+ Slug: "move-tab", Args: []string{"dest"}, Self: true,
823+ TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
824+ })
825+ require.True(t, result.OK, result.Text)
826+
827+ source.mu.Lock()
828+ require.Len(t, source.tabs, 1)
829+ require.Equal(t, "t_first", source.tabs[0].stableID)
830+ source.mu.Unlock()
831+ destination.mu.Lock()
832+ require.Len(t, destination.tabs, 2)
833+ require.Equal(t, "t_second", destination.tabs[1].stableID)
834+ destination.mu.Unlock()
835+}
836+
837+func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
838+ tests := []struct {
839+ name string
840+ arrange func(*Daemon) ports.CommandRequest
841+ code uint16
842+ text string
843+ }{
844+ {
845+ name: "move-pane missing destination session",
846+ arrange: func(d *Daemon) ports.CommandRequest {
847+ addControlSession(d, "work", "t_work", "p_work")
848+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
849+ },
850+ code: ports.ErrNoSuchTarget,
851+ text: "move request is invalid",
852+ },
853+ {
854+ name: "move-pane unknown destination session",
855+ arrange: func(d *Daemon) ports.CommandRequest {
856+ addControlSession(d, "work", "t_work", "p_work")
857+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
858+ },
859+ code: ports.ErrNoSuchTarget,
860+ text: "move target is no longer available",
861+ },
862+ {
863+ name: "move-pane destination tab not in session",
864+ arrange: func(d *Daemon) ports.CommandRequest {
865+ addControlSession(d, "work", "t_work", "p_work")
866+ addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
867+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
868+ },
869+ code: ports.ErrNoSuchTarget,
870+ text: "move target is no longer available",
871+ },
872+ {
873+ name: "move-pane same source tab",
874+ arrange: func(d *Daemon) ports.CommandRequest {
875+ addControlSession(d, "work", "t_work", "p_work")
876+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
877+ },
878+ code: ports.ErrNoSuchTarget,
879+ text: "move request is invalid",
880+ },
881+ {
882+ name: "move-tab same session",
883+ arrange: func(d *Daemon) ports.CommandRequest {
884+ addControlSession(d, "work", "t_work", "p_work")
885+ return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
886+ },
887+ code: ports.ErrNoSuchTarget,
888+ text: "move request is invalid",
889+ },
890+ {
891+ name: "move-tab stopping destination",
892+ arrange: func(d *Daemon) ports.CommandRequest {
893+ addControlSession(d, "work", "t_work", "p_work")
894+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
895+ destination.teardownMu.Lock()
896+ destination.teardownActive = true
897+ destination.teardownMu.Unlock()
898+ return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
899+ },
900+ code: ports.ErrNoSuchTarget,
901+ text: "move target is no longer available",
902+ },
903+ {
904+ name: "move-pane too few args",
905+ arrange: func(d *Daemon) ports.CommandRequest {
906+ addControlSession(d, "work", "t_work", "p_work")
907+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
908+ },
909+ code: ports.ErrInvalidCommandArgs,
910+ },
911+ {
912+ name: "move-pane too many args",
913+ arrange: func(d *Daemon) ports.CommandRequest {
914+ addControlSession(d, "work", "t_work", "p_work")
915+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
916+ },
917+ code: ports.ErrInvalidCommandArgs,
918+ },
919+ {
920+ name: "move-tab missing destination",
921+ arrange: func(d *Daemon) ports.CommandRequest {
922+ addControlSession(d, "work", "t_work", "p_work")
923+ return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"}
924+ },
925+ code: ports.ErrInvalidCommandArgs,
926+ },
927+ {
928+ name: "explicit session name without IDs remains authoritative",
929+ arrange: func(d *Daemon) ports.CommandRequest {
930+ addControlSession(d, "renamed", "t_work", "p_work")
931+ addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
932+ return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
933+ },
934+ code: ports.ErrNoSuchTarget,
935+ text: "no such session: old-name",
936+ },
937+ }
938+
939+ for _, tt := range tests {
940+ t.Run(tt.name, func(t *testing.T) {
941+ d := newTestDaemon(t, nil, stubClock{})
942+ request := tt.arrange(d)
943+ result := sendCommand(t, d, request)
944+ require.False(t, result.OK)
945+ require.Equal(t, tt.code, result.Code)
946+ if tt.text != "" {
947+ require.Equal(t, tt.text, result.Text)
948+ }
949+ })
950+ }
951+}
952+
953+func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
954+ d := newTestDaemon(t, nil, stubClock{})
955+ advisory := addControlSession(d, "old-name", "t_other", "p_other")
956+ source := addControlSession(d, "renamed", "t_work", "p_work")
957+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
958+
959+ result := sendCommand(t, d, ports.CommandRequest{
960+ Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
961+ TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
962+ })
963+ require.True(t, result.OK, result.Text)
964+
965+ source.mu.Lock()
966+ require.Len(t, source.tabs, 0)
967+ source.mu.Unlock()
968+ advisory.mu.Lock()
969+ require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
970+ advisory.mu.Unlock()
971+ destination.mu.Lock()
972+ destTab := destination.tabs[0]
973+ destination.mu.Unlock()
974+ destTab.mu.Lock()
975+ require.Len(t, destTab.panes, 2)
976+ destTab.mu.Unlock()
977+}
978+
979+func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
980+ d := newTestDaemon(t, nil, stubClock{})
981+ source := addControlSession(d, "renamed", "t_active", "p_active")
982+ inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
983+ inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
984+ publishTiledPaneOwners(source, inactive)
985+ source.mu.Lock()
986+ source.tabs = append(source.tabs, inactive)
987+ source.active = 1
988+ source.mu.Unlock()
989+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
990+
991+ result := sendCommand(t, d, ports.CommandRequest{
992+ Slug: "move-pane", Args: []string{"dest", "t_dest"},
993+ TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
994+ })
995+ require.True(t, result.OK, result.Text)
996+
997+ source.mu.Lock()
998+ require.Len(t, source.tabs, 1)
999+ require.Equal(t, "t_active", source.tabs[0].stableID)
1000+ source.mu.Unlock()
1001+ destination.mu.Lock()
1002+ destTab := destination.tabs[0]
1003+ destination.mu.Unlock()
1004+ destTab.mu.Lock()
1005+ var moved *pane
1006+ for _, candidate := range destTab.panes {
1007+ if candidate.stableID == "p_inactive" {
1008+ moved = candidate
1009+ }
1010+ }
1011+ require.NotNil(t, moved)
1012+ destTab.mu.Unlock()
1013+}
1014+
1015+func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1016+ d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1017+ left.mu.Lock()
1018+ left.name = "left"
1019+ left.tabs[0].stableID = "left-moved"
1020+ left.active = 0
1021+ left.mu.Unlock()
1022+
1023+ right := addMoveTabTestSession(d, "right", "right-stays")
1024+ rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1025+ rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1026+ publishTiledPaneOwners(right, rightMoved)
1027+ right.mu.Lock()
1028+ right.tabs = append(right.tabs, rightMoved)
1029+ right.active = 1
1030+ right.mu.Unlock()
1031+
1032+ leftFrame := commandFrame(t, ports.CommandRequest{
1033+ Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1034+ })
1035+ rightFrame := commandFrame(t, ports.CommandRequest{
1036+ Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1037+ })
1038+ leftTransport, leftSends, _ := newConn(t, leftFrame)
1039+ rightTransport, rightSends, _ := newConn(t, rightFrame)
1040+ start := make(chan struct{})
1041+ done := make(chan struct{}, 2)
1042+ go func() {
1043+ <-start
1044+ d.handleCommand(leftTransport, leftFrame)
1045+ done <- struct{}{}
1046+ }()
1047+ go func() {
1048+ <-start
1049+ d.handleCommand(rightTransport, rightFrame)
1050+ done <- struct{}{}
1051+ }()
1052+ close(start)
1053+ for _, sends := range []chan ports.Frame{leftSends, rightSends} {
1054+ select {
1055+ case <-done:
1056+ result := awaitCommandResult(t, sends)
1057+ require.True(t, result.OK, result.Text)
1058+ case <-time.After(5 * time.Second):
1059+ t.Fatal("opposite-direction move commands deadlocked")
1060+ }
1061+ }
1062+}
1063+
1064+func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1065+ ctx, cancel := context.WithCancel(d.serveCtx)
1066+ tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1067+ tb.ctx, tb.cancel = context.WithCancel(ctx)
1068+ sess := &session{
1069+ id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1070+ ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0,
1071+ }
1072+ publishTiledPaneOwners(sess, tb)
1073+ d.mu.Lock()
1074+ d.sessions[sess.id] = sess
1075+ d.mu.Unlock()
1076+ return sess
1077+}
1078+
710 func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {1079 func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
711 t.Helper()1080 t.Helper()
712 if request.Version == 0 {1081 if request.Version == 0 {
M internal/usecase/daemon/copymode_lifecycle_test.go +33 -0
@@ -55,6 +55,39 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
55 ac.overlays.copyMu.Unlock()55 ac.overlays.copyMu.Unlock()
56 }56 }
57 57
58+func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
59+ for _, tt := range []struct {
60+ name string
61+ leaseBacked bool
62+ }{
63+ {name: "direct", leaseBacked: false},
64+ {name: "lease-backed", leaseBacked: true},
65+ } {
66+ t.Run(tt.name, func(t *testing.T) {
67+ d, sess, ac, _, releases := newManualTabSession(t, 2)
68+ defer releaseAll(releases)
69+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})
70+ rc.attach(ac)
71+ sess.installRenderCoordinator(rc)
72+ defer func() { rc.beginSessionTeardown().finish() }()
73+ path := make(chan bool, 1)
74+ rc.opts.onActivateTabAfterResize = func(leaseBacked bool) { path <- leaseBacked }
75+ var lease *attachmentLease
76+ if tt.leaseBacked {
77+ lease = rc.attachmentLease(ac)
78+ require.NotNil(t, lease)
79+ }
80+
81+ d.enterCopyMode(sess, ac)
82+ require.True(t, ac.overlays.copyActive())
83+ d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)
84+
85+ require.False(t, ac.overlays.copyActive())
86+ require.Equal(t, tt.leaseBacked, <-path)
87+ })
88+ }
89+}
90+
58 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {91 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
59 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})92 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
60 tb := sess.activeTab()93 tb := sess.activeTab()
M internal/usecase/daemon/copymode_test.go +2 -1
@@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
845 awaitFrame(t, sends, ports.MsgOutput)845 awaitFrame(t, sends, ports.MsgOutput)
846 tb.mu.Lock()846 tb.mu.Lock()
847 generation := tb.floating.generation847 generation := tb.floating.generation
848+ publishPaneOwner(fp, sess, tb, generation)
848 tb.mu.Unlock()849 tb.mu.Unlock()
849 850
850- d.reapFloating(sess, tb, fp, generation)851+ d.reapInstalledFloating(fp)
851 852
852 ac.overlays.copyMu.Lock()853 ac.overlays.copyMu.Lock()
853 require.Nil(t, ac.overlays.copyMode)854 require.Nil(t, ac.overlays.copyMode)
M internal/usecase/daemon/daemon.go +221 -58
@@ -85,6 +85,19 @@ type Daemon struct {
85 // under the same mutex — so a Hello racing shutdown can never insert a new85 // under the same mutex — so a Hello racing shutdown can never insert a new
86 // session that nobody would tear down.86 // session that nobody would tear down.
87 closing bool87 closing bool
88+
89+ // moveLifecycleMu is the daemon-level admission gate for transferable
90+ // ownership changes. It is acquired before any session teardownMu, and is
91+ // never held while a move, teardown, or external operation runs.
92+ moveLifecycleMu sync.Mutex
93+ moveLifecycleClosing bool
94+ moveLifecycleActive uint
95+ moveLifecycleChanged chan struct{}
96+ // paneProcessCtx roots transferable pane processes outside any one session.
97+ // Shutdown cancels it only after the global move gate drains.
98+ paneProcessCtx context.Context
99+ paneProcessCancel context.CancelFunc
100+
88 // notifies holds one completion channel per in-flight async Detached101 // notifies holds one completion channel per in-flight async Detached
89 // notification (guarded by mu, pruned on insert). Channels rather than a102 // notification (guarded by mu, pruned on insert). Channels rather than a
90 // WaitGroup: notifications are spawned from arbitrary goroutines while103 // WaitGroup: notifications are spawned from arbitrary goroutines while
@@ -112,18 +125,84 @@ type Daemon struct {
112 // and before coordinator epoch admission. It is a deterministic regression125 // and before coordinator epoch admission. It is a deterministic regression
113 // seam for stale resize publication.126 // seam for stale resize publication.
114 beforeSessionResizePublication func()127 beforeSessionResizePublication func()
115- ptys ports.PTYFactory128+ // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
116- clock ports.Clock129+ // and immediately before a post-commit effect is published. Tests use it to
117- log *slog.Logger130+ // move a pane through the ordered resize fences at the former TOCTOU window.
118- runtimeObserver ports.RuntimeObserver131+ beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
119- baseEnv []string132+ // afterResizeCommitSendLocked is a deterministic lock-order seam. It runs
120- shell string133+ // after publishResizeCommit acquires attachment sendMu and before it acquires
121- shellArgs []string134+ // owner fences. Tests must not perform external work from this callback.
122- shellOverride bool135+ afterResizeCommitSendLocked func()
123- persistEnabled bool136+ // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
124- catalogue ports.Catalogue137+ // after one overlay-family mutex is released and before the next is taken.
125- catalogueRecords []domain.CatalogueRecord138+ afterSnatchOverlayFamily func(string)
126- catalogueRecordsProvided bool139+ // afterSnatchedEscapeAccepted observes a standalone ESC after parser state
140+ // is consumed and before its captured role and transport are revalidated.
141+ afterSnatchedEscapeAccepted func()
142+ // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
143+ // role capability after revalidation.
144+ afterSnatchedEscapeAttempt func(bool)
145+ // afterActiveFrameDispatch is a deterministic test seam after the connection
146+ // loop's first role check and before a decoded active frame takes effect.
147+ afterActiveFrameDispatch func(attachmentRoleToken)
148+ // afterRoleEffectParticipantsSnapshotted observes immutable role-gate
149+ // participants after architecture preflight locks are released and before
150+ // the globally ordered freeze begins.
151+ afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient)
152+ // afterRoleEffectGateFrozen observes each participant after it is frozen in
153+ // immutable identity order and before drain/publication continues.
154+ afterRoleEffectGateFrozen func(string, *attachedClient)
155+ // afterRoleEffectsFrozen observes the lock-free boundary after all affected
156+ // attachment gates are frozen and drained, before architecture publication.
157+ afterRoleEffectsFrozen func()
158+ // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
159+ // seams. They run before role/fence work and after the pre-fence source
160+ // attachment snapshot respectively. beforeMovePaneCommit runs inside the
161+ // non-failing publication section. None is set in production, and none may
162+ // perform external work while locks are held.
163+ afterMoveLifecycleReserved func()
164+ afterMoveLifecycleGateBeforeTeardownLocks func()
165+ afterMovePaneSourceSnapshot func()
166+ afterMoveTabSourceSnapshot func()
167+ beforeMovePaneCommit func()
168+ beforeMoveTabCommit func()
169+ // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
170+ // attachment gate but before it checks session ownership.
171+ afterDetachRoleEffectsFrozen func()
172+ // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
173+ // synchronizes with the attachment gate.
174+ afterDisplacedCleanupStarted func()
175+ // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup
176+ // is queued and before its generation-bound reset first paint is admitted.
177+ beforeReclaimFirstPaint func(attachmentRoleToken)
178+ // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
179+ // reserves its exact capability and before its first observable mutation.
180+ afterRoleEffectAdmitted func(attachmentRoleToken)
181+ // beforeFirstPaintSendWait is a test-only seam immediately before a
182+ // transition first paint waits for the attachment send lock.
183+ beforeFirstPaintSendWait func(attachmentRoleToken)
184+ // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
185+ // fresh exact capability before producing PTY, action, or overlay effects.
186+ afterDelayedKeyEffectAttempt func(bool)
187+ // afterActionRoleEffectEnded observes action-specific admission release. It
188+ // is a deterministic seam for proving no role-bound mutation follows release.
189+ afterActionRoleEffectEnded func(string)
190+ // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement
191+ // after the render ticket ends and before exact lifecycle validation.
192+ beforeRoleSendErrorCleanup func(attachmentRoleToken)
193+ afterRoleSendErrorCleanup func()
194+ ptys ports.PTYFactory
195+ clock ports.Clock
196+ log *slog.Logger
197+ runtimeObserver ports.RuntimeObserver
198+ baseEnv []string
199+ shell string
200+ shellArgs []string
201+ shellOverride bool
202+ persistEnabled bool
203+ catalogue ports.Catalogue
204+ catalogueRecords []domain.CatalogueRecord
205+ catalogueRecordsProvided bool
127 // snapshotRepository is the sole checkpoint storage contract.206 // snapshotRepository is the sole checkpoint storage contract.
128 snapshotRepository ports.SnapshotRepository207 snapshotRepository ports.SnapshotRepository
129 recovery *recoveryusecase.Coordinator208 recovery *recoveryusecase.Coordinator
@@ -212,6 +291,7 @@ type Daemon struct {
212 type parkedAttachment struct {291 type parkedAttachment struct {
213 sess *session292 sess *session
214 ac *attachedClient293 ac *attachedClient
294+ role attachmentRole
215 timer ports.Timer295 timer ports.Timer
216 done chan struct{}296 done chan struct{}
217 doneOnce sync.Once297 doneOnce sync.Once
@@ -443,25 +523,28 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
443 if shell == "" {523 if shell == "" {
444 shell = "/bin/sh"524 shell = "/bin/sh"
445 }525 }
526+ paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
446 d := &Daemon{527 d := &Daemon{
447- sessions: make(map[domain.SessionID]*session),528+ sessions: make(map[domain.SessionID]*session),
448- stopped: make(map[string]stoppedSession),529+ stopped: make(map[string]stoppedSession),
449- creating: make(map[string]struct{}),530+ creating: make(map[string]struct{}),
450- parked: make(map[uint64]*parkedAttachment),531+ parked: make(map[uint64]*parkedAttachment),
451- ptys: ptys,532+ paneProcessCtx: paneProcessCtx,
452- clock: clock,533+ paneProcessCancel: paneProcessCancel,
453- log: log,534+ ptys: ptys,
454- baseEnv: os.Environ(),535+ clock: clock,
455- shell: shell,536+ log: log,
456- dirOrHome: dirOrHome,537+ baseEnv: os.Environ(),
457- done: make(chan struct{}),538+ shell: shell,
458- restoreDone: make(chan struct{}),539+ dirOrHome: dirOrHome,
459- animWake: make(chan struct{}, 1),540+ done: make(chan struct{}),
460- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),541+ restoreDone: make(chan struct{}),
461- snapshotAdmitted: make(map[*snapshotCapture]struct{}),542+ animWake: make(chan struct{}, 1),
462- snapshotWake: make(chan struct{}, 1),543+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
463- notices: newNoticeCenter(),544+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
464- resumeParkGrace: defaultResumeParkGrace,545+ snapshotWake: make(chan struct{}, 1),
546+ notices: newNoticeCenter(),
547+ resumeParkGrace: defaultResumeParkGrace,
465 barScripts: &barScriptState{548 barScripts: &barScriptState{
466 cfg: barConfigFromDomain(domain.Defaults().Bar),549 cfg: barConfigFromDomain(domain.Defaults().Bar),
467 outputs: make(map[domain.SessionID]barScriptOutputs),550 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -635,14 +718,14 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
635 }718 }
636 719
637 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {720 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
721+ d.closeMoveLifecycles()
638 d.mu.Lock()722 d.mu.Lock()
639 d.closing = true723 d.closing = true
640- for token, parked := range d.parked {724+ parkedRetirements := d.purgeAllParkedLocked()
641- d.removeParkedLocked(token, parked)
642- }
643 snapshot := d.sessionsSnapshotLocked()725 snapshot := d.sessionsSnapshotLocked()
644 empty := len(snapshot) == 0726 empty := len(snapshot) == 0
645 d.mu.Unlock()727 d.mu.Unlock()
728+ finishParkedAttachmentRetirements(parkedRetirements)
646 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))729 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
647 if empty {730 if empty {
648 d.doneOnce.Do(func() { close(d.done) })731 d.doneOnce.Do(func() { close(d.done) })
@@ -656,7 +739,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
656 ephemeral := s.ephemeral739 ephemeral := s.ephemeral
657 s.mu.Unlock()740 s.mu.Unlock()
658 s.stopInMemoryLifecycle()741 s.stopInMemoryLifecycle()
659- if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil {742+ if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
660 checkpointIncomplete = true743 checkpointIncomplete = true
661 d.log.Error("closing session with unpersisted terminal state", "err", err)744 d.log.Error("closing session with unpersisted terminal state", "err", err)
662 }745 }
@@ -918,21 +1001,64 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
918 lease = rc.attachmentLease(ac)1001 lease = rc.attachmentLease(ac)
919 }1002 }
920 expected := ac.transportSnapshot()1003 expected := ac.transportSnapshot()
921- if lease == nil || expected.transport != tr {1004+ welcomeToken := sess.attachmentToken(ac, tr)
922- d.clientGone(sess, ac, tr, false)1005+ welcomeToken.lease = lease
1006+ welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)
1007+ validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil
1008+ if expected.transport != tr || !admitted || !validRole {
1009+ if admitted {
1010+ welcomeTicket.End()
1011+ }
1012+ if welcomeToken.role == attachmentSnatched {
1013+ d.parkOrDropSnatchedAttachment(welcomeToken)
1014+ } else {
1015+ d.clientGone(sess, ac, tr, false)
1016+ }
923 return1017 return
924 }1018 }
925- if err := ac.sendExpectedTransport(expected, frameWelcome(sess, ac)); err != nil {1019+ if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
1020+ welcomeTicket.End()
1021+ if welcomeToken.role == attachmentSnatched {
1022+ d.parkOrDropSnatchedAttachment(welcomeToken)
1023+ } else {
1024+ d.clientGone(sess, ac, tr, false)
1025+ }
1026+ return
1027+ }
1028+ // Welcome is the only effect allowed to retain the route-time role. Release
1029+ // it before discovering post-handshake authority so a replacement blocked
1030+ // behind the send can publish its generation and lease first.
1031+ welcomeTicket.End()
1032+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr)
1033+ if !admitted {
926 d.clientGone(sess, ac, tr, false)1034 d.clientGone(sess, ac, tr, false)
927 return1035 return
928 }1036 }
929- if !rc.markAttachmentReady(lease) {1037+ if postWelcomeToken.role == attachmentSnatched {
1038+ if err := d.sendInitialSnatchedPanel(postWelcomeToken, postWelcomeTicket); err != nil {
1039+ postWelcomeTicket.End()
1040+ d.parkOrDropSnatchedAttachment(postWelcomeToken)
1041+ return
1042+ }
1043+ postWelcomeTicket.End()
1044+ d.runConnLoop(ac)
1045+ _ = tr.Close()
1046+ return
1047+ }
1048+ postWelcomeLease := postWelcomeToken.lease
1049+ if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil || !rc.markAttachmentReady(postWelcomeLease) {
1050+ postWelcomeTicket.End()
930 // The attachment was displaced or detached while Welcome was in flight;1051 // The attachment was displaced or detached while Welcome was in flight;
931 // never let this stale handshake emit an Output frame.1052 // never let this stale handshake emit an Output frame.
932 d.clientGone(sess, ac, tr, false)1053 d.clientGone(sess, ac, tr, false)
933 return1054 return
934 }1055 }
935- d.firstPaint(sess, ac, h.Size)1056+ postWelcomeTicket.End()
1057+ paintToken := sess.attachmentToken(ac, tr)
1058+ if !d.firstPaintForTransition(paintToken) {
1059+ d.clientGone(sess, ac, tr, false)
1060+ return
1061+ }
936 d.runConnLoop(ac)1062 d.runConnLoop(ac)
937 _ = tr.Close()1063 _ = tr.Close()
938 }1064 }
@@ -945,24 +1071,42 @@ type protoErr struct {
945 1071
946 func (e *protoErr) Error() string { return e.text }1072 func (e *protoErr) Error() string { return e.text }
947 1073
948-// finishAttach completes an attachment prepared while d.mu is held. It1074+// finishAttach completes an attachment prepared while d.mu is held. Every
949-// publishes the terminal state before releasing d.mu, then queues replacement1075+// caller must hold d.mu on entry; finishAttach transfers ownership by
950-// teardown so obsolete worker joins never delay the new handshake.1076+// unlocking d.mu before returning, including every error path. It publishes
951-func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {1077+// terminal and role state before releasing d.mu, then defers coordinator
1078+// cleanup so obsolete workers never delay the new handshake.
1079+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {
952 // Session state is the sole source for future PTY children. Update it before1080 // Session state is the sole source for future PTY children. Update it before
953 // publishing the attachment; existing PTYs keep their original environment.1081 // publishing the attachment; existing PTYs keep their original environment.
954 sess.mu.Lock()1082 sess.mu.Lock()
955 sess.env = copyEnvironment(h.Env)1083 sess.env = copyEnvironment(h.Env)
956 sess.terminal = term1084 sess.terminal = term
957 sess.mu.Unlock()1085 sess.mu.Unlock()
958- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{1086+ opts := attachClientOptions{
959 clientID: h.ClientID,1087 clientID: h.ClientID,
960 resumeCapable: true,1088 resumeCapable: true,
961 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1089 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
962- })1090+ }
1091+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
963 d.mu.Unlock()1092 d.mu.Unlock()
964- d.retireReplacedClient(old, cleanup)1093+ result, err := d.transitionAttachment(attachmentTransitionRequest{
965- return ac1094+ target: sess,
1095+ next: ac,
1096+ expectedRole: attachmentDetached,
1097+ targetRole: attachmentActive,
1098+ expectedTransport: ac.transportSnapshot(),
1099+ ready: false,
1100+ })
1101+ if err != nil {
1102+ if tr != nil {
1103+ _ = tr.Close()
1104+ }
1105+ return nil, err
1106+ }
1107+ d.finishAttachedClient(sess, ac, opts)
1108+ d.deferAttachmentTransitionCleanups(result)
1109+ return ac, nil
966 }1110 }
967 1111
968 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {1112 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
@@ -1021,9 +1165,28 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1021 }1165 }
1022 term := terminalEnv{TrueColor: h.TrueColor}1166 term := terminalEnv{TrueColor: h.TrueColor}
1023 1167
1024- if h.Intent == ports.IntentResume {1168+ // A non-zero token is an authoritative resume credential. If it is unknown,
1025- if sess, ac, ok, err := d.resumeParked(h, tr, sz); ok || err != nil {1169+ // expired, or raced with lifecycle teardown, fail closed instead of routing
1026- return sess, ac, err1170+ // the Hello as an ordinary attach that could create or replace ownership.
1171+ if h.ResumeToken != 0 {
1172+ d.mu.Lock()
1173+ parkedAtStart := d.parked[h.ResumeToken]
1174+ d.mu.Unlock()
1175+ if parkedAtStart == nil {
1176+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1177+ }
1178+ if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1179+ if ok {
1180+ return sess, ac, nil
1181+ }
1182+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1183+ } else if errors.Is(err, errResumeTokenLifecycleRace) {
1184+ // The parked entry was replaced while this handshake waited for
1185+ // its send lock. The ordinary attach path can reclaim the still-live
1186+ // named session instead of turning this grace-period race into a
1187+ // terminal no-such-session error.
1188+ } else {
1189+ return nil, nil, err
1027 }1190 }
1028 }1191 }
1029 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {1192 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
@@ -1063,8 +1226,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1063 return nil, nil, err1226 return nil, nil, err
1064 }1227 }
1065 }1228 }
1066- d.purgeParkedForSessionLocked(sess)1229+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1067- return sess, d.finishAttach(sess, tr, sz, term, h), nil1230+ return sess, ac, err
1068 1231
1069 case ports.IntentEphemeral:1232 case ports.IntentEphemeral:
1070 name := d.allocEphemeralNameLocked()1233 name := d.allocEphemeralNameLocked()
@@ -1073,8 +1236,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1073 d.mu.Unlock()1236 d.mu.Unlock()
1074 return nil, nil, err1237 return nil, nil, err
1075 }1238 }
1076- d.purgeParkedForSessionLocked(sess)1239+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1077- return sess, d.finishAttach(sess, tr, sz, term, h), nil1240+ return sess, ac, err
1078 1241
1079 case ports.IntentNew:1242 case ports.IntentNew:
1080 if h.Name == "" {1243 if h.Name == "" {
@@ -1094,8 +1257,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1094 d.mu.Unlock()1257 d.mu.Unlock()
1095 return nil, nil, err1258 return nil, nil, err
1096 }1259 }
1097- d.purgeParkedForSessionLocked(sess)1260+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1098- return sess, d.finishAttach(sess, tr, sz, term, h), nil1261+ return sess, ac, err
1099 1262
1100 case ports.IntentAttach:1263 case ports.IntentAttach:
1101 sess := d.findByNameLocked(h.Name)1264 sess := d.findByNameLocked(h.Name)
@@ -1113,8 +1276,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1113 return nil, nil, err1276 return nil, nil, err
1114 }1277 }
1115 }1278 }
1116- d.purgeParkedForSessionLocked(sess)1279+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1117- return sess, d.finishAttach(sess, tr, sz, term, h), nil1280+ return sess, ac, err
1118 1281
1119 default:1282 default:
1120 d.mu.Unlock()1283 d.mu.Unlock()
M internal/usecase/daemon/daemon_test.go +15 -2
@@ -381,12 +381,22 @@ func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.
381 381
382 func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {382 func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
383 t.Helper()383 t.Helper()
384- return newManualSessionWithPTYsCleanup(t, true, ptys...)384+ return newManualSessionWithPTYsClock(t, stubClock{}, ptys...)
385+}
386+
387+func newManualSessionWithPTYsClock(t testing.TB, clock ports.Clock, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
388+ t.Helper()
389+ return newManualSessionWithPTYsClockCleanup(t, clock, true, ptys...)
385 }390 }
386 391
387 func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {392 func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
388 t.Helper()393 t.Helper()
389- d := newTestDaemonWithCleanup(t, nil, stubClock{}, registerCleanup)394+ return newManualSessionWithPTYsClockCleanup(t, stubClock{}, registerCleanup, ptys...)
395+}
396+
397+func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
398+ t.Helper()
399+ d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
390 tr, sends := newCapturingTransport(t)400 tr, sends := newCapturingTransport(t)
391 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}401 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
392 ac.initOverlays()402 ac.initOverlays()
@@ -402,6 +412,9 @@ func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ..
402 tabs = append(tabs, tb)412 tabs = append(tabs, tb)
403 }413 }
404 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}414 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
415+ for _, tb := range tabs {
416+ publishTiledPaneOwners(sess, tb)
417+ }
405 ac.setSession(sess)418 ac.setSession(sess)
406 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)419 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
407 d.sessions[sess.id] = sess420 d.sessions[sess.id] = sess
M internal/usecase/daemon/floating.go +98 -46
@@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 {
42 return tb.floating.generation42 return tb.floating.generation
43 }43 }
44 44
45+// floatingTransferableLocked reports whether a tab's floating lifecycle can
46+// cross an ownership commit. A warming Open remains registered to its source
47+// session until installation or failure, so moves must reject only that state.
48+// The caller must hold tb.mu.
49+func (tb *tab) floatingTransferableLocked() bool {
50+ return tb != nil && tb.floating.state != floatingWarming
51+}
52+
45 // toggleFloatingLocked applies the user action. Its result says whether a new53 // toggleFloatingLocked applies the user action. Its result says whether a new
46 // PTY launch is required and returns the generation associated with that launch54 // PTY launch is required and returns the generation associated with that launch
47 // or current slot.55 // or current slot.
@@ -160,15 +168,33 @@ func (d *Daemon) activateTab(sess *session, tb *tab) bool {
160 // activateTabAfterResize retains tab activation's warmup work while avoiding a168 // activateTabAfterResize retains tab activation's warmup work while avoiding a
161 // second resize when a synchronous outer resize request was already accepted.169 // second resize when a synchronous outer resize request was already accepted.
162 func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {170 func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
171+ return d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, nil, nil)
172+}
173+
174+// activateTabAfterResizeForLease keeps transition-owned resize effects bound to
175+// the exact coordinator incarnation. A nil lease preserves direct/headless
176+// activation behavior.
177+func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerResizeAccepted bool, ac *attachedClient, lease *attachmentLease) bool {
163 if d == nil || sess == nil || tb == nil {178 if d == nil || sess == nil || tb == nil {
164 return false179 return false
165 }180 }
166 // A headless session has no actual terminal destination. Deferring warmup181 // A headless session has no actual terminal destination. Deferring warmup
167 // until firstPaint keeps restored tabs cold and avoids launching children182 // until firstPaint keeps restored tabs cold and avoids launching children
168 // merely because a tab was manipulated during teardown.183 // merely because a tab was manipulated during teardown.
169- sess.mu.Lock()184+ if ac == nil {
170- ac := sess.client185+ sess.mu.Lock()
171- sess.mu.Unlock()186+ ac = sess.client
187+ sess.mu.Unlock()
188+ }
189+ if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190+ rc.opts.onActivateTabAfterResize(lease != nil)
191+ }
192+ if lease != nil {
193+ rc := sess.renderCoordinator()
194+ if rc == nil || !rc.leaseCurrent(lease, true) {
195+ return false
196+ }
197+ }
172 d.exitCopyMode(ac)198 d.exitCopyMode(ac)
173 if ac == nil {199 if ac == nil {
174 return false200 return false
@@ -182,6 +208,9 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep
182 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}208 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
183 tb.mu.Unlock()209 tb.mu.Unlock()
184 if hasFloating {210 if hasFloating {
211+ if lease != nil {
212+ return d.requestTransactionalResizeForLease(sess, ac, lease, size, true)
213+ }
185 return d.requestTransactionalResize(sess, ac, size, true)214 return d.requestTransactionalResize(sess, ac, size, true)
186 }215 }
187 return false216 return false
@@ -366,17 +395,22 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
366 if err := spec.parentCtx.Err(); err != nil {395 if err := spec.parentCtx.Err(); err != nil {
367 return396 return
368 }397 }
369- openCtx, cancelOpen := context.WithCancel(spec.parentCtx)398+ lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
370- stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
371 ownedByPane := false399 ownedByPane := false
372 defer func() {400 defer func() {
373 if !ownedByPane {401 if !ownedByPane {
374- stopSessionCancel()402+ lifetime.abort()
375- cancelOpen()
376 }403 }
377 }()404 }()
378- pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)405+ pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
379 if err != nil {406 if err != nil {
407+ // Open retains ownership of a nonnil PTY only on success. Some factory
408+ // implementations can return a partially opened PTY with an error, so
409+ // release both resources before publishing the launch failure.
410+ lifetime.abort()
411+ if pty != nil {
412+ _ = pty.Close()
413+ }
380 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)414 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
381 return415 return
382 }416 }
@@ -384,17 +418,17 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
384 p.rect = spec.geometry.ptyRect()418 p.rect = spec.geometry.ptyRect()
385 p.popupGeometry = spec.geometry419 p.popupGeometry = spec.geometry
386 p.title.displayFallback = spec.fallback420 p.title.displayFallback = spec.fallback
387- // CommandContext owns the child through openCtx, so retain both this context421+ if !lifetime.publish(p) {
388- // and its cancellation until the installed pane is reaped or torn down.422+ _ = pty.Close()
389- p.ctx = openCtx423+ d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, lifetime.ctx.Err())
390- p.cancel = func() {424+ return
391- stopSessionCancel()
392- cancelOpen()
393 }425 }
394 ownedByPane = true426 ownedByPane = true
395 // The reader may run as soon as install returns; make its exit policy427 // The reader may run as soon as install returns; make its exit policy
396- // immutable before publishing the pane to the slot.428+ // immutable before publishing the pane to the slot. Installed panes resolve
397- p.onExit = func() { d.reapFloating(sess, tb, p, generation) }429+ // their owner dynamically so a transferred tab never reaps through source
430+ // session pointers captured by its launch.
431+ p.onExit = func() { d.reapInstalledFloating(p) }
398 d.installFloating(sess, tb, p, generation)432 d.installFloating(sess, tb, p, generation)
399 }433 }
400 434
@@ -423,8 +457,18 @@ func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, u
423 // installFloating installs only the current generation. A stale successful457 // installFloating installs only the current generation. A stale successful
424 // launch is cancelled and closed outside the tab lock.458 // launch is cancelled and closed outside the tab lock.
425 func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) {459 func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) {
460+ if sess == nil || tb == nil || p == nil {
461+ closeFloatingPane(p)
462+ return
463+ }
426 tb.mu.Lock()464 tb.mu.Lock()
427- installed := tb.installFloatingLocked(p, generation)465+ installable := tb.floating.state == floatingWarming && tb.floating.generation == generation
466+ if installable {
467+ // Publish under pane.mu before the slot becomes hidden or visible. The
468+ // tab-to-pane lock order also linearizes owner and slot publication.
469+ publishPaneOwner(p, sess, tb, generation)
470+ }
471+ installed := installable && tb.installFloatingLocked(p, generation)
428 visible := installed && tb.floating.state == floatingVisible472 visible := installed && tb.floating.state == floatingVisible
429 tb.mu.Unlock()473 tb.mu.Unlock()
430 if !installed {474 if !installed {
@@ -444,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
444 }488 }
445 }489 }
446 490
447-// reapFloating ignores old readers and restores the background only when the491+// reapInstalledFloating resolves the pane's current immutable owner on every
448-// exiting popup was visible.492+// attempt. If ownership changes before the tab lock is acquired, retrying the
449-func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {493+// lookup routes exit through the destination. The owner pointer and floating
450- tb.mu.Lock()494+// slot generation are then checked together under tb.mu so an old exit can
451- visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation495+// never clear a reused slot.
452- cleared := tb.clearFloatingLocked(p, generation)496+func (d *Daemon) reapInstalledFloating(p *pane) {
453- tb.mu.Unlock()497+ if p == nil {
454- if !cleared {
455 return498 return
456 }499 }
457- closeFloatingPane(p)500+ for {
458- sess.mu.Lock()501+ owner := p.ownerSnapshot()
459- ac := sess.client502+ if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 {
460- sess.mu.Unlock()503+ return
461- if ac != nil {504+ }
462- ac.pruneCaptureFrames(p)505+ sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration
463- }506+ tb.mu.Lock()
464- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)507+ if p.ownerSnapshot() != owner {
465- if ac != nil && (visible || copyCleared) {508+ tb.mu.Unlock()
466- d.invalidateRender(sess, ac, true, "floating.go")509+ continue
510+ }
511+ visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
512+ cleared := tb.clearFloatingLocked(p, generation)
513+ tb.mu.Unlock()
514+ if !cleared {
515+ return
516+ }
517+ closeFloatingPane(p)
518+ sess.mu.Lock()
519+ ac := sess.client
520+ sess.mu.Unlock()
521+ if ac != nil {
522+ ac.pruneCaptureFrames(p)
523+ }
524+ copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
525+ if ac != nil && (visible || copyCleared) {
526+ d.invalidateRender(sess, ac, true, "floating.go")
527+ }
528+ return
467 }529 }
468 }530 }
469 531
@@ -485,17 +547,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
485 // closeFloatingPane releases a floating runtime at most once. It is called547 // closeFloatingPane releases a floating runtime at most once. It is called
486 // only after a generation/identity check has detached the pane from its slot.548 // only after a generation/identity check has detached the pane from its slot.
487 func closeFloatingPane(p *pane) {549 func closeFloatingPane(p *pane) {
488- if p == nil {550+ closePaneProcess(p)
489- return
490- }
491- p.floatingCloseOnce.Do(func() {
492- if p.cancel != nil {
493- p.cancel()
494- }
495- if p.pty != nil {
496- _ = p.pty.Close()
497- }
498- })
499 }551 }
500 552
501 func floatingCommandFallback(command, shell string) string {553 func floatingCommandFallback(command, shell string) string {
M internal/usecase/daemon/floating_open_test.go +175 -7
@@ -2,7 +2,9 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "io"
5 "sync"6 "sync"
7+ "sync/atomic"
6 "testing"8 "testing"
7 "time"9 "time"
8 10
@@ -71,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st
71 73
72 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {74 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
73 teardowns := []struct {75 teardowns := []struct {
74- name string76+ name string
75- teardown func(*Daemon, *session, *tab)77+ desiredVisible bool
78+ teardown func(*Daemon, *session, *tab)
76 }{79 }{
77 {80 {
78- name: "floating pane",81+ name: "hidden floating pane",
79 teardown: func(d *Daemon, _ *session, tb *tab) {82 teardown: func(d *Daemon, _ *session, tb *tab) {
80 d.teardownFloating(tb, nil)83 d.teardownFloating(tb, nil)
81 },84 },
82 },85 },
83 {86 {
84- name: "tab",87+ name: "visible tab",
88+ desiredVisible: true,
85 teardown: func(_ *Daemon, _ *session, tb *tab) {89 teardown: func(_ *Daemon, _ *session, tb *tab) {
86 tb.closeAllPanes()90 tb.closeAllPanes()
87 },91 },
88 },92 },
89 {93 {
90- name: "session",94+ name: "visible session",
95+ desiredVisible: true,
91 teardown: func(d *Daemon, sess *session, _ *tab) {96 teardown: func(d *Daemon, sess *session, _ *tab) {
92 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))97 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
93 },98 },
@@ -107,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
107 d.sessions[sess.id] = sess112 d.sessions[sess.id] = sess
108 d.mu.Unlock()113 d.mu.Unlock()
109 tb.mu.Lock()114 tb.mu.Lock()
110- generation := tb.beginFloatingWarmLocked(true)115+ generation := tb.beginFloatingWarmLocked(tt.desiredVisible)
111 tb.mu.Unlock()116 tb.mu.Unlock()
112 117
113 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{118 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
@@ -127,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
127 sess.mu.Lock()132 sess.mu.Lock()
128 sess.client = &attachedClient{}133 sess.client = &attachedClient{}
129 sess.mu.Unlock()134 sess.mu.Unlock()
130- require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable")135+ require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
131 sess.mu.Lock()136 sess.mu.Lock()
132 sess.client = nil137 sess.client = nil
133 sess.mu.Unlock()138 sess.mu.Unlock()
@@ -154,6 +159,169 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
154 }159 }
155 }160 }
156 161
162+type failedOpenPTY struct {
163+ ctx context.Context
164+ readerStarts atomic.Int32
165+ closes atomic.Int32
166+ closed chan struct{}
167+ closeOnce sync.Once
168+ onClose func()
169+}
170+
171+func newFailedOpenPTY() *failedOpenPTY {
172+ return &failedOpenPTY{closed: make(chan struct{})}
173+}
174+
175+func (p *failedOpenPTY) Read([]byte) (int, error) {
176+ p.readerStarts.Add(1)
177+ select {
178+ case <-p.ctx.Done():
179+ return 0, p.ctx.Err()
180+ case <-p.closed:
181+ return 0, io.EOF
182+ }
183+}
184+
185+func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil }
186+func (*failedOpenPTY) Resize(domain.Size) error { return nil }
187+func (*failedOpenPTY) Pid() int { return 1 }
188+func (*failedOpenPTY) ForegroundPgid() (int, error) {
189+ return 1, nil
190+}
191+
192+func (p *failedOpenPTY) Close() error {
193+ p.closes.Add(1)
194+ if p.onClose != nil {
195+ p.onClose()
196+ }
197+ p.closeOnce.Do(func() { close(p.closed) })
198+ return nil
199+}
200+
201+func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.T) {
202+ tests := []struct {
203+ name string
204+ userOpen bool
205+ }{
206+ {name: "user open", userOpen: true},
207+ {name: "background prewarm", userOpen: false},
208+ }
209+ for _, tt := range tests {
210+ t.Run(tt.name, func(t *testing.T) {
211+ cause := io.ErrUnexpectedEOF
212+ pty := newFailedOpenPTY()
213+ factory := portsmocks.NewMockPTYFactory(t)
214+ cancelCallbacks := atomic.Int32{}
215+ cancelled := make(chan struct{})
216+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
217+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
218+ pty.ctx = ctx
219+ context.AfterFunc(ctx, func() {
220+ cancelCallbacks.Add(1)
221+ close(cancelled)
222+ })
223+ return pty, cause
224+ }).Once()
225+ d := newTestDaemon(t, factory, stubClock{})
226+ tb := newFloatingTestTab(t)
227+ tr, _ := newCapturingTransport(t)
228+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229+ ac.initOverlays()
230+ sessCtx, cancelSession := context.WithCancel(t.Context())
231+ sess := &session{id: "floating-open-error", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession, client: ac}
232+ ac.setSession(sess)
233+
234+ // Close must be safe to run before failure publication and without
235+ // either ownership lock held.
236+ var unpublishedAtClose atomic.Bool
237+ pty.onClose = func() {
238+ tb.mu.Lock()
239+ launchUnpublished := tb.floating.pane == nil && tb.floating.state == floatingWarming
240+ tb.mu.Unlock()
241+ sess.mu.Lock()
242+ sessionName := sess.name
243+ sess.mu.Unlock()
244+ unpublishedAtClose.Store(launchUnpublished && sessionName == "work")
245+ }
246+
247+ d.startFloating(sess, tb, tt.userOpen)
248+ d.sessWg.Wait()
249+ select {
250+ case <-cancelled:
251+ case <-time.After(time.Second):
252+ t.Fatal("failed floating Open process context was not cancelled")
253+ }
254+
255+ // Exercise later cancellation/stale cleanup paths; neither may find
256+ // ownership to release a second time.
257+ cancelSession()
258+ d.teardownFloating(tb, ac)
259+ require.Equal(t, int32(1), cancelCallbacks.Load(), "process context must be released exactly once")
260+ require.Equal(t, int32(1), pty.closes.Load(), "returned PTY must be closed exactly once")
261+ require.True(t, unpublishedAtClose.Load(), "PTY must close before any failure publication")
262+ require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
263+
264+ tb.mu.Lock()
265+ floating := tb.floating.pane
266+ publishedFailedPTY := false
267+ for _, published := range tb.panes {
268+ publishedFailedPTY = publishedFailedPTY || published.pty == pty
269+ }
270+ tb.mu.Unlock()
271+ require.Nil(t, floating, "failed Open must not publish the floating slot")
272+ require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
273+ if tt.userOpen {
274+ toasts := awaitToastCount(t, ac, 1)
275+ require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code)
276+ require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message)
277+ } else {
278+ toasts, _ := visibleToasts(ac)
279+ require.Empty(t, toasts, "background prewarm failure must stay silent")
280+ }
281+ })
282+ }
283+}
284+
285+func TestInstallFloatingRejectsNilPublicationInputs(t *testing.T) {
286+ tests := []struct {
287+ name string
288+ nilSession bool
289+ nilTab bool
290+ nilPane bool
291+ wantCloses int
292+ }{
293+ {name: "nil session", nilSession: true, wantCloses: 1},
294+ {name: "nil tab", nilTab: true, wantCloses: 1},
295+ {name: "nil pane", nilPane: true},
296+ {name: "all nil", nilSession: true, nilTab: true, nilPane: true},
297+ }
298+ for _, tt := range tests {
299+ t.Run(tt.name, func(t *testing.T) {
300+ d := &Daemon{}
301+ sess := &session{}
302+ tb := newFloatingTestTab(t)
303+ tb.mu.Lock()
304+ generation := tb.beginFloatingWarmLocked(true)
305+ tb.mu.Unlock()
306+ pty := newContextAwareFloatingPTY()
307+ p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 8})
308+
309+ if tt.nilSession {
310+ sess = nil
311+ }
312+ if tt.nilTab {
313+ tb = nil
314+ }
315+ if tt.nilPane {
316+ p = nil
317+ }
318+
319+ require.NotPanics(t, func() { d.installFloating(sess, tb, p, generation) })
320+ require.Equal(t, tt.wantCloses, pty.closeCount)
321+ })
322+ }
323+}
324+
157 func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {325 func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
158 tests := []struct {326 tests := []struct {
159 name string327 name string
M internal/usecase/daemon/floating_test.go +85 -5
@@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) {
131 }131 }
132 }132 }
133 133
134+func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) {
135+ tests := []struct {
136+ name string
137+ state floatingState
138+ want bool
139+ }{
140+ {name: "uninitialized", state: floatingUninitialized, want: true},
141+ {name: "warming", state: floatingWarming, want: false},
142+ {name: "hidden installed", state: floatingHidden, want: true},
143+ {name: "visible installed", state: floatingVisible, want: true},
144+ }
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}}
148+ tb.mu.Lock()
149+ got := tb.floatingTransferableLocked()
150+ tb.mu.Unlock()
151+ require.Equal(t, tt.want, got)
152+ require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state")
153+ require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation")
154+ })
155+ }
156+}
157+
134 func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {158 func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
135 pty := portsmocks.NewMockPTY(t)159 pty := portsmocks.NewMockPTY(t)
136 readerStarted := make(chan struct{})160 readerStarted := make(chan struct{})
@@ -337,28 +361,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
337 second := &pane{}361 second := &pane{}
338 tb.mu.Lock()362 tb.mu.Lock()
339 g1 := tb.beginFloatingWarmLocked(true)363 g1 := tb.beginFloatingWarmLocked(true)
364+ publishPaneOwner(first, sess, tb, g1)
340 require.True(t, tb.installFloatingLocked(first, g1))365 require.True(t, tb.installFloatingLocked(first, g1))
341- g2 := tb.clearFloatingLocked(first, g1)366+ clearSucceeded := tb.clearFloatingLocked(first, g1)
342- require.True(t, g2)367+ require.True(t, clearSucceeded)
343 generation := tb.beginFloatingWarmLocked(true)368 generation := tb.beginFloatingWarmLocked(true)
344 require.True(t, tb.installFloatingLocked(second, generation))369 require.True(t, tb.installFloatingLocked(second, generation))
345 tb.mu.Unlock()370 tb.mu.Unlock()
346- d.reapFloating(sess, tb, first, g1)371+ d.reapInstalledFloating(first)
347 tb.mu.Lock()372 tb.mu.Lock()
348 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")373 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
349 tb.mu.Unlock()374 tb.mu.Unlock()
350 }375 }
351 376
377+func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
378+ factory := &lifetimePTYFactory{}
379+ d := newTestDaemon(t, factory, stubClock{})
380+ tb := newFloatingTestTab(t)
381+ source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()}
382+ destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()}
383+
384+ tb.mu.Lock()
385+ generation := tb.beginFloatingWarmLocked(false)
386+ tb.mu.Unlock()
387+ d.openAndInstallFloating(source, tb, floatingLaunchSpec{
388+ sessionName: source.name,
389+ size: domain.Size{Cols: 20, Rows: 8},
390+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
391+ paneStableID: "p_floating",
392+ parentCtx: tb.ctx,
393+ }, generation)
394+
395+ tb.mu.Lock()
396+ floating := tb.floating.pane
397+ tb.mu.Unlock()
398+ require.NotNil(t, floating)
399+ tb.mu.Lock()
400+ floating.mu.Lock()
401+ floating.publishOwnerLocked(destination, tb, generation)
402+ floating.mu.Unlock()
403+ tb.mu.Unlock()
404+
405+ sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
406+ sourceClient.initOverlays()
407+ destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
408+ destinationClient.initOverlays()
409+ source.client = sourceClient
410+ destination.client = destinationClient
411+
412+ floating.onExit()
413+ d.sessWg.Wait()
414+
415+ tb.mu.Lock()
416+ require.Equal(t, floatingUninitialized, tb.floating.state)
417+ require.Nil(t, tb.floating.pane)
418+ tb.mu.Unlock()
419+ sourceClient.sendMu.Lock()
420+ _, sourceRetained := sourceClient.captureFrames[floating]
421+ sourceClient.sendMu.Unlock()
422+ destinationClient.sendMu.Lock()
423+ _, destinationRetained := destinationClient.captureFrames[floating]
424+ destinationClient.sendMu.Unlock()
425+ require.True(t, sourceRetained, "exit must not clean up the retired source owner")
426+ require.False(t, destinationRetained, "exit must clean up the current destination owner")
427+}
428+
352 func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {429 func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
353 pty, _ := newBlockingPTY(t)430 pty, _ := newBlockingPTY(t)
354 tb := newFloatingTestTab(t)431 tb := newFloatingTestTab(t)
355 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})432 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
433+ sess := &session{}
356 tb.mu.Lock()434 tb.mu.Lock()
357 g := tb.beginFloatingWarmLocked(true)435 g := tb.beginFloatingWarmLocked(true)
436+ publishPaneOwner(p, sess, tb, g)
358 require.True(t, tb.installFloatingLocked(p, g))437 require.True(t, tb.installFloatingLocked(p, g))
359 tb.mu.Unlock()438 tb.mu.Unlock()
360 d := newTestDaemon(t, nil, stubClock{})439 d := newTestDaemon(t, nil, stubClock{})
361- d.reapFloating(&session{}, tb, p, g)440+ d.reapInstalledFloating(p)
362 d.teardownFloating(tb, nil)441 d.teardownFloating(tb, nil)
363 pty.AssertNumberOfCalls(t, "Close", 1)442 pty.AssertNumberOfCalls(t, "Close", 1)
364 }443 }
@@ -423,6 +502,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
423 }).Once()502 }).Once()
424 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})503 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
425 generation := tb.beginFloatingWarmLocked(state == floatingVisible)504 generation := tb.beginFloatingWarmLocked(state == floatingVisible)
505+ publishPaneOwner(floating, sess, tb, generation)
426 require.True(t, tb.installFloatingLocked(floating, generation))506 require.True(t, tb.installFloatingLocked(floating, generation))
427 if state == floatingHidden {507 if state == floatingHidden {
428 require.Equal(t, floatingHidden, tb.floating.state)508 require.Equal(t, floatingHidden, tb.floating.state)
@@ -439,7 +519,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
439 519
440 reaped := make(chan struct{})520 reaped := make(chan struct{})
441 floating.onExit = func() {521 floating.onExit = func() {
442- d.reapFloating(sess, tb, floating, generation)522+ d.reapInstalledFloating(floating)
443 close(reaped)523 close(reaped)
444 }524 }
445 d.sessWg.Add(1)525 d.sessWg.Add(1)
M internal/usecase/daemon/input.go +86 -10
@@ -20,6 +20,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
20 d.handleInput(sess, ac, data)20 d.handleInput(sess, ac, data)
21 }21 }
22 22
23+func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {
24+ if !token.activeEffect() {
25+ return
26+ }
27+ d.handleInputForRole(token, data)
28+}
29+
23 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
24 ac.initOverlays()31 ac.initOverlays()
25 ac.mouseScan.Scan(data,32 ac.mouseScan.Scan(data,
@@ -33,6 +40,30 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
33 )40 )
34 }41 }
35 42
43+func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
44+ ac := token.ac
45+ ac.initOverlays()
46+ ac.mouseScan.Scan(data,
47+ func(ev mouse.Event) {
48+ if token.activeEffect() {
49+ d.handleMouse(ac, ev)
50+ }
51+ },
52+ func(b []byte) {
53+ if !token.activeEffect() {
54+ return
55+ }
56+ if ac.overlays.HandleInput(d, b, token.effect) {
57+ return
58+ }
59+ if !token.activeEffect() {
60+ return
61+ }
62+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token})
63+ },
64+ )
65+}
66+
36 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {67 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
37 frameEvent := ev68 frameEvent := ev
38 ac.initOverlays()69 ac.initOverlays()
@@ -267,16 +298,49 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
267 }298 }
268 299
269 type daemonKeyHandler struct {300 type daemonKeyHandler struct {
270- d *Daemon301+ d *Daemon
271- ac *attachedClient302+ ac *attachedClient
272- actions daemonActionRunner303+ actions daemonActionRunner
304+ roleToken attachmentRoleToken
273 }305 }
274 306
275-func (h daemonKeyHandler) Forward(data []byte) {307+// acquireRoleEffect preserves a synchronous frame's existing admission and
308+// gives delayed router callbacks a fresh ticket for the exact captured role.
309+// Direct/headless callers without a role token retain their existing behavior.
310+func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {
311+ if h.roleToken.ac != nil {
312+ if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
313+ return h.roleToken.sess, effect, false
314+ }
315+ effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
316+ if h.d.afterDelayedKeyEffectAttempt != nil {
317+ h.d.afterDelayedKeyEffectAttempt(admitted)
318+ }
319+ if !admitted {
320+ return nil, nil, false
321+ }
322+ if h.d.afterRoleEffectAdmitted != nil {
323+ token := h.roleToken
324+ token.effect = effect
325+ h.d.afterRoleEffectAdmitted(token)
326+ }
327+ return h.roleToken.sess, effect, true
328+ }
276 sess := h.ac.currentSession()329 sess := h.ac.currentSession()
330+ if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {
331+ return nil, nil, false
332+ }
333+ return sess, nil, false
334+}
335+
336+func (h daemonKeyHandler) Forward(data []byte) {
337+ sess, effect, owned := h.acquireRoleEffect()
277 if sess == nil {338 if sess == nil {
278 return339 return
279 }340 }
341+ if owned {
342+ defer effect.End()
343+ }
280 tb := sess.activeTab()344 tb := sess.activeTab()
281 if tb == nil {345 if tb == nil {
282 return346 return
@@ -288,10 +352,13 @@ func (h daemonKeyHandler) Forward(data []byte) {
288 }352 }
289 353
290 func (h daemonKeyHandler) Action(action keys.Action) {354 func (h daemonKeyHandler) Action(action keys.Action) {
291- sess := h.ac.currentSession()355+ sess, effect, owned := h.acquireRoleEffect()
292 if sess == nil {356 if sess == nil {
293 return357 return
294 }358 }
359+ if owned {
360+ defer effect.End()
361+ }
295 runAction := func(request daemonActionRequest) {362 runAction := func(request daemonActionRequest) {
296 request.target = resolveDaemonActionTarget(sess)363 request.target = resolveDaemonActionTarget(sess)
297 runner := h.actions364 runner := h.actions
@@ -317,7 +384,16 @@ func (h daemonKeyHandler) Action(action keys.Action) {
317 case keys.ActionOpenPalette:384 case keys.ActionOpenPalette:
318 h.d.enterPalette(sess, h.ac)385 h.d.enterPalette(sess, h.ac)
319 case keys.ActionJumpAttention:386 case keys.ActionJumpAttention:
320- if err := h.d.jumpAttention(sess, h.ac); err != nil {387+ if effect == nil {
388+ if err := h.d.jumpAttention(sess, h.ac); err != nil {
389+ h.d.reportError(sess, err)
390+ }
391+ return
392+ }
393+ effect.bindActionEnd(h.d, "jump-attention")
394+ token := h.roleToken
395+ token.effect = effect
396+ if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil {
321 h.d.reportError(sess, err)397 h.d.reportError(sess, err)
322 }398 }
323 case keys.ActionToggleFloatingPane:399 case keys.ActionToggleFloatingPane:
@@ -326,19 +402,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
326 h.d.reportError(sess, err)402 h.d.reportError(sess, err)
327 }403 }
328 case keys.ActionFocusPaneLeft:404 case keys.ActionFocusPaneLeft:
329- if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil {405+ if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
330 h.d.reportError(sess, err)406 h.d.reportError(sess, err)
331 }407 }
332 case keys.ActionFocusPaneRight:408 case keys.ActionFocusPaneRight:
333- if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil {409+ if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
334 h.d.reportError(sess, err)410 h.d.reportError(sess, err)
335 }411 }
336 case keys.ActionFocusPaneUp:412 case keys.ActionFocusPaneUp:
337- if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil {413+ if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
338 h.d.reportError(sess, err)414 h.d.reportError(sess, err)
339 }415 }
340 case keys.ActionFocusPaneDown:416 case keys.ActionFocusPaneDown:
341- if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {417+ if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
342 h.d.reportError(sess, err)418 h.d.reportError(sess, err)
343 }419 }
344 case keys.ActionGrowPaneWidth:420 case keys.ActionGrowPaneWidth:
M internal/usecase/daemon/layout_mutation.go +9 -9
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "slices"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/layout"7 "github.com/bnema/vev/internal/usecase/layout"
6 )8 )
@@ -31,14 +33,7 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan
31 return false, layout.ErrNotFound33 return false, layout.ErrNotFound
32 }34 }
33 target.session.mu.Lock()35 target.session.mu.Lock()
34- foundTab := false36+ if !slices.Contains(target.session.tabs, target.tab) {
35- for _, tb := range target.session.tabs {
36- if tb == target.tab {
37- foundTab = true
38- break
39- }
40- }
41- if !foundTab {
42 target.session.mu.Unlock()37 target.session.mu.Unlock()
43 d.mu.Unlock()38 d.mu.Unlock()
44 return false, layout.ErrNotFound39 return false, layout.ErrNotFound
@@ -74,7 +69,12 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan
74 d.mu.Unlock()69 d.mu.Unlock()
75 70
76 if !d.applyTabLayout(target.session, target.tab) {71 if !d.applyTabLayout(target.session, target.tab) {
77- return false, layout.ErrNotFound72+ target.tab.mu.Lock()
73+ applied := target.tab.tree == candidate
74+ target.tab.mu.Unlock()
75+ if !applied {
76+ return false, layout.ErrNotFound
77+ }
78 }78 }
79 return true, nil79 return true, nil
80 }80 }
A internal/usecase/daemon/move.go +330 -0
@@ -0,0 +1,330 @@
1+package daemon
2+
3+import (
4+ "reflect"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// movePaneRequest identifies both lifecycles and both topology objects by
10+// immutable identity. Session names are advisory; the ID and incarnation are
11+// the commit-time authority.
12+type movePaneRequest struct {
13+ Source moveSessionLocator
14+ SourceTabID domain.TabStableID
15+ SourcePaneID domain.PaneStableID
16+ Destination moveSessionLocator
17+ DestinationTabID domain.TabStableID
18+}
19+
20+type moveTabRequest struct {
21+ Source moveSessionLocator
22+ SourceTabID domain.TabStableID
23+ Destination moveSessionLocator
24+}
25+
26+// movePane resolves, reserves, validates, and commits one pane relocation. All
27+// fallible work happens before the membership publication section. Once the
28+// section starts, the caller holds the ordered architecture locks and only
29+// non-failing in-memory writes are permitted.
30+func (d *Daemon) movePane(req movePaneRequest) (result error) {
31+ defer func() { result = normalizeMoveRejection(result) }()
32+ if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||
33+ req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
34+ return errMovePaneInvalid
35+ }
36+
37+ d.mu.Lock()
38+ source := moveSessionForLocatorLocked(d, req.Source)
39+ destination := moveSessionForLocatorLocked(d, req.Destination)
40+ d.mu.Unlock()
41+ if source == nil || destination == nil {
42+ return errMoveStaleTarget
43+ }
44+
45+ reservation, err := d.reserveMoveLifecycles(source, destination)
46+ if err != nil {
47+ return errMoveStaleTarget
48+ }
49+ reservationHeld := true
50+ defer func() {
51+ if reservationHeld {
52+ reservation.Release()
53+ }
54+ }()
55+ if d.afterMoveLifecycleReserved != nil {
56+ d.afterMoveLifecycleReserved()
57+ }
58+
59+ unlockDispatch := lockMoveDispatch(source, destination)
60+ dispatchHeld := true
61+ defer func() {
62+ if dispatchHeld {
63+ unlockDispatch()
64+ }
65+ }()
66+
67+ // Snapshot the exact live objects and generations before waiting on resize
68+ // fences. The locked commit repeats every authority check before publication.
69+ admission, err := d.snapshotMovePaneAdmission(req, source, destination)
70+ if err != nil {
71+ return err
72+ }
73+ sourceTab := admission.sourceTab
74+ destinationTab := admission.destinationTab
75+ movedPane := admission.movedPane
76+ sourceClient := admission.sourceClient
77+ destinationClient := admission.destinationClient
78+ sourceSnatched := admission.sourceSnatched
79+ sourceTabWasActive := admission.sourceTabWasActive
80+ sourceTabInitialGeneration := admission.sourceGeneration
81+ destinationTabInitialGeneration := admission.destinationGeneration
82+ finalSourceTab := admission.finalSourceTab
83+ if d.afterMovePaneSourceSnapshot != nil {
84+ d.afterMovePaneSourceSnapshot()
85+ }
86+
87+ // A final source tab with an active client needs the centralized attachment
88+ // transition. Freeze that exact role set before entering the fence callback;
89+ // the callback then validates the handoff before changing any topology.
90+ var handoffReq attachmentTransitionRequest
91+ var handoffParticipants attachmentTransitionParticipants
92+ var frozen frozenRoleEffectGates
93+ var sourceRolesFrozen bool
94+ var handoffFrozen bool
95+
96+ if finalSourceTab && source != destination && sourceClient != nil {
97+ transport := sourceClient.transportSnapshot()
98+ sourceToken := source.attachmentToken(sourceClient, transport.transport)
99+ if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil {
100+ return errMovePaneInvalid
101+ }
102+ destinationTabIndex := moveTabIndex(destination, destinationTab)
103+ if destinationTabIndex < 0 {
104+ return errMovePaneInvalid
105+ }
106+ handoffReq = attachmentTransitionRequest{
107+ source: source, target: destination, next: sourceClient,
108+ expectedRole: attachmentActive, targetRole: attachmentActive,
109+ expectedTransport: sourceToken.transport, sourceToken: &sourceToken,
110+ expectedSourceTab: sourceTab, activateTargetTab: true,
111+ targetTabIndex: destinationTabIndex, ready: true,
112+ }
113+ var err error
114+ handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)
115+ if err != nil {
116+ return err
117+ }
118+ // Retiring source snatched clients is part of the same role gate set as
119+ // the follower. Their exact membership is checked again in the commit.
120+ d.mu.Lock()
121+ source.mu.Lock()
122+ for _, ac := range sourceSnatched {
123+ handoffParticipants.clients = append(handoffParticipants.clients, ac)
124+ tr := ac.transportSnapshot()
125+ if tr.transport != nil {
126+ handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
127+ }
128+ }
129+ source.mu.Unlock()
130+ d.mu.Unlock()
131+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
132+ if d.afterRoleEffectGateFrozen != nil {
133+ d.afterRoleEffectGateFrozen("move-pane", ac)
134+ }
135+ }}, handoffParticipants.clients...)
136+ if !frozen.acquired || !frozen.drained {
137+ frozen.unfreeze()
138+ return errMovePaneInvalid
139+ }
140+ handoffFrozen = true
141+ sourceRolesFrozen = true
142+ handoffReq.roleEffectsFrozen = true
143+ } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 {
144+ // A headless source may still have persistent snatched clients. Freeze
145+ // their exact role gates before retiring the source registry.
146+ d.mu.Lock()
147+ source.mu.Lock()
148+ participants := append([]*attachedClient(nil), sourceSnatched...)
149+ interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched))
150+ for _, ac := range sourceSnatched {
151+ if transport := ac.transportSnapshot(); transport.transport != nil {
152+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
153+ }
154+ }
155+ source.mu.Unlock()
156+ d.mu.Unlock()
157+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
158+ if d.afterRoleEffectGateFrozen != nil {
159+ d.afterRoleEffectGateFrozen("move-pane", ac)
160+ }
161+ }}, participants...)
162+ if !frozen.acquired || !frozen.drained {
163+ frozen.unfreeze()
164+ return errMovePaneInvalid
165+ }
166+ sourceRolesFrozen = true
167+ }
168+ if sourceRolesFrozen {
169+ defer func() {
170+ if sourceRolesFrozen {
171+ frozen.unfreeze()
172+ }
173+ }()
174+ }
175+
176+ commit := movePaneCommit{
177+ req: req,
178+ source: source,
179+ destination: destination,
180+ sourceTab: sourceTab,
181+ destinationTab: destinationTab,
182+ movedPane: movedPane,
183+ sourceClient: sourceClient,
184+ sourceSnatched: sourceSnatched,
185+ sourceGeneration: sourceTabInitialGeneration,
186+ destinationGeneration: destinationTabInitialGeneration,
187+ handoffFrozen: handoffFrozen,
188+ frozenRoles: frozen,
189+ handoffReq: handoffReq,
190+ }
191+ fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
192+ if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
193+ commit.releasePublication()
194+ if commit.err != nil {
195+ return commit.err
196+ }
197+ return errMoveStaleTarget
198+ }
199+ // Owner/layout fences end at the atomic publication boundary. The immutable
200+ // postcommit plan owns every later cleanup and effect.
201+ fences.Release()
202+ postcommit := movePanePostcommitPlan{
203+ source: source,
204+ destination: destination,
205+ sourceTab: sourceTab,
206+ destinationTab: destinationTab,
207+ movedPane: movedPane,
208+ destinationClient: destinationClient,
209+ sourceCleanupToken: commit.sourceCleanupToken,
210+ handoffResult: commit.handoffResult,
211+ syncCleanup: commit.syncCleanup,
212+ frozenRoles: frozen,
213+ rolesFrozen: sourceRolesFrozen,
214+ unlockDispatch: unlockDispatch,
215+ reservation: reservation,
216+ sourceTabWasActive: sourceTabWasActive,
217+ sourceTabRemoved: commit.sourceTabRemoved,
218+ sourceEmpty: commit.sourceEmpty,
219+ retiredParked: commit.retiredParked,
220+ retiredAttachments: commit.retiredAttachments,
221+ sourceMetadata: commit.sourceMetadata,
222+ sourceMetadataValid: commit.sourceMetadataValid,
223+ destinationMetadata: commit.destinationMetadata,
224+ destinationMetadataValid: commit.destinationMetadataValid,
225+ }
226+ // Transfer cleanup ownership away from the admission defers only after the
227+ // complete postcommit plan exists.
228+ sourceRolesFrozen = false
229+ dispatchHeld = false
230+ reservationHeld = false
231+ postcommit.execute(d)
232+ return nil
233+}
234+
235+// moveSessionForLocatorLocked resolves only live sessions. The name is
236+// intentionally ignored after the stable ID/incarnation pair is checked.
237+func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session {
238+ if d == nil {
239+ return nil
240+ }
241+ sess := d.sessions[locator.ID]
242+ if sess == nil || sess.incarnation != locator.Incarnation {
243+ return nil
244+ }
245+ return sess
246+}
247+
248+func moveSessionLocatorCurrentLocked(sess *session, locator moveSessionLocator) bool {
249+ return sess != nil && sess.id == locator.ID && sess.incarnation == locator.Incarnation
250+}
251+
252+func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab {
253+ if sess == nil {
254+ return nil
255+ }
256+ for _, tb := range sess.tabs {
257+ if tb != nil && tb.stableID == string(stableID) {
258+ return tb
259+ }
260+ }
261+ return nil
262+}
263+
264+func moveTabIndex(sess *session, target *tab) int {
265+ if sess == nil || target == nil {
266+ return -1
267+ }
268+ sess.mu.Lock()
269+ defer sess.mu.Unlock()
270+ return indexMoveTabLocked(sess, target)
271+}
272+
273+func indexMoveTabLocked(sess *session, target *tab) int {
274+ for i, tb := range sess.tabs {
275+ if tb == target {
276+ return i
277+ }
278+ }
279+ return -1
280+}
281+
282+func moveTabMemberLocked(sess *session, target *tab) bool {
283+ return indexMoveTabLocked(sess, target) >= 0
284+}
285+
286+func lockMoveTabs(a, b *tab) func() {
287+ if a == nil && b == nil {
288+ return func() {}
289+ }
290+ if a == nil {
291+ b.mu.Lock()
292+ return b.mu.Unlock
293+ }
294+ if b == nil {
295+ a.mu.Lock()
296+ return a.mu.Unlock
297+ }
298+ if a == b {
299+ a.mu.Lock()
300+ return a.mu.Unlock
301+ }
302+ first, second := a, b
303+ if first.stableID > second.stableID ||
304+ (first.stableID == second.stableID && reflect.ValueOf(first).Pointer() > reflect.ValueOf(second).Pointer()) {
305+ first, second = second, first
306+ }
307+ first.mu.Lock()
308+ second.mu.Lock()
309+ return func() {
310+ second.mu.Unlock()
311+ first.mu.Unlock()
312+ }
313+}
314+
315+func lockMoveDispatch(a, b *session) func() {
316+ if a == b {
317+ a.dispatchMu.Lock()
318+ return a.dispatchMu.Unlock
319+ }
320+ first, second := a, b
321+ if first.id > second.id {
322+ first, second = second, first
323+ }
324+ first.dispatchMu.Lock()
325+ second.dispatchMu.Lock()
326+ return func() {
327+ second.dispatchMu.Unlock()
328+ first.dispatchMu.Unlock()
329+ }
330+}
A internal/usecase/daemon/move_acceptance_test.go +278 -0
@@ -0,0 +1,278 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
14+ names := make([]string, 0, len(updates))
15+ for _, update := range updates {
16+ names = append(names, update.Name)
17+ }
18+ return names
19+}
20+
21+type moveCatalogueRecorder struct {
22+ daemon *Daemon
23+ source *session
24+ destination *session
25+ mu sync.Mutex
26+ updates []domain.CatalogueMetadataUpdate
27+ lockViolation bool
28+}
29+
30+func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
31+func (c *moveCatalogueRecorder) Record(string) (domain.CatalogueRecord, bool, error) {
32+ return domain.CatalogueRecord{}, false, nil
33+}
34+func (c *moveCatalogueRecorder) Create(domain.CatalogueRecord) error { return nil }
35+func (c *moveCatalogueRecorder) Replace(string, domain.CatalogueRecord) error { return nil }
36+func (c *moveCatalogueRecorder) Rename(string, domain.CatalogueRecord) error { return nil }
37+func (c *moveCatalogueRecorder) Delete(string) error { return nil }
38+func (c *moveCatalogueRecorder) Sync() error { return nil }
39+func (c *moveCatalogueRecorder) Close() error { return nil }
40+func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
41+ violated := false
42+ if c.daemon.mu.TryLock() {
43+ c.daemon.mu.Unlock()
44+ } else {
45+ violated = true
46+ }
47+ if c.source.mu.TryLock() {
48+ c.source.mu.Unlock()
49+ } else {
50+ violated = true
51+ }
52+ if c.destination.mu.TryLock() {
53+ c.destination.mu.Unlock()
54+ } else {
55+ violated = true
56+ }
57+ c.mu.Lock()
58+ c.updates = append(c.updates, update)
59+ c.lockViolation = c.lockViolation || violated
60+ c.mu.Unlock()
61+ return nil
62+}
63+
64+func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T) {
65+ movedPTY, releaseMoved := newBlockingPTY(t)
66+ remainingPTY, releaseRemaining := newBlockingPTY(t)
67+ destinationPTY, releaseDestination := newBlockingPTY(t)
68+ activePTY, releaseActive := newBlockingPTY(t)
69+ defer releaseMoved()
70+ defer releaseRemaining()
71+ defer releaseDestination()
72+ defer releaseActive()
73+
74+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
75+ sourceTab := source.tabs[0]
76+ sourceTab.stableID = "source-tab"
77+ moved := sourceTab.focusedPane()
78+ remaining := newPaneWithStableID("pane-2", "remaining-pane", remainingPTY, domain.Size{Cols: 80, Rows: 23})
79+ sourceTab.mu.Lock()
80+ sourceTab.tree = &layout.Tree{
81+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
82+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
83+ }},
84+ Focus: "pane-1",
85+ }
86+ sourceTab.panes[remaining.id] = remaining
87+ sourceTab.mu.Unlock()
88+ publishPaneOwner(remaining, source, sourceTab, 0)
89+
90+ destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
91+ destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
92+ destination := &session{
93+ id: "destination", name: "destination", ephemeral: true,
94+ tabs: []*tab{destinationActive, destinationTab}, active: 0,
95+ }
96+ publishTiledPaneOwners(destination, destinationActive)
97+ publishTiledPaneOwners(destination, destinationTab)
98+ d.mu.Lock()
99+ d.sessions[destination.id] = destination
100+ d.mu.Unlock()
101+ source.ephemeral = false
102+ destination.ephemeral = false
103+ source.snapEligible.Store(true)
104+ destination.snapEligible.Store(true)
105+ catalogue := &moveCatalogueRecorder{daemon: d, source: source, destination: destination}
106+ d.catalogue = catalogue
107+ d.persistEnabled = true
108+ source.snapshotMu.Lock()
109+ beforeSourceSnapshot := source.snapshotGeneration
110+ source.snapshotMu.Unlock()
111+ destination.snapshotMu.Lock()
112+ beforeDestinationSnapshot := destination.snapshotGeneration
113+ destination.snapshotMu.Unlock()
114+
115+ sourceTab.mu.Lock()
116+ beforeSourceGeneration := sourceTab.layoutGeneration
117+ sourceTab.mu.Unlock()
118+ destinationTab.mu.Lock()
119+ beforeDestinationGeneration := destinationTab.layoutGeneration
120+ destinationTab.mu.Unlock()
121+ moved.mu.Lock()
122+ beforeOwnerGeneration := moved.ownerGeneration
123+ moved.mu.Unlock()
124+
125+ require.NoError(t, d.movePane(movePaneRequest{
126+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
127+ SourceTabID: domain.TabStableID(sourceTab.stableID),
128+ SourcePaneID: domain.PaneStableID(moved.stableID),
129+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
130+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
131+ }))
132+
133+ source.mu.Lock()
134+ sourceTabs := append([]*tab(nil), source.tabs...)
135+ source.mu.Unlock()
136+ require.Len(t, sourceTabs, 1)
137+ require.Same(t, sourceTab, sourceTabs[0])
138+ sourceTab.mu.Lock()
139+ remainingPane := sourceTab.panes[remaining.id]
140+ sourceFocus := sourceTab.tree.Focus
141+ sourceGeneration := sourceTab.layoutGeneration
142+ sourceTab.mu.Unlock()
143+ require.Same(t, remaining, remainingPane)
144+ require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf")
145+ destination.mu.Lock()
146+ destinationActiveTab := destination.tabs[destination.active]
147+ destination.mu.Unlock()
148+ require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged")
149+ destinationTab.mu.Lock()
150+ destinationMovedPane := destinationTab.panes[moved.id]
151+ destinationFocus := destinationTab.tree.Focus
152+ destinationGeneration := destinationTab.layoutGeneration
153+ destinationTab.mu.Unlock()
154+ require.Same(t, moved, destinationMovedPane)
155+ require.Equal(t, destinationFocus, moved.id)
156+ moved.mu.Lock()
157+ ownerGeneration := moved.ownerGeneration
158+ moved.mu.Unlock()
159+ require.Equal(t, beforeOwnerGeneration+1, ownerGeneration, "move publishes exactly one new owner generation")
160+ require.Equal(t, beforeSourceGeneration+1, sourceGeneration)
161+ require.Equal(t, beforeDestinationGeneration+1, destinationGeneration)
162+ source.snapshotMu.Lock()
163+ require.GreaterOrEqual(t, source.snapshotGeneration, beforeSourceSnapshot+1)
164+ require.True(t, source.snapDirty.Load())
165+ source.snapshotMu.Unlock()
166+ destination.snapshotMu.Lock()
167+ require.GreaterOrEqual(t, destination.snapshotGeneration, beforeDestinationSnapshot+1)
168+ require.True(t, destination.snapDirty.Load())
169+ destination.snapshotMu.Unlock()
170+ catalogue.mu.Lock()
171+ updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
172+ lockViolation := catalogue.lockViolation
173+ catalogue.mu.Unlock()
174+ require.False(t, lockViolation, "catalogue publication ran under an architecture lock")
175+ require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
176+}
177+
178+func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
179+ movedPTY, releaseMoved := newBlockingPTY(t)
180+ destinationPTY, releaseDestination := newBlockingPTY(t)
181+ defer releaseMoved()
182+ defer releaseDestination()
183+
184+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
185+ sourceTab := source.tabs[0]
186+ sourceTab.stableID = "source-tab"
187+ moved := sourceTab.focusedPane()
188+ destination := &session{
189+ id: "destination", name: "destination", ephemeral: true,
190+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
191+ }
192+ destinationTab := destination.tabs[0]
193+ publishTiledPaneOwners(destination, destinationTab)
194+ d.mu.Lock()
195+ d.sessions[destination.id] = destination
196+ d.mu.Unlock()
197+
198+ sourceTab.mu.Lock()
199+ beforeSource := sourceTab.tree.Clone()
200+ sourceTab.mu.Unlock()
201+ destinationTab.mu.Lock()
202+ beforeDestination := destinationTab.tree.Clone()
203+ destinationTab.mu.Unlock()
204+ beforeOwner := moved.ownerSnapshot()
205+ staleIncarnation := source.incarnation
206+ staleIncarnation[0]++
207+ err := d.movePane(movePaneRequest{
208+ Source: moveSessionLocator{ID: source.id, Incarnation: staleIncarnation},
209+ SourceTabID: domain.TabStableID(sourceTab.stableID),
210+ SourcePaneID: domain.PaneStableID(moved.stableID),
211+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
212+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
213+ })
214+ require.ErrorIs(t, err, errMovePaneInvalid)
215+ sourceTab.mu.Lock()
216+ afterSource := sourceTab.tree.Clone()
217+ sourceMovedPane := sourceTab.panes[moved.id]
218+ sourceTab.mu.Unlock()
219+ destinationTab.mu.Lock()
220+ afterDestination := destinationTab.tree.Clone()
221+ destinationMovedPane := destinationTab.panes[moved.id]
222+ destinationTab.mu.Unlock()
223+ require.Equal(t, beforeSource, afterSource)
224+ require.Equal(t, beforeDestination, afterDestination)
225+ require.Same(t, beforeOwner, moved.ownerSnapshot())
226+ require.Same(t, moved, sourceMovedPane)
227+ require.NotSame(t, moved, destinationMovedPane)
228+}
229+
230+func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
231+ movedPTY, releaseMoved := newBlockingPTY(t)
232+ destinationPTY, releaseDestination := newBlockingPTY(t)
233+ defer releaseMoved()
234+ defer releaseDestination()
235+
236+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
237+ sourceTab := source.tabs[0]
238+ sourceTab.stableID = "source-tab"
239+ moved := sourceTab.focusedPane()
240+ destination := &session{
241+ id: "destination", name: "destination", ephemeral: true,
242+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
243+ }
244+ destinationTab := destination.tabs[0]
245+ publishTiledPaneOwners(destination, destinationTab)
246+ d.mu.Lock()
247+ d.sessions[destination.id] = destination
248+ d.mu.Unlock()
249+
250+ destinationTab.mu.Lock()
251+ beforeDestination := destinationTab.tree.Clone()
252+ destinationTab.mu.Unlock()
253+ beforeOwner := moved.ownerSnapshot()
254+ d.afterMovePaneSourceSnapshot = func() {
255+ sourceTab.mu.Lock()
256+ sourceTab.layoutGeneration++
257+ sourceTab.mu.Unlock()
258+ }
259+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
260+
261+ err := d.movePane(movePaneRequest{
262+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
263+ SourceTabID: domain.TabStableID(sourceTab.stableID),
264+ SourcePaneID: domain.PaneStableID(moved.stableID),
265+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
266+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
267+ })
268+ require.Error(t, err)
269+ destinationTab.mu.Lock()
270+ afterDestination := destinationTab.tree.Clone()
271+ destinationTab.mu.Unlock()
272+ sourceTab.mu.Lock()
273+ sourceMovedPane := sourceTab.panes[moved.id]
274+ sourceTab.mu.Unlock()
275+ require.Equal(t, beforeDestination, afterDestination)
276+ require.Same(t, beforeOwner, moved.ownerSnapshot())
277+ require.Same(t, moved, sourceMovedPane)
278+}
A internal/usecase/daemon/move_atomicity_test.go +185 -0
@@ -0,0 +1,185 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+type moveCommitObservation struct {
13+ sourceRegistered bool
14+ sourceTabMember bool
15+ sourcePaneMember bool
16+ destinationTabMember bool
17+ destinationPaneMember bool
18+ sourceActive *tab
19+ destinationActive *tab
20+ sourceFocus layout.PaneID
21+ destinationFocus layout.PaneID
22+ ownerSession *session
23+ ownerTab *tab
24+ sourceRole attachmentRole
25+ destinationRole attachmentRole
26+ sourceClientSession *session
27+}
28+
29+// readMoveCommitObservation follows the same architecture lock order as the
30+// move commit. Every field is therefore one supported reader's coherent view,
31+// rather than a collection of lock-free observations that could straddle a
32+// publication boundary.
33+func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, sourceClient *attachedClient) moveCommitObservation {
34+ d.mu.Lock()
35+ d.notices.routingMu.Lock()
36+ unlockSessions := lockAttachmentSessions(source, destination)
37+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
38+ movedPane.mu.Lock()
39+
40+ owner := movedPane.ownerSnapshot()
41+ observation := moveCommitObservation{
42+ sourceRegistered: d.sessions[source.id] == source,
43+ sourceTabMember: moveTabMemberLocked(source, sourceTab),
44+ sourcePaneMember: sourceTab.panes[movedPane.id] == movedPane,
45+ destinationTabMember: moveTabMemberLocked(destination, destinationTab),
46+ destinationPaneMember: destinationTab.panes[movedPane.id] == movedPane,
47+ sourceActive: activeMoveTabLocked(source),
48+ destinationActive: activeMoveTabLocked(destination),
49+ sourceFocus: sourceTab.tree.Focus,
50+ destinationFocus: destinationTab.tree.Focus,
51+ ownerSession: nil,
52+ ownerTab: nil,
53+ sourceRole: source.attachmentRoleLocked(sourceClient),
54+ destinationRole: destination.attachmentRoleLocked(sourceClient),
55+ sourceClientSession: sourceClient.currentSession(),
56+ }
57+ if owner != nil {
58+ observation.ownerSession = owner.session
59+ observation.ownerTab = owner.tab
60+ }
61+ movedPane.mu.Unlock()
62+ unlockTabs()
63+ unlockSessions()
64+ d.notices.routingMu.Unlock()
65+ d.mu.Unlock()
66+ return observation
67+}
68+
69+func activeMoveTabLocked(sess *session) *tab {
70+ if sess == nil || sess.active < 0 || sess.active >= len(sess.tabs) {
71+ return nil
72+ }
73+ return sess.tabs[sess.active]
74+}
75+
76+func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
77+ p1, release1 := newBlockingPTY(t)
78+ p2, release2 := newBlockingPTY(t)
79+ defer release1()
80+ defer release2()
81+
82+ d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
83+ sourceTab := source.tabs[0]
84+ sourceTab.stableID = "source-tab"
85+ movedPane := sourceTab.focusedPane()
86+
87+ destination := &session{
88+ id: "destination",
89+ name: "destination",
90+ ephemeral: true,
91+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
92+ active: 0,
93+ }
94+ destinationTab := destination.tabs[0]
95+ publishTiledPaneOwners(destination, destinationTab)
96+ d.mu.Lock()
97+ d.sessions[destination.id] = destination
98+ d.mu.Unlock()
99+ require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
100+
101+ commitEntered := make(chan struct{})
102+ releaseCommit := make(chan struct{})
103+ d.beforeMovePaneCommit = func() {
104+ close(commitEntered)
105+ <-releaseCommit
106+ }
107+
108+ moveDone := make(chan error, 1)
109+ go func() {
110+ moveDone <- d.movePane(movePaneRequest{
111+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
112+ SourceTabID: domain.TabStableID(sourceTab.stableID),
113+ SourcePaneID: domain.PaneStableID(movedPane.stableID),
114+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
115+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
116+ })
117+ }()
118+ <-commitEntered
119+
120+ type reader struct {
121+ name string
122+ read func(moveCommitObservation) bool
123+ }
124+ readers := []reader{
125+ {name: "source membership", read: func(o moveCommitObservation) bool {
126+ return o.sourceRegistered || o.sourceTabMember || o.sourcePaneMember
127+ }},
128+ {name: "destination membership", read: func(o moveCommitObservation) bool { return o.destinationTabMember && !o.destinationPaneMember }},
129+ {name: "owner", read: func(o moveCommitObservation) bool { return o.ownerSession == source && o.ownerTab == sourceTab }},
130+ {name: "active tab and focus", read: func(o moveCommitObservation) bool {
131+ return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")
132+ }},
133+ {name: "attachment roles", read: func(o moveCommitObservation) bool {
134+ return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source
135+ }},
136+ }
137+ started := make(chan string, len(readers))
138+ done := make(chan struct {
139+ name string
140+ observation moveCommitObservation
141+ }, len(readers))
142+ for _, current := range readers {
143+ go func(current reader) {
144+ started <- current.name
145+ observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
146+ done <- struct {
147+ name string
148+ observation moveCommitObservation
149+ }{name: current.name, observation: observation}
150+ }(current)
151+ }
152+ for range readers {
153+ <-started
154+ }
155+ for range readers {
156+ select {
157+ case result := <-done:
158+ t.Fatalf("%s reader completed while the move commit was paused: %#v", result.name, result.observation)
159+ default:
160+ }
161+ }
162+
163+ close(releaseCommit)
164+ require.NoError(t, <-moveDone)
165+ for _, current := range readers {
166+ result := <-done
167+ require.False(t, current.read(result.observation), "%s observed partial move state: %#v", current.name, result.observation)
168+ }
169+
170+ observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
171+ require.False(t, observation.sourceRegistered)
172+ require.False(t, observation.sourceTabMember)
173+ require.False(t, observation.sourcePaneMember)
174+ require.True(t, observation.destinationTabMember)
175+ require.True(t, observation.destinationPaneMember)
176+ require.Nil(t, observation.sourceActive)
177+ require.Equal(t, layout.PaneID("pane-1"), observation.sourceFocus)
178+ require.Same(t, destinationTab, observation.destinationActive)
179+ require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)
180+ require.Same(t, destination, observation.ownerSession)
181+ require.Same(t, destinationTab, observation.ownerTab)
182+ require.Equal(t, attachmentDetached, observation.sourceRole)
183+ require.Equal(t, attachmentActive, observation.destinationRole)
184+ require.Same(t, destination, observation.sourceClientSession)
185+}
A internal/usecase/daemon/move_attachments.go +46 -0
@@ -0,0 +1,46 @@
1+package daemon
2+
3+// frozenMoveAttachmentRetirement is proof that every source snatched client
4+// retired by a final move has had its exact role-effect gate frozen and drained.
5+// It is prepared while the source session lock is held, before topology writes.
6+type frozenMoveAttachmentRetirement struct {
7+ clients []*attachedClient
8+}
9+
10+func snapshotMoveSnatchedLocked(sess *session) []*attachedClient {
11+ clients := make([]*attachedClient, 0, len(sess.snatched))
12+ for ac := range sess.snatched {
13+ clients = append(clients, ac)
14+ }
15+ return clients
16+}
17+
18+func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {
19+ if sess == nil || len(sess.snatched) != len(admitted) {
20+ return false
21+ }
22+ for _, ac := range admitted {
23+ if _, ok := sess.snatched[ac]; !ok {
24+ return false
25+ }
26+ }
27+ return true
28+}
29+
30+func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {
31+ if !sameMoveSnatchedLocked(sess, admitted) {
32+ return frozenMoveAttachmentRetirement{}, false
33+ }
34+ if len(admitted) == 0 {
35+ return frozenMoveAttachmentRetirement{}, true
36+ }
37+ if !frozen.acquired || !frozen.drained {
38+ return frozenMoveAttachmentRetirement{}, false
39+ }
40+ for _, ac := range admitted {
41+ if !frozen.contains(ac) {
42+ return frozenMoveAttachmentRetirement{}, false
43+ }
44+ }
45+ return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true
46+}
A internal/usecase/daemon/move_cleanup_test.go +193 -0
@@ -0,0 +1,193 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+)
11+
12+func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
13+ movedPTY, releaseMoved := newBlockingPTY(t)
14+ remainingPTY, releaseRemaining := newBlockingPTY(t)
15+ destinationPTY, releaseDestination := newBlockingPTY(t)
16+ defer releaseMoved()
17+ defer releaseRemaining()
18+ defer releaseDestination()
19+
20+ d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
22+ sourceTab := source.tabs[0]
23+ sourceTab.stableID = "source-tab"
24+ moved := sourceTab.focusedPane()
25+ client.sendMu.Lock()
26+ client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
27+ client.sendMu.Unlock()
28+
29+ destination := &session{
30+ id: "destination", name: "destination", ephemeral: true,
31+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
32+ active: 0,
33+ }
34+ destinationTab := destination.tabs[0]
35+ publishTiledPaneOwners(destination, destinationTab)
36+ d.mu.Lock()
37+ d.sessions[destination.id] = destination
38+ d.mu.Unlock()
39+
40+ moved.mu.Lock()
41+ owner := moved.effectLeaseLocked()
42+ moved.mu.Unlock()
43+ members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}}
44+ rc := source.renderCoordinator()
45+ require.NotNil(t, rc)
46+ lease := rc.attachmentLease(client)
47+ epoch := rc.recordResizeRequestForLease(client.size, client, lease)
48+ ticket, admitted := beginActiveLeaseEffect(source, client, lease)
49+ require.True(t, admitted)
50+ defer ticket.End()
51+
52+ sendLocked := make(chan struct{})
53+ allowResizeFence := make(chan struct{})
54+ d.afterResizeCommitSendLocked = func() {
55+ close(sendLocked)
56+ <-allowResizeFence
57+ }
58+ defer func() { d.afterResizeCommitSendLocked = nil }()
59+ resizeDone := make(chan bool, 1)
60+ go func() {
61+ resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size)
62+ }()
63+ awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu")
64+
65+ insideMoveCommit := make(chan struct{})
66+ allowMoveCommit := make(chan struct{})
67+ d.beforeMovePaneCommit = func() {
68+ close(insideMoveCommit)
69+ <-allowMoveCommit
70+ }
71+ defer func() { d.beforeMovePaneCommit = nil }()
72+ moveDone := make(chan error, 1)
73+ go func() {
74+ moveDone <- d.movePane(movePaneRequest{
75+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
76+ SourceTabID: domain.TabStableID(sourceTab.stableID),
77+ SourcePaneID: domain.PaneStableID(moved.stableID),
78+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
79+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
80+ })
81+ }()
82+ awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section")
83+
84+ // Resize now owns sendMu and waits for the moved pane fence. The move may
85+ // finish only if it releases every resize fence before capture cleanup tries
86+ // to acquire sendMu.
87+ close(allowResizeFence)
88+ close(allowMoveCommit)
89+ require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit"))
90+ require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))
91+}
92+
93+func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
94+ movedPTY, releaseMoved := newBlockingPTY(t)
95+ remainingPTY, releaseRemaining := newBlockingPTY(t)
96+ destinationPTY, releaseDestination := newBlockingPTY(t)
97+ defer releaseMoved()
98+ defer releaseRemaining()
99+ defer releaseDestination()
100+
101+ d, source, displaced, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
102+ sourceTab := source.tabs[0]
103+ remainingTab := source.tabs[1]
104+ sourceTab.stableID = "source-tab"
105+ remainingTab.stableID = "remaining-tab"
106+ moved := sourceTab.focusedPane()
107+ require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
108+
109+ destination := &session{
110+ id: "destination", name: "destination", ephemeral: true,
111+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
112+ active: 0,
113+ }
114+ destinationTab := destination.tabs[0]
115+ publishTiledPaneOwners(destination, destinationTab)
116+ d.mu.Lock()
117+ d.sessions[destination.id] = destination
118+ d.mu.Unlock()
119+
120+ // Both clients carry state tied specifically to the pane. The displaced
121+ // client was captured before the fence wait; the replacement is the only
122+ // attachment that may be cleaned after commit.
123+ displaced.overlays.copyMu.Lock()
124+ displaced.overlays.copyPane = moved
125+ displaced.overlays.copyMode = &scopy.Mode{}
126+ displaced.overlays.copyMu.Unlock()
127+ displaced.sendMu.Lock()
128+ displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
129+ displaced.sendMu.Unlock()
130+
131+ replacementTransport, _ := newCapturingTransport(t)
132+ replacement := &attachedClient{
133+ tr: replacementTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
134+ }
135+ replacement.initOverlays()
136+ replacement.setSession(source)
137+ replacement.overlays.copyMu.Lock()
138+ replacement.overlays.copyPane = moved
139+ replacement.overlays.copyMode = &scopy.Mode{}
140+ replacement.overlays.copyMu.Unlock()
141+ replacement.sendMu.Lock()
142+ replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
143+ replacement.sendMu.Unlock()
144+
145+ // Keep the source layout fence occupied while the move has captured its
146+ // pre-fence source client. Replacing the client at this barrier reproduces
147+ // the stale cleanup race without sleeps.
148+ source.layoutApplyMu.Lock()
149+ snapshot := make(chan struct{})
150+ releaseSnapshot := make(chan struct{})
151+ d.afterMovePaneSourceSnapshot = func() {
152+ close(snapshot)
153+ <-releaseSnapshot
154+ }
155+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
156+
157+ moveDone := make(chan error, 1)
158+ go func() {
159+ moveDone <- d.movePane(movePaneRequest{
160+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
161+ SourceTabID: domain.TabStableID(sourceTab.stableID),
162+ SourcePaneID: domain.PaneStableID(moved.stableID),
163+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
164+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
165+ })
166+ }()
167+ awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
168+
169+ source.mu.Lock()
170+ source.client = replacement
171+ source.mu.Unlock()
172+ close(releaseSnapshot)
173+ source.layoutApplyMu.Unlock()
174+ require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
175+
176+ displaced.overlays.copyMu.Lock()
177+ require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")
178+ require.Same(t, moved, displaced.overlays.copyPane)
179+ displaced.overlays.copyMu.Unlock()
180+ displaced.sendMu.Lock()
181+ _, displacedCaptured := displaced.captureFrames[moved]
182+ displaced.sendMu.Unlock()
183+ require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned")
184+
185+ replacement.overlays.copyMu.Lock()
186+ require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")
187+ require.Nil(t, replacement.overlays.copyPane)
188+ replacement.overlays.copyMu.Unlock()
189+ replacement.sendMu.Lock()
190+ _, replacementCaptured := replacement.captureFrames[moved]
191+ replacement.sendMu.Unlock()
192+ require.False(t, replacementCaptured, "current source capture was not pruned")
193+}
A internal/usecase/daemon/move_final_source_attachment_race_test.go +211 -0
@@ -0,0 +1,211 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+type finalMoveAttachmentMutation int
12+
13+const (
14+ installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota
15+ installSnatchedAfterFinalMoveAdmission
16+ replaceSnatchedAfterFinalMoveAdmission
17+)
18+
19+func newFinalMoveRaceClient(sess *session) *attachedClient {
20+ ac := &attachedClient{
21+ tr: &closeTrackingTransport{},
22+ output: newOutputStateStream(),
23+ size: domain.Size{Cols: 80, Rows: 24},
24+ }
25+ ac.initOverlays()
26+ ac.setSession(sess)
27+ return ac
28+}
29+
30+func makeFinalMoveSourceHeadless(source *session, original *attachedClient) {
31+ source.mu.Lock()
32+ source.client = nil
33+ source.mu.Unlock()
34+ original.setSession(nil)
35+}
36+
37+func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) {
38+ current = newFinalMoveRaceClient(source)
39+ source.mu.Lock()
40+ switch mutation {
41+ case installActiveAfterFinalMoveAdmission:
42+ source.client = current
43+ case installSnatchedAfterFinalMoveAdmission:
44+ source.addSnatchedLocked(current)
45+ case replaceSnatchedAfterFinalMoveAdmission:
46+ for ac := range source.snatched {
47+ replaced = ac
48+ delete(source.snatched, ac)
49+ ac.setSession(nil)
50+ break
51+ }
52+ source.addSnatchedLocked(current)
53+ }
54+ source.mu.Unlock()
55+ return current, replaced
56+}
57+
58+func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) {
59+ t.Helper()
60+ require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned")
61+ switch mutation {
62+ case installActiveAfterFinalMoveAdmission:
63+ require.Equal(t, attachmentActive, source.attachmentRole(current))
64+ case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission:
65+ require.Equal(t, attachmentSnatched, source.attachmentRole(current))
66+ }
67+ if replaced != nil {
68+ require.Nil(t, replaced.currentSession())
69+ require.Equal(t, attachmentDetached, source.attachmentRole(replaced))
70+ }
71+}
72+
73+func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) {
74+ for _, tc := range []struct {
75+ name string
76+ mutation finalMoveAttachmentMutation
77+ existing bool
78+ }{
79+ {name: "new active", mutation: installActiveAfterFinalMoveAdmission},
80+ {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission},
81+ {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true},
82+ } {
83+ t.Run(tc.name, func(t *testing.T) {
84+ d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t)
85+ defer release()
86+ original := source.client
87+ makeFinalMoveSourceHeadless(source, original)
88+ if tc.existing {
89+ existing := newFinalMoveRaceClient(source)
90+ source.mu.Lock()
91+ source.addSnatchedLocked(existing)
92+ source.mu.Unlock()
93+ }
94+
95+ sourceGeneration := sourceTab.layoutGeneration
96+ destinationGeneration := destinationTab.layoutGeneration
97+ destinationPaneAtMovedID := destinationTab.panes[moved.id]
98+ owner := moved.ownerSnapshot()
99+ barrier := make(chan struct{})
100+ releaseMove := make(chan struct{})
101+ d.afterMovePaneSourceSnapshot = func() {
102+ close(barrier)
103+ <-releaseMove
104+ }
105+ defer func() { d.afterMovePaneSourceSnapshot = nil }()
106+
107+ moveDone := make(chan error, 1)
108+ go func() {
109+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
110+ }()
111+ waitMoveRace(t, barrier, "move pane admission")
112+ current, replaced := mutateFinalMoveAttachments(source, tc.mutation)
113+ currentGeneration := current.roleGeneration.Load()
114+ close(releaseMove)
115+ err := waitMoveRace(t, moveDone, "move pane rejection")
116+
117+ require.ErrorIs(t, err, errMoveStaleTarget)
118+ d.mu.Lock()
119+ require.Same(t, source, d.sessions[source.id])
120+ require.Same(t, destination, d.sessions[destination.id])
121+ d.mu.Unlock()
122+ source.mu.Lock()
123+ require.Equal(t, []*tab{sourceTab}, source.tabs)
124+ source.mu.Unlock()
125+ sourceTab.mu.Lock()
126+ require.Same(t, moved, sourceTab.panes[moved.id])
127+ require.Equal(t, sourceGeneration, sourceTab.layoutGeneration)
128+ sourceTab.mu.Unlock()
129+ destinationTab.mu.Lock()
130+ require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id])
131+ require.Equal(t, destinationGeneration, destinationTab.layoutGeneration)
132+ destinationTab.mu.Unlock()
133+ require.Equal(t, owner, moved.ownerSnapshot())
134+ require.Equal(t, currentGeneration, current.roleGeneration.Load())
135+ assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation)
136+ })
137+ }
138+}
139+
140+func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) {
141+ for _, tc := range []struct {
142+ name string
143+ mutation finalMoveAttachmentMutation
144+ existing bool
145+ }{
146+ {name: "new active", mutation: installActiveAfterFinalMoveAdmission},
147+ {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission},
148+ {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true},
149+ } {
150+ t.Run(tc.name, func(t *testing.T) {
151+ d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY())
152+ moved := source.tabs[0]
153+ moved.stableID = "moved-tab"
154+ makeFinalMoveSourceHeadless(source, original)
155+ if tc.existing {
156+ existing := newFinalMoveRaceClient(source)
157+ source.mu.Lock()
158+ source.addSnatchedLocked(existing)
159+ source.mu.Unlock()
160+ }
161+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
162+ destinationTabs := append([]*tab(nil), destination.tabs...)
163+ sourceGeneration := moved.layoutGeneration
164+ owners := make(map[*pane]*paneOwner, len(moved.panes))
165+ for _, p := range moved.panes {
166+ owners[p] = p.ownerSnapshot()
167+ }
168+
169+ barrier := make(chan struct{})
170+ releaseMove := make(chan struct{})
171+ d.afterMoveTabSourceSnapshot = func() {
172+ close(barrier)
173+ <-releaseMove
174+ }
175+ defer func() { d.afterMoveTabSourceSnapshot = nil }()
176+ moveDone := make(chan error, 1)
177+ go func() {
178+ moveDone <- d.moveTab(moveTabRequest{
179+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
180+ SourceTabID: domain.TabStableID(moved.stableID),
181+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
182+ })
183+ }()
184+ waitMoveRace(t, barrier, "move tab admission")
185+ current, replaced := mutateFinalMoveAttachments(source, tc.mutation)
186+ currentGeneration := current.roleGeneration.Load()
187+ close(releaseMove)
188+ err := waitMoveRace(t, moveDone, "move tab rejection")
189+
190+ require.ErrorIs(t, err, errMoveStaleTarget)
191+ d.mu.Lock()
192+ require.Same(t, source, d.sessions[source.id])
193+ require.Same(t, destination, d.sessions[destination.id])
194+ d.mu.Unlock()
195+ source.mu.Lock()
196+ require.Equal(t, []*tab{moved}, source.tabs)
197+ source.mu.Unlock()
198+ destination.mu.Lock()
199+ require.Equal(t, destinationTabs, destination.tabs)
200+ destination.mu.Unlock()
201+ moved.mu.Lock()
202+ require.Equal(t, sourceGeneration, moved.layoutGeneration)
203+ moved.mu.Unlock()
204+ for p, owner := range owners {
205+ require.Equal(t, owner, p.ownerSnapshot())
206+ }
207+ require.Equal(t, currentGeneration, current.roleGeneration.Load())
208+ assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation)
209+ })
210+ }
211+}
A internal/usecase/daemon/move_gate_race_test.go +164 -0
@@ -0,0 +1,164 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *session, *tab, func()) {
15+ t.Helper()
16+ movedPTY, releaseMoved := newBlockingPTY(t)
17+ destinationPTY, releaseDestination := newBlockingPTY(t)
18+ d, source, client, _ := newManualSessionWithPTYs(t, movedPTY)
19+ sourceTab := source.tabs[0]
20+ sourceTab.stableID = "source-tab"
21+ moved := sourceTab.focusedPane()
22+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
23+
24+ destination := &session{
25+ id: "destination", name: "destination", ephemeral: true,
26+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
27+ active: 0,
28+ }
29+ destinationTab := destination.tabs[0]
30+ publishTiledPaneOwners(destination, destinationTab)
31+ d.mu.Lock()
32+ d.sessions[destination.id] = destination
33+ d.mu.Unlock()
34+ return d, source, sourceTab, moved, destination, destinationTab, func() {
35+ releaseMoved()
36+ releaseDestination()
37+ }
38+}
39+
40+func moveGateRaceRequest(source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) movePaneRequest {
41+ return movePaneRequest{
42+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
43+ SourceTabID: domain.TabStableID(sourceTab.stableID),
44+ SourcePaneID: domain.PaneStableID(moved.stableID),
45+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
46+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
47+ }
48+}
49+
50+func waitMoveRace[T any](t *testing.T, ch <-chan T, name string) T {
51+ t.Helper()
52+ select {
53+ case value := <-ch:
54+ return value
55+ case <-timeAfterMoveRace():
56+ t.Fatalf("%s did not finish", name)
57+ var zero T
58+ return zero
59+ }
60+}
61+
62+// timeAfterMoveRace is kept in one helper so these barrier tests use a
63+// deterministic bounded wait rather than sleeps.
64+func timeAfterMoveRace() <-chan time.Time {
65+ return time.After(testWaitTimeout)
66+}
67+
68+func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
69+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
70+ defer releasePTYs()
71+
72+ moveReserved := make(chan struct{})
73+ releaseMoveAdmission := make(chan struct{})
74+ d.afterMoveLifecycleReserved = func() {
75+ close(moveReserved)
76+ <-releaseMoveAdmission
77+ }
78+ killFrozen := make(chan struct{})
79+ releaseKill := make(chan struct{})
80+ var killFrozenOnce sync.Once
81+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
82+ if action != "" {
83+ return
84+ }
85+ killFrozenOnce.Do(func() { close(killFrozen) })
86+ <-releaseKill
87+ }
88+ defer func() {
89+ d.afterMoveLifecycleReserved = nil
90+ d.afterRoleEffectGateFrozen = nil
91+ }()
92+
93+ moveDone := make(chan error, 1)
94+ go func() {
95+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
96+ }()
97+ waitMoveRace(t, moveReserved, "move reservation")
98+
99+ killDone := make(chan error, 1)
100+ go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }()
101+ waitMoveRace(t, killFrozen, "kill gate freeze")
102+ close(releaseMoveAdmission)
103+ moveErr := waitMoveRace(t, moveDone, "move abort after kill gate acquisition")
104+ close(releaseKill)
105+ killErr := waitMoveRace(t, killDone, "kill teardown")
106+
107+ require.ErrorIs(t, moveErr, errMovePaneInvalid)
108+ require.NoError(t, killErr)
109+ d.mu.Lock()
110+ _, sourceLive := d.sessions[source.id]
111+ _, destinationLive := d.sessions[destination.id]
112+ d.mu.Unlock()
113+ require.False(t, sourceLive, "kill winner must retire the source")
114+ require.True(t, destinationLive)
115+ require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
116+}
117+
118+func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
119+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
120+ defer releasePTYs()
121+
122+ moveGateFrozen := make(chan struct{})
123+ releaseMove := make(chan struct{})
124+ var moveGateFrozenOnce sync.Once
125+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
126+ if action != "move-pane" {
127+ return
128+ }
129+ moveGateFrozenOnce.Do(func() { close(moveGateFrozen) })
130+ <-releaseMove
131+ }
132+ killSnapshotted := make(chan struct{})
133+ var killSnapshottedOnce sync.Once
134+ d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
135+ if action == "" {
136+ killSnapshottedOnce.Do(func() { close(killSnapshotted) })
137+ }
138+ }
139+ defer func() {
140+ d.afterRoleEffectGateFrozen = nil
141+ d.afterRoleEffectParticipantsSnapshotted = nil
142+ }()
143+
144+ moveDone := make(chan error, 1)
145+ go func() {
146+ moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
147+ }()
148+ waitMoveRace(t, moveGateFrozen, "move gate freeze")
149+
150+ killDone := make(chan error, 1)
151+ go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }()
152+ waitMoveRace(t, killSnapshotted, "kill participant snapshot")
153+ close(releaseMove)
154+
155+ require.NoError(t, waitMoveRace(t, moveDone, "move commit"))
156+ require.NoError(t, waitMoveRace(t, killDone, "kill loser"))
157+ d.mu.Lock()
158+ _, sourceLive := d.sessions[source.id]
159+ _, destinationLive := d.sessions[destination.id]
160+ d.mu.Unlock()
161+ require.False(t, sourceLive)
162+ require.True(t, destinationLive)
163+ require.Same(t, moved, destinationTab.panes[moved.id])
164+}
A internal/usecase/daemon/move_handoff_failure_test.go +338 -0
@@ -0,0 +1,338 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "maps"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+type moveTabState struct {
15+ ptr *tab
16+ stableID string
17+ tree *layout.Tree
18+ panes map[layout.PaneID]*pane
19+ nextPaneID int
20+ layoutGeneration uint64
21+ floatingState floatingState
22+ floatingPane *pane
23+ floatingVisible bool
24+ floatingGeneration uint64
25+ ctxDone bool
26+}
27+
28+type moveSessionState struct {
29+ ptr *session
30+ id domain.SessionID
31+ name string
32+ incarnation domain.IncarnationID
33+ tabs []*tab
34+ active int
35+ client *attachedClient
36+ snatched map[*attachedClient]struct{}
37+ moveReservations uint
38+ snapshotGeneration uint64
39+ snapshotPublished uint64
40+ snapshotDirty bool
41+ snapshotEligible bool
42+ ctxDone bool
43+}
44+
45+type moveLeaseState struct {
46+ lease *attachmentLease
47+ attachment *attachedClient
48+ ready bool
49+ active bool
50+}
51+
52+type moveClientState struct {
53+ transport transportSnapshot
54+ roleGeneration uint64
55+ currentSession *session
56+ sourceRole attachmentRole
57+ destinationRole attachmentRole
58+ sourceLease moveLeaseState
59+ destinationLease moveLeaseState
60+ roleEffectPhase roleEffectPhase
61+}
62+
63+type movePaneState struct {
64+ id layout.PaneID
65+ stableID string
66+ owner *paneOwner
67+ ownerGeneration uint64
68+ ctxDone bool
69+}
70+
71+type moveState struct {
72+ closing bool
73+ registry map[domain.SessionID]*session
74+ source moveSessionState
75+ destination moveSessionState
76+ sourceTab moveTabState
77+ destinationTab moveTabState
78+ movedPane movePaneState
79+ client moveClientState
80+}
81+
82+func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState {
83+ clientState := captureMoveClientState(source, destination, client)
84+ d.mu.Lock()
85+ d.notices.routingMu.Lock()
86+ unlockSessions := lockAttachmentSessions(source, destination)
87+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
88+ movedPane.mu.Lock()
89+
90+ registry := make(map[domain.SessionID]*session, len(d.sessions))
91+ maps.Copy(registry, d.sessions)
92+ state := moveState{
93+ closing: d.closing,
94+ registry: registry,
95+ source: captureMoveSessionStateLocked(source),
96+ destination: captureMoveSessionStateLocked(destination),
97+ sourceTab: captureMoveTabStateLocked(sourceTab),
98+ destinationTab: captureMoveTabStateLocked(destinationTab),
99+ movedPane: movePaneState{
100+ id: movedPane.id,
101+ stableID: movedPane.stableID,
102+ owner: movedPane.ownerSnapshot(),
103+ ownerGeneration: movedPane.ownerGeneration,
104+ ctxDone: moveContextDone(movedPane.ctx),
105+ },
106+ client: clientState,
107+ }
108+
109+ movedPane.mu.Unlock()
110+ unlockTabs()
111+ unlockSessions()
112+ d.notices.routingMu.Unlock()
113+ d.mu.Unlock()
114+ return state
115+}
116+
117+func captureMoveSessionStateLocked(sess *session) moveSessionState {
118+ snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
119+ maps.Copy(snatched, sess.snatched)
120+ return moveSessionState{
121+ ptr: sess,
122+ id: sess.id,
123+ name: sess.name,
124+ incarnation: sess.incarnation,
125+ tabs: append([]*tab(nil), sess.tabs...),
126+ active: sess.active,
127+ client: sess.client,
128+ snatched: snatched,
129+ moveReservations: sess.moveReservations,
130+ snapshotGeneration: sess.snapshotGeneration,
131+ snapshotPublished: sess.snapshotPublishedGeneration,
132+ snapshotDirty: sess.snapDirty.Load(),
133+ snapshotEligible: sess.snapEligible.Load(),
134+ ctxDone: moveContextDone(sess.ctx),
135+ }
136+}
137+
138+func captureMoveTabStateLocked(tb *tab) moveTabState {
139+ panes := make(map[layout.PaneID]*pane, len(tb.panes))
140+ maps.Copy(panes, tb.panes)
141+ return moveTabState{
142+ ptr: tb,
143+ stableID: tb.stableID,
144+ tree: tb.tree.Clone(),
145+ panes: panes,
146+ nextPaneID: tb.nextPaneID,
147+ layoutGeneration: tb.layoutGeneration,
148+ floatingState: tb.floating.state,
149+ floatingPane: tb.floating.pane,
150+ floatingVisible: tb.floating.desiredVisible,
151+ floatingGeneration: tb.floating.generation,
152+ ctxDone: moveContextDone(tb.ctx),
153+ }
154+}
155+
156+// captureMoveClientState snapshots coordinator and role-effect state before
157+// callers acquire daemon/session/tab move-state locks. This keeps coordinator
158+// and role-gate locks above, never beneath, the outer move fixture locks.
159+func captureMoveClientState(source, destination *session, client *attachedClient) moveClientState {
160+ sourceLease := captureMoveLeaseState(source, client)
161+ destinationLease := captureMoveLeaseState(destination, client)
162+ roleEffectPhase := captureMoveRoleEffectPhase(client)
163+ return moveClientState{
164+ transport: client.transportSnapshot(),
165+ roleGeneration: client.roleGeneration.Load(),
166+ currentSession: client.currentSession(),
167+ sourceRole: source.attachmentRole(client),
168+ destinationRole: destination.attachmentRole(client),
169+ sourceLease: sourceLease,
170+ destinationLease: destinationLease,
171+ roleEffectPhase: roleEffectPhase,
172+ }
173+}
174+
175+func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState {
176+ coordinator := sess.renderCoordinator()
177+ if coordinator == nil {
178+ return moveLeaseState{}
179+ }
180+ coordinator.mu.Lock()
181+ defer coordinator.mu.Unlock()
182+ lease := coordinator.lease
183+ if lease == nil || lease.attachment != client {
184+ return moveLeaseState{lease: lease}
185+ }
186+ return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active}
187+}
188+
189+func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase {
190+ client.roleEffects.mu.Lock()
191+ defer client.roleEffects.mu.Unlock()
192+ return client.roleEffects.phase
193+}
194+
195+func moveContextDone(ctx context.Context) bool {
196+ return ctx != nil && ctx.Err() != nil
197+}
198+
199+func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
200+ p1, release1 := newBlockingPTY(t)
201+ p2, release2 := newBlockingPTY(t)
202+ defer release1()
203+ defer release2()
204+
205+ d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
206+ sourceTab := source.tabs[0]
207+ sourceTab.stableID = "source-tab"
208+ movedPane := sourceTab.focusedPane()
209+
210+ destination := &session{
211+ id: "destination",
212+ name: "destination",
213+ ephemeral: true,
214+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
215+ active: 0,
216+ }
217+ destinationTab := destination.tabs[0]
218+ publishTiledPaneOwners(destination, destinationTab)
219+ d.mu.Lock()
220+ d.sessions[destination.id] = destination
221+ d.mu.Unlock()
222+ require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
223+
224+ before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
225+ originalTransport := before.client.transport
226+ staleTransport := &closeTrackingTransport{}
227+ staleInjected := make(chan struct{})
228+ d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
229+ if action != "move-pane" || client != sourceClient {
230+ return
231+ }
232+ client.replaceTransport(staleTransport)
233+ close(staleInjected)
234+ }
235+
236+ moveDone := make(chan error, 1)
237+ go func() {
238+ moveDone <- d.movePane(movePaneRequest{
239+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
240+ SourceTabID: domain.TabStableID(sourceTab.stableID),
241+ SourcePaneID: domain.PaneStableID(movedPane.stableID),
242+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
243+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
244+ })
245+ }()
246+ <-staleInjected
247+ err := <-moveDone
248+
249+ // Restore the exact test-injected transport incarnation before comparing the
250+ // complete state. The move itself must not be able to alter this state.
251+ sourceClient.linkMu.Lock()
252+ sourceClient.tr = originalTransport.transport
253+ sourceClient.transportIncarnation = originalTransport.incarnation
254+ sourceClient.linkMu.Unlock()
255+ d.afterRoleEffectGateFrozen = nil
256+
257+ require.ErrorIs(t, err, errAttachmentTransition)
258+ after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
259+ require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
260+}
261+
262+type moveTabHandoffState struct {
263+ closing bool
264+ registry map[domain.SessionID]*session
265+ source moveSessionState
266+ destination moveSessionState
267+ movedTab moveTabState
268+ follower moveClientState
269+}
270+
271+func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState {
272+ followerState := captureMoveClientState(source, destination, follower)
273+ d.mu.Lock()
274+ d.notices.routingMu.Lock()
275+ unlockSessions := lockAttachmentSessions(source, destination)
276+ moved.mu.Lock()
277+
278+ registry := make(map[domain.SessionID]*session, len(d.sessions))
279+ maps.Copy(registry, d.sessions)
280+ state := moveTabHandoffState{
281+ closing: d.closing,
282+ registry: registry,
283+ source: captureMoveSessionStateLocked(source),
284+ destination: captureMoveSessionStateLocked(destination),
285+ movedTab: captureMoveTabStateLocked(moved),
286+ follower: followerState,
287+ }
288+
289+ moved.mu.Unlock()
290+ unlockSessions()
291+ d.notices.routingMu.Unlock()
292+ d.mu.Unlock()
293+ return state
294+}
295+
296+func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
297+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
298+ floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
299+ floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
300+ t.Cleanup(floating.cancel)
301+ moved.mu.Lock()
302+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9}
303+ moved.mu.Unlock()
304+ publishPaneOwner(floating, source, moved, 9)
305+
306+ before := captureMoveTabHandoffState(d, source, destination, moved, follower)
307+ originalTransport := before.follower.transport
308+ staleTransport := &closeTrackingTransport{}
309+ staleInjected := make(chan struct{})
310+ d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
311+ if action != "move-tab" || client != follower {
312+ return
313+ }
314+ client.replaceTransport(staleTransport)
315+ close(staleInjected)
316+ }
317+
318+ moveDone := make(chan error, 1)
319+ go func() {
320+ moveDone <- d.moveTab(moveTabRequest{
321+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
322+ SourceTabID: domain.TabStableID(moved.stableID),
323+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
324+ })
325+ }()
326+ <-staleInjected
327+ err := <-moveDone
328+
329+ follower.linkMu.Lock()
330+ follower.tr = originalTransport.transport
331+ follower.transportIncarnation = originalTransport.incarnation
332+ follower.linkMu.Unlock()
333+ d.afterRoleEffectGateFrozen = nil
334+
335+ require.ErrorIs(t, err, errAttachmentTransition)
336+ after := captureMoveTabHandoffState(d, source, destination, moved, follower)
337+ require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication")
338+}
A internal/usecase/daemon/move_lifecycle.go +152 -0
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "cmp"
5+ "errors"
6+ "slices"
7+ "sync"
8+)
9+
10+var errMoveLifecycleUnavailable = errors.New("move lifecycle is no longer available")
11+
12+// moveLifecycleReservation pins a unique, canonically ordered set of sessions
13+// plus the daemon move gate. Release is idempotent so every abort path can use
14+// defer without coordinating ownership with its caller.
15+type moveLifecycleReservation struct {
16+ daemon *Daemon
17+ sessions []*session
18+ once sync.Once
19+}
20+
21+// reserveMoveLifecycles admits a move at the daemon gate before touching any
22+// per-session lifecycle state. All session counts publish atomically while the
23+// complete ordered teardown lock set is held.
24+func (d *Daemon) reserveMoveLifecycles(source, destination *session) (*moveLifecycleReservation, error) {
25+ if d == nil || source == nil || destination == nil {
26+ return nil, errMoveLifecycleUnavailable
27+ }
28+
29+ d.moveLifecycleMu.Lock()
30+ if d.moveLifecycleClosing {
31+ d.moveLifecycleMu.Unlock()
32+ return nil, errMoveLifecycleUnavailable
33+ }
34+ d.mu.Lock()
35+ closing := d.closing
36+ d.mu.Unlock()
37+ if closing {
38+ d.moveLifecycleMu.Unlock()
39+ return nil, errMoveLifecycleUnavailable
40+ }
41+ d.moveLifecycleActive++
42+ d.signalMoveLifecycleChangedLocked()
43+ d.moveLifecycleMu.Unlock()
44+ if d.afterMoveLifecycleGateBeforeTeardownLocks != nil {
45+ d.afterMoveLifecycleGateBeforeTeardownLocks()
46+ }
47+
48+ sessions := uniqueMoveSessions(source, destination)
49+ for _, sess := range sessions {
50+ sess.teardownMu.Lock()
51+ }
52+ for _, sess := range sessions {
53+ if sess.teardownActive {
54+ unlockMoveSessions(sessions)
55+ d.moveLifecycleMu.Lock()
56+ d.moveLifecycleActive--
57+ d.signalMoveLifecycleChangedLocked()
58+ d.moveLifecycleMu.Unlock()
59+ return nil, errMoveLifecycleUnavailable
60+ }
61+ }
62+ for _, sess := range sessions {
63+ sess.moveReservations++
64+ sess.signalTeardownChangedLocked()
65+ }
66+ unlockMoveSessions(sessions)
67+
68+ return &moveLifecycleReservation{daemon: d, sessions: sessions}, nil
69+}
70+
71+func uniqueMoveSessions(source, destination *session) []*session {
72+ sessions := []*session{source}
73+ if destination != source {
74+ sessions = append(sessions, destination)
75+ }
76+ slices.SortFunc(sessions, func(left, right *session) int {
77+ return cmp.Compare(left.id, right.id)
78+ })
79+ return sessions
80+}
81+
82+func unlockMoveSessions(sessions []*session) {
83+ for i := len(sessions) - 1; i >= 0; i-- {
84+ sessions[i].teardownMu.Unlock()
85+ }
86+}
87+
88+func (r *moveLifecycleReservation) Release() {
89+ if r == nil {
90+ return
91+ }
92+ r.once.Do(func() {
93+ r.daemon.moveLifecycleMu.Lock()
94+ for _, sess := range r.sessions {
95+ sess.teardownMu.Lock()
96+ }
97+ for _, sess := range r.sessions {
98+ sess.moveReservations--
99+ sess.signalTeardownChangedLocked()
100+ }
101+ unlockMoveSessions(r.sessions)
102+ r.daemon.moveLifecycleActive--
103+ r.daemon.signalMoveLifecycleChangedLocked()
104+ r.daemon.moveLifecycleMu.Unlock()
105+ })
106+}
107+
108+// closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary
109+// routing, then drains reservations without holding either daemon mutex. Pane
110+// process lifetime is cancelled only after the final reservation releases.
111+func (d *Daemon) closeMoveLifecycles() {
112+ if d == nil {
113+ return
114+ }
115+ d.moveLifecycleMu.Lock()
116+ if !d.moveLifecycleClosing {
117+ d.moveLifecycleClosing = true
118+ d.signalMoveLifecycleChangedLocked()
119+ }
120+ d.moveLifecycleMu.Unlock()
121+
122+ // Publish ordinary shutdown admission separately: no move gate mutex is held
123+ // while taking the architecture registry lock.
124+ d.mu.Lock()
125+ d.closing = true
126+ d.mu.Unlock()
127+
128+ d.moveLifecycleMu.Lock()
129+ for d.moveLifecycleActive != 0 {
130+ changed := d.moveLifecycleChangeLocked()
131+ d.moveLifecycleMu.Unlock()
132+ <-changed
133+ d.moveLifecycleMu.Lock()
134+ }
135+ cancel := d.paneProcessCancel
136+ d.moveLifecycleMu.Unlock()
137+ if cancel != nil {
138+ cancel()
139+ }
140+}
141+
142+func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
143+ if d.moveLifecycleChanged == nil {
144+ d.moveLifecycleChanged = make(chan struct{})
145+ }
146+ return d.moveLifecycleChanged
147+}
148+
149+func (d *Daemon) signalMoveLifecycleChangedLocked() {
150+ close(d.moveLifecycleChangeLocked())
151+ d.moveLifecycleChanged = make(chan struct{})
152+}
A internal/usecase/daemon/move_lifecycle_test.go +253 -0
@@ -0,0 +1,253 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) {
13+ t.Helper()
14+ for {
15+ sess.teardownMu.Lock()
16+ gotReservations := sess.moveReservations
17+ gotWaiters := sess.teardownWaiters
18+ changed := sess.teardownChangeLocked()
19+ sess.teardownMu.Unlock()
20+ if gotReservations == reservations && gotWaiters == waiters {
21+ return
22+ }
23+ select {
24+ case <-changed:
25+ case <-time.After(testWaitTimeout):
26+ t.Fatalf("move reservations/waiters = %d/%d, want %d/%d", gotReservations, gotWaiters, reservations, waiters)
27+ }
28+ }
29+}
30+
31+func awaitDaemonMoveActive(t *testing.T, d *Daemon, want uint) {
32+ t.Helper()
33+ for {
34+ d.moveLifecycleMu.Lock()
35+ active := d.moveLifecycleActive
36+ changed := d.moveLifecycleChangeLocked()
37+ d.moveLifecycleMu.Unlock()
38+ if active == want {
39+ return
40+ }
41+ select {
42+ case <-changed:
43+ case <-time.After(testWaitTimeout):
44+ t.Fatalf("active move lifecycles = %d, want %d", active, want)
45+ }
46+ }
47+}
48+
49+func awaitDaemonMoveClosing(t *testing.T, d *Daemon) {
50+ t.Helper()
51+ for {
52+ d.moveLifecycleMu.Lock()
53+ closing := d.moveLifecycleClosing
54+ changed := d.moveLifecycleChangeLocked()
55+ d.moveLifecycleMu.Unlock()
56+ if closing {
57+ return
58+ }
59+ select {
60+ case <-changed:
61+ case <-time.After(testWaitTimeout):
62+ t.Fatal("daemon move lifecycle gate did not begin closing")
63+ }
64+ }
65+}
66+
67+func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
68+ d := newTestDaemon(t, nil, stubClock{})
69+ sess := &session{id: "source"}
70+
71+ reservation, err := d.reserveMoveLifecycles(sess, sess)
72+ require.NoError(t, err)
73+ awaitMoveTeardownState(t, sess, 1, 0)
74+
75+ teardown := make(chan bool, 1)
76+ go func() { teardown <- sess.beginTeardown(nil) }()
77+ awaitMoveTeardownState(t, sess, 1, 1)
78+ select {
79+ case <-teardown:
80+ t.Fatal("teardown claimed ownership while the move lifecycle was reserved")
81+ default:
82+ }
83+
84+ reservation.Release()
85+ require.True(t, awaitTestValue(t, teardown, "teardown did not wake after move release"))
86+ sess.finishTeardown()
87+}
88+
89+func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
90+ d := newTestDaemon(t, nil, stubClock{})
91+ first := &session{id: "a"}
92+ second := &session{id: "b"}
93+
94+ // Both opposite-direction reservations must block on a before either takes
95+ // b. This proves order from immutable IDs rather than argument direction.
96+ first.teardownMu.Lock()
97+ beforeTeardownLocks := make(chan struct{}, 2)
98+ d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
99+ defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }()
100+ results := make(chan *moveLifecycleReservation, 2)
101+ errs := make(chan error, 2)
102+ for _, pair := range [][2]*session{{first, second}, {second, first}} {
103+ go func() {
104+ reservation, err := d.reserveMoveLifecycles(pair[0], pair[1])
105+ results <- reservation
106+ errs <- err
107+ }()
108+ }
109+ awaitDaemonMoveActive(t, d, 2)
110+ for range 2 {
111+ awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission")
112+ }
113+ require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session")
114+ second.teardownMu.Unlock()
115+ first.teardownMu.Unlock()
116+
117+ for range 2 {
118+ require.NoError(t, awaitTestValue(t, errs, "opposite move reservation did not finish"))
119+ reservation := awaitTestValue(t, results, "opposite move reservation token missing")
120+ require.NotNil(t, reservation)
121+ reservation.Release()
122+ }
123+ awaitDaemonMoveActive(t, d, 0)
124+}
125+
126+func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.T) {
127+ for _, teardownSession := range []string{"source", "destination"} {
128+ t.Run(teardownSession, func(t *testing.T) {
129+ d := newTestDaemon(t, nil, stubClock{})
130+ source := &session{id: "source"}
131+ destination := &session{id: "destination"}
132+ tearingDown := source
133+ if teardownSession == "destination" {
134+ tearingDown = destination
135+ }
136+ require.True(t, tearingDown.beginTeardown(nil))
137+
138+ reservation, err := d.reserveMoveLifecycles(source, destination)
139+ require.ErrorIs(t, err, errMoveLifecycleUnavailable)
140+ require.Nil(t, reservation)
141+ awaitMoveTeardownState(t, source, 0, 0)
142+ awaitMoveTeardownState(t, destination, 0, 0)
143+ awaitDaemonMoveActive(t, d, 0)
144+ tearingDown.finishTeardown()
145+ })
146+ }
147+}
148+
149+func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) {
150+ d := newTestDaemon(t, nil, stubClock{})
151+ sess := &session{id: "same"}
152+ reservation, err := d.reserveMoveLifecycles(sess, sess)
153+ require.NoError(t, err)
154+ awaitMoveTeardownState(t, sess, 1, 0)
155+
156+ const releasers = 8
157+ released := make(chan struct{}, releasers)
158+ for range releasers {
159+ go func() {
160+ reservation.Release()
161+ released <- struct{}{}
162+ }()
163+ }
164+ for range releasers {
165+ awaitTestCompletion(t, released, "concurrent reservation release did not return")
166+ }
167+ awaitMoveTeardownState(t, sess, 0, 0)
168+ awaitDaemonMoveActive(t, d, 0)
169+}
170+
171+func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) {
172+ d := newTestDaemon(t, nil, stubClock{})
173+ sess := &session{id: "source"}
174+ reservation, err := d.reserveMoveLifecycles(sess, sess)
175+ require.NoError(t, err)
176+ deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
177+
178+ teardown := make(chan bool, 1)
179+ go func() { teardown <- sess.beginTeardown(deadline) }()
180+ awaitMoveTeardownState(t, sess, 1, 1)
181+ close(deadline.done)
182+ require.False(t, awaitTestValue(t, teardown, "cancelled teardown waiter did not return"))
183+ awaitMoveTeardownState(t, sess, 1, 0)
184+ awaitDaemonMoveActive(t, d, 1)
185+
186+ reservation.Release()
187+ awaitMoveTeardownState(t, sess, 0, 0)
188+ awaitDaemonMoveActive(t, d, 0)
189+}
190+
191+func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
192+ d := newTestDaemon(t, nil, stubClock{})
193+ sess := &session{id: "source"}
194+ reservation, err := d.reserveMoveLifecycles(sess, sess)
195+ require.NoError(t, err)
196+
197+ claimed := make(chan struct{}, 2)
198+ finish := make(chan struct{}, 2)
199+ done := make(chan struct{}, 2)
200+ for range 2 {
201+ go func() {
202+ if sess.beginTeardown(nil) {
203+ claimed <- struct{}{}
204+ <-finish
205+ sess.finishTeardown()
206+ }
207+ done <- struct{}{}
208+ }()
209+ }
210+ awaitMoveTeardownState(t, sess, 1, 2)
211+ reservation.Release()
212+ for range 2 {
213+ awaitTestCompletion(t, claimed, "teardown waiter did not claim ownership")
214+ finish <- struct{}{}
215+ }
216+ for range 2 {
217+ awaitTestCompletion(t, done, "teardown waiter did not complete")
218+ }
219+ awaitMoveTeardownState(t, sess, 0, 0)
220+}
221+
222+func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
223+ d := newTestDaemon(t, nil, stubClock{})
224+ source := &session{id: "source"}
225+ destination := &session{id: "destination"}
226+ reservation, err := d.reserveMoveLifecycles(source, destination)
227+ require.NoError(t, err)
228+ paneLifetime := d.paneProcessCtx
229+ require.NotNil(t, paneLifetime)
230+
231+ shutdown := make(chan bool, 1)
232+ go func() { shutdown <- d.shutdownAll(ports.ReasonServerShutdown) }()
233+ awaitDaemonMoveClosing(t, d)
234+ select {
235+ case <-paneLifetime.Done():
236+ t.Fatal("shutdown cancelled transferable pane lifetime before the move released")
237+ default:
238+ }
239+ select {
240+ case <-shutdown:
241+ t.Fatal("shutdown completed while a move lifecycle remained reserved")
242+ default:
243+ }
244+
245+ reservation.Release()
246+ awaitTestCompletion(t, paneLifetime.Done(), "shutdown did not cancel pane lifetime after move drain")
247+ require.False(t, awaitTestValue(t, shutdown, "shutdown did not continue after move drain"))
248+
249+ reservation, err = d.reserveMoveLifecycles(source, destination)
250+ require.ErrorIs(t, err, errMoveLifecycleUnavailable)
251+ require.Nil(t, reservation)
252+ awaitDaemonMoveActive(t, d, 0)
253+}
A internal/usecase/daemon/move_pane_admission.go +74 -0
@@ -0,0 +1,74 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+)
6+
7+// movePaneAdmission is an immutable snapshot of the exact live objects and
8+// generations a move may later commit. Resize fences are acquired only after
9+// this snapshot; the locked commit revalidates every field before publication.
10+type movePaneAdmission struct {
11+ source *session
12+ destination *session
13+ sourceTab *tab
14+ destinationTab *tab
15+ movedPane *pane
16+ sourceClient *attachedClient
17+ destinationClient *attachedClient
18+ sourceSnatched []*attachedClient
19+ sourceGeneration uint64
20+ destinationGeneration uint64
21+ finalSourceTab bool
22+ sourceTabWasActive bool
23+}
24+
25+func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {
26+ d.mu.Lock()
27+ defer d.mu.Unlock()
28+ if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
29+ return nil, errMoveStaleTarget
30+ }
31+ unlockSessions := lockAttachmentSessions(source, destination)
32+ defer unlockSessions()
33+ if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
34+ return nil, errMoveStaleTarget
35+ }
36+
37+ sourceTab := findMoveTabLocked(source, req.SourceTabID)
38+ destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
39+ if sourceTab == nil || destinationTab == nil {
40+ return nil, errMoveStaleTarget
41+ }
42+ if sourceTab == destinationTab {
43+ return nil, errMovePaneInvalid
44+ }
45+ unlockTabs := lockMoveTabs(sourceTab, destinationTab)
46+ defer unlockTabs()
47+
48+ var movedPane *pane
49+ for _, candidate := range sourceTab.panes {
50+ if candidate != nil && candidate.stableID == string(req.SourcePaneID) {
51+ movedPane = candidate
52+ break
53+ }
54+ }
55+ if movedPane == nil {
56+ return nil, errMoveStaleTarget
57+ }
58+
59+ return &movePaneAdmission{
60+ source: source,
61+ destination: destination,
62+ sourceTab: sourceTab,
63+ destinationTab: destinationTab,
64+ movedPane: movedPane,
65+ sourceClient: source.client,
66+ destinationClient: destination.client,
67+ sourceSnatched: snapshotMoveSnatchedLocked(source),
68+ sourceGeneration: sourceTab.layoutGeneration,
69+ destinationGeneration: destinationTab.layoutGeneration,
70+ finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
71+ sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
72+ sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab,
73+ }, nil
74+}
A internal/usecase/daemon/move_pane_candidate.go +111 -0
@@ -0,0 +1,111 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+// movePaneCandidate contains only unpublished topology and identity changes.
13+// The caller must revalidate the live tabs before committing these values.
14+type movePaneCandidate struct {
15+ pane *pane
16+
17+ sourceID layout.PaneID
18+ destinationID layout.PaneID
19+ removeSourceTab bool
20+
21+ sourceTree *layout.Tree
22+ sourcePlacements []layout.Placement
23+ destinationTree *layout.Tree
24+ destinationPlacements []layout.Placement
25+ destinationNextPaneID int
26+ sourceGeneration uint64
27+ destinationGeneration uint64
28+}
29+
30+// prepareMovePaneCandidate builds both affected layouts without publishing any
31+// tab or pane state. The caller is responsible for holding and later
32+// revalidating the source and destination tab state.
33+func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneCandidate, error) {
34+ if source == nil || destination == nil || source == destination || moved == nil ||
35+ source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved ||
36+ !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil ||
37+ destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) {
38+ return nil, errMovePaneInvalid
39+ }
40+
41+ candidate := &movePaneCandidate{
42+ pane: moved,
43+ sourceID: moved.id,
44+ sourceGeneration: source.layoutGeneration,
45+ destinationGeneration: destination.layoutGeneration,
46+ }
47+
48+ sourceLeaves := layout.LeafIDs(source.tree.Root)
49+ if len(sourceLeaves) == 1 {
50+ switch source.floating.state {
51+ case floatingWarming:
52+ return nil, errMoveFloatingWarming
53+ case floatingHidden, floatingVisible:
54+ return nil, errMoveFinalSourceFloating
55+ }
56+ candidate.removeSourceTab = true
57+ } else {
58+ candidate.sourceTree = source.tree.Clone()
59+ if err := candidate.sourceTree.Close(moved.id); err != nil {
60+ return nil, errMovePaneInvalid
61+ }
62+ var ok bool
63+ candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source))
64+ if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) {
65+ return nil, errMoveTooSmall
66+ }
67+ }
68+
69+ var err error
70+ candidate.destinationID, candidate.destinationNextPaneID, err = allocateMovePaneID(destination, moved.id)
71+ if err != nil {
72+ return nil, err
73+ }
74+ candidate.destinationTree = destination.tree.Clone()
75+ area := tabArea(destination)
76+ if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil {
77+ if errors.Is(err, layout.ErrTooSmall) {
78+ return nil, errMoveTooSmall
79+ }
80+ return nil, errMovePaneInvalid
81+ }
82+ var ok bool
83+ candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area)
84+ if !ok {
85+ return nil, errMoveTooSmall
86+ }
87+ return candidate, nil
88+}
89+
90+func allocateMovePaneID(destination *tab, sourceID layout.PaneID) (layout.PaneID, int, error) {
91+ next := max(destination.nextPaneID, 1)
92+ for {
93+ id := layout.PaneID(fmt.Sprintf("pane-%d", next))
94+ _, mapped := destination.panes[id]
95+ available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id)
96+ if available {
97+ if next == math.MaxInt {
98+ return id, next, nil
99+ }
100+ return id, next + 1, nil
101+ }
102+ if next == math.MaxInt {
103+ return "", 0, errMovePaneIDExhausted
104+ }
105+ next++
106+ }
107+}
108+
109+func tabArea(tb *tab) domain.Rect {
110+ return domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
111+}
A internal/usecase/daemon/move_pane_commit.go +173 -0
@@ -0,0 +1,173 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// movePaneCommit owns the single architecture-locked publication section. Its
9+// inputs are immutable admission/fence snapshots; outputs form the postcommit
10+// plan. publishLocked performs all fallible checks before its first write.
11+type movePaneCommit struct {
12+ req movePaneRequest
13+ source, destination *session
14+ sourceTab, destinationTab *tab
15+ movedPane *pane
16+ sourceClient *attachedClient
17+ sourceSnatched []*attachedClient
18+ sourceGeneration uint64
19+ destinationGeneration uint64
20+ handoffFrozen bool
21+ frozenRoles frozenRoleEffectGates
22+ handoffReq attachmentTransitionRequest
23+ handoffPublication *attachmentPublication
24+ err error
25+
26+ handoffResult attachmentTransitionResult
27+ syncCleanup syncTimerCleanup
28+ sourceCleanupToken attachmentRoleToken
29+ sourceEmpty bool
30+ sourceTabRemoved bool
31+ retiredParked []parkedAttachmentRetirement
32+ retiredAttachments []detachedAttachmentSnapshot
33+ sourceMetadata domain.CatalogueMetadataUpdate
34+ sourceMetadataValid bool
35+ destinationMetadata domain.CatalogueMetadataUpdate
36+ destinationMetadataValid bool
37+}
38+
39+func (c *movePaneCommit) releasePublication() {
40+ if c != nil && c.handoffPublication != nil {
41+ c.handoffPublication.unlockCoordinators()
42+ c.handoffPublication = nil
43+ }
44+}
45+
46+func (c *movePaneCommit) publishLocked(d *Daemon) bool {
47+ var candidate *movePaneCandidate
48+ var retirement frozenMoveAttachmentRetirement
49+ var err error
50+ d.mu.Lock()
51+ if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
52+ d.mu.Unlock()
53+ return false
54+ }
55+ d.notices.routingMu.Lock()
56+ unlockSessions := lockAttachmentSessions(c.source, c.destination)
57+ defer unlockSessions()
58+ defer d.notices.routingMu.Unlock()
59+ defer d.mu.Unlock()
60+
61+ if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
62+ return false
63+ }
64+ if c.handoffFrozen {
65+ if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
66+ c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
67+ c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
68+ return false
69+ }
70+ c.handoffPublication, err = d.validateAttachmentTransitionPrelocked(c.handoffReq)
71+ if err != nil {
72+ c.err = err
73+ return false
74+ }
75+ defer c.releasePublication()
76+ }
77+
78+ unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
79+ c.movedPane.mu.Lock()
80+ defer c.movedPane.mu.Unlock()
81+ defer unlockTabs()
82+
83+ if !moveTabMemberLocked(c.source, c.sourceTab) || !moveTabMemberLocked(c.destination, c.destinationTab) || c.sourceTab == c.destinationTab ||
84+ c.sourceTab.layoutGeneration != c.sourceGeneration || c.destinationTab.layoutGeneration != c.destinationGeneration ||
85+ c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane ||
86+ c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) ||
87+ c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil {
88+ return false
89+ }
90+ owner := c.movedPane.ownerSnapshot()
91+ if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) {
92+ return false
93+ }
94+ candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)
95+ if err != nil {
96+ c.err = err
97+ return false
98+ }
99+ if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration {
100+ return false
101+ }
102+
103+ sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104+ if sourceWillEmpty && c.source != c.destination {
105+ if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
106+ (c.sourceClient != nil) != c.handoffFrozen {
107+ return false
108+ }
109+ var retirementOK bool
110+ retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles)
111+ if !retirementOK {
112+ return false
113+ }
114+ }
115+
116+ // Everything above is fallible. Topology, owner, lifecycle registry, and
117+ // optional attachment roles now become visible under one lock section.
118+ if candidate.removeSourceTab {
119+ idx := indexMoveTabLocked(c.source, c.sourceTab)
120+ if idx < 0 {
121+ return false
122+ }
123+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
124+ c.sourceTabRemoved = true
125+ if len(c.source.tabs) == 0 {
126+ c.source.active = -1
127+ } else if c.source.active > idx {
128+ c.source.active--
129+ } else if c.source.active >= len(c.source.tabs) {
130+ c.source.active = len(c.source.tabs) - 1
131+ }
132+ } else {
133+ c.sourceTab.tree = candidate.sourceTree
134+ c.sourceTab.bumpLayoutGenerationLocked()
135+ }
136+ delete(c.sourceTab.panes, candidate.sourceID)
137+ if d.beforeMovePaneCommit != nil {
138+ d.beforeMovePaneCommit()
139+ }
140+ c.destinationTab.tree = candidate.destinationTree
141+ c.destinationTab.nextPaneID = candidate.destinationNextPaneID
142+ c.destinationTab.panes[candidate.destinationID] = c.movedPane
143+ c.destinationTab.bumpLayoutGenerationLocked()
144+ c.movedPane.id = candidate.destinationID
145+ oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)
146+ if c.handoffPublication != nil {
147+ c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
148+ c.handoffPublication.unlockCoordinators()
149+ c.handoffPublication = nil
150+ }
151+ c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
152+ if c.handoffResult.published.ac != nil {
153+ c.sourceCleanupToken = c.handoffResult.published
154+ } else if c.source.client != nil {
155+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
156+ }
157+
158+ if candidate.removeSourceTab && len(c.source.tabs) == 0 {
159+ delete(d.sessions, c.source.id)
160+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
161+ c.retiredParked = d.purgeParkedForSessionLocked(c.source)
162+ }
163+ c.sourceEmpty = len(c.source.tabs) == 0
164+ if !c.source.ephemeral {
165+ c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
166+ c.sourceMetadataValid = true
167+ }
168+ if !c.destination.ephemeral && c.destination != c.source {
169+ c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
170+ c.destinationMetadataValid = true
171+ }
172+ return true
173+}
A internal/usecase/daemon/move_pane_postcommit.go +214 -0
@@ -0,0 +1,214 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// movePanePostcommitPlan is captured while the move's architecture and owner
13+// fences still protect publication. It contains only immutable pointers,
14+// generations, and exact attachment tokens needed by fallible follow-up work.
15+func firstMovePane(panes []*pane) *pane {
16+ if len(panes) == 0 {
17+ return nil
18+ }
19+ return panes[0]
20+}
21+
22+type movePanePostcommitPlan struct {
23+ source *session
24+ destination *session
25+ sourceTab *tab
26+ destinationTab *tab
27+ movedPane *pane
28+ movedPanes []*pane
29+ destinationClient *attachedClient
30+ sourceCleanupToken attachmentRoleToken
31+ handoffResult attachmentTransitionResult
32+ syncCleanup syncTimerCleanup
33+ frozenRoles frozenRoleEffectGates
34+ rolesFrozen bool
35+ unlockDispatch func()
36+ reservation *moveLifecycleReservation
37+ oldTabCancel context.CancelFunc
38+
39+ sourceTabWasActive bool
40+ sourceTabRemoved bool
41+ sourceEmpty bool
42+
43+ retiredParked []parkedAttachmentRetirement
44+ retiredAttachments []detachedAttachmentSnapshot
45+ sourceMetadata domain.CatalogueMetadataUpdate
46+ sourceMetadataValid bool
47+ destinationMetadata domain.CatalogueMetadataUpdate
48+ destinationMetadataValid bool
49+}
50+
51+// execute runs only after every resize-owner fence and architecture lock is
52+// released. Generation-bound tokens make stale effects drop rather than route
53+// back to the retired source.
54+func (p movePanePostcommitPlan) execute(d *Daemon) {
55+ if p.oldTabCancel != nil {
56+ p.oldTabCancel()
57+ } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
58+ p.sourceTab.cancel()
59+ }
60+ if p.sourceCleanupToken.current() {
61+ cleanupClient := p.sourceCleanupToken.ac
62+ panes := p.movedPanes
63+ if len(panes) == 0 && p.movedPane != nil {
64+ panes = []*pane{p.movedPane}
65+ }
66+ for _, movedPane := range panes {
67+ if cleanupClient.overlays != nil {
68+ cleanupClient.overlays.clearCopyModeForPane(movedPane)
69+ }
70+ cleanupClient.pruneCaptureFrames(movedPane)
71+ }
72+ }
73+
74+ // Snapshot dirtiness is admitted while lifecycle reservations still pin
75+ // both sessions. Repository publication remains outside every lock below.
76+ markSnapshotDirty(p.destination)
77+ if !p.sourceEmpty {
78+ markSnapshotDirty(p.source)
79+ }
80+ p.syncCleanup.finish()
81+ if p.rolesFrozen {
82+ p.frozenRoles.unfreeze()
83+ }
84+ p.unlockDispatch()
85+ p.reservation.Release()
86+
87+ if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {
88+ d.activateTab(p.source, p.source.activeTab())
89+ }
90+ if !moveSessionRetired(d, p.source) {
91+ sourceLayoutTab := p.sourceTab
92+ if p.sourceTabRemoved {
93+ sourceLayoutTab = p.source.activeTab()
94+ }
95+ if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
96+ d.applyTabLayout(p.source, sourceLayoutTab)
97+ }
98+ }
99+ d.applyTabLayout(p.destination, p.destinationTab)
100+ if p.sourceMetadataValid && !p.sourceEmpty {
101+ d.markCatalogueDirty(p.sourceMetadata)
102+ }
103+ if p.destinationMetadataValid {
104+ d.markCatalogueDirty(p.destinationMetadata)
105+ }
106+ if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {
107+ d.invalidateRender(p.destination, p.destinationClient, true, "move.go")
108+ }
109+ if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
110+ d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")
111+ }
112+ if p.handoffResult.published.ac != nil {
113+ follower := p.handoffResult.published.ac
114+ d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)
115+ follower.recordPreviousSession(p.source)
116+ d.deferAttachmentTransitionCleanups(p.handoffResult)
117+ d.firstPaintForTransition(p.handoffResult.published)
118+ }
119+ if p.sourceEmpty {
120+ retireEmptySessionAfterMove(d, p.source)
121+ }
122+ for _, attachment := range p.retiredAttachments {
123+ d.unregisterPreview(attachment.ac)
124+ attachment.ac.clearPreviousSession()
125+ attachment.ac.clearCaptureFrames()
126+ d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
127+ }
128+ for _, retirement := range p.retiredParked {
129+ if retirement.parked == nil || retirement.parked.ac == nil {
130+ continue
131+ }
132+ d.unregisterPreview(retirement.parked.ac)
133+ retirement.parked.ac.clearCaptureFrames()
134+ }
135+ finishParkedAttachmentRetirements(p.retiredParked)
136+ if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
137+ if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
138+ if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
139+ d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name)
140+ }
141+ }
142+ }
143+}
144+
145+// retireEmptyMoveSessionLocked removes all source-owned attachment roles that
146+// were not transferred. It runs inside the move commit, after the moved pane
147+// has already acquired its destination owner, and performs no external work.
148+func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {
149+ if sess == nil || sess.client != nil {
150+ return nil
151+ }
152+ retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))
153+ for _, ac := range retirement.clients {
154+ retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
155+ delete(sess.snatched, ac)
156+ ac.roleGeneration.Add(1)
157+ ac.setSession(nil)
158+ ac.invalidateFrozenRoleCapability()
159+ }
160+ sess.tabs = nil
161+ sess.active = -1
162+ return retired
163+}
164+
165+func moveSessionRetired(d *Daemon, sess *session) bool {
166+ if d == nil || sess == nil {
167+ return true
168+ }
169+ d.mu.Lock()
170+ _, live := d.sessions[sess.id]
171+ d.mu.Unlock()
172+ return !live
173+}
174+
175+func destinationTabIsActive(sess *session, target *tab) bool {
176+ if sess == nil || target == nil {
177+ return false
178+ }
179+ sess.mu.Lock()
180+ defer sess.mu.Unlock()
181+ return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target
182+}
183+
184+func sessionClientIs(sess *session, client *attachedClient) bool {
185+ if sess == nil || client == nil {
186+ return false
187+ }
188+ sess.mu.Lock()
189+ defer sess.mu.Unlock()
190+ return sess.client == client
191+}
192+
193+// retireEmptySessionAfterMove completes only source-owned lifecycle work. The
194+// moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
195+// cannot cancel or close the transferred PTY.
196+func retireEmptySessionAfterMove(d *Daemon, sess *session) {
197+ if d == nil || sess == nil {
198+ return
199+ }
200+ sess.stopInMemoryLifecycle()
201+ if coordinator := sess.renderCoordinator(); coordinator != nil {
202+ coordinator.beginSessionTeardown().finish()
203+ coordinator.waitForTimerWorkers()
204+ }
205+ sess.mu.Lock()
206+ clipFiles := append([]string(nil), sess.clipFiles...)
207+ sess.clipFiles = nil
208+ sess.mu.Unlock()
209+ for _, path := range clipFiles {
210+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
211+ d.log.Warn("removing clipboard temp file after move failed", "err", err, "path", path)
212+ }
213+ }
214+}
A internal/usecase/daemon/move_persistence_test.go +280 -0
@@ -0,0 +1,280 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14+)
15+
16+type movePersistenceCatalogue struct {
17+ mu sync.Mutex
18+ records map[string]domain.CatalogueRecord
19+ events []string
20+ source *session
21+ destination *session
22+ requireSourceDirty bool
23+ requireDestDirty bool
24+ dirtyBeforeUpdate bool
25+}
26+
27+func (c *movePersistenceCatalogue) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
28+func (c *movePersistenceCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
29+ c.mu.Lock()
30+ defer c.mu.Unlock()
31+ record, ok := c.records[name]
32+ return record, ok, nil
33+}
34+func (c *movePersistenceCatalogue) Create(domain.CatalogueRecord) error { return nil }
35+func (c *movePersistenceCatalogue) Replace(string, domain.CatalogueRecord) error { return nil }
36+func (c *movePersistenceCatalogue) Rename(string, domain.CatalogueRecord) error { return nil }
37+func (c *movePersistenceCatalogue) Delete(name string) error {
38+ c.mu.Lock()
39+ defer c.mu.Unlock()
40+ delete(c.records, name)
41+ c.events = append(c.events, "delete:"+name)
42+ return nil
43+}
44+func (c *movePersistenceCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
45+ sourceDirty := true
46+ if c.requireSourceDirty {
47+ c.source.snapshotMu.Lock()
48+ sourceDirty = c.source.snapDirty.Load() && c.source.snapshotGeneration > 0
49+ c.source.snapshotMu.Unlock()
50+ }
51+ destinationDirty := true
52+ if c.requireDestDirty {
53+ c.destination.snapshotMu.Lock()
54+ destinationDirty = c.destination.snapDirty.Load() && c.destination.snapshotGeneration > 0
55+ c.destination.snapshotMu.Unlock()
56+ }
57+ c.mu.Lock()
58+ defer c.mu.Unlock()
59+ c.dirtyBeforeUpdate = c.dirtyBeforeUpdate && sourceDirty && destinationDirty
60+ c.events = append(c.events, "metadata:"+update.Name)
61+ return nil
62+}
63+func (c *movePersistenceCatalogue) Sync() error { return nil }
64+func (c *movePersistenceCatalogue) Close() error { return nil }
65+
66+type movePersistenceRepository struct {
67+ noOpSnapshotRepository
68+ mu sync.Mutex
69+ events []string
70+ publish error
71+ daemon *Daemon
72+ source *session
73+ destination *session
74+ moved *tab
75+ pane *pane
76+ outsideLocks bool
77+}
78+
79+func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
80+ outsideLocks := true
81+ unlocks := make([]func(), 0, 7)
82+ candidates := make([]struct {
83+ tryLock func() bool
84+ unlock func()
85+ }, 0, 7)
86+ if r.daemon != nil {
87+ candidates = append(candidates, struct {
88+ tryLock func() bool
89+ unlock func()
90+ }{r.daemon.mu.TryLock, r.daemon.mu.Unlock})
91+ }
92+ if r.source != nil {
93+ candidates = append(candidates,
94+ struct {
95+ tryLock func() bool
96+ unlock func()
97+ }{r.source.mu.TryLock, r.source.mu.Unlock},
98+ struct {
99+ tryLock func() bool
100+ unlock func()
101+ }{r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock})
102+ }
103+ if r.destination != nil {
104+ candidates = append(candidates,
105+ struct {
106+ tryLock func() bool
107+ unlock func()
108+ }{r.destination.mu.TryLock, r.destination.mu.Unlock},
109+ struct {
110+ tryLock func() bool
111+ unlock func()
112+ }{r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock})
113+ }
114+ if r.moved != nil {
115+ candidates = append(candidates, struct {
116+ tryLock func() bool
117+ unlock func()
118+ }{r.moved.mu.TryLock, r.moved.mu.Unlock})
119+ }
120+ if r.pane != nil {
121+ candidates = append(candidates, struct {
122+ tryLock func() bool
123+ unlock func()
124+ }{r.pane.mu.TryLock, r.pane.mu.Unlock})
125+ }
126+ for _, candidate := range candidates {
127+ if candidate.tryLock() {
128+ unlocks = append(unlocks, candidate.unlock)
129+ } else {
130+ outsideLocks = false
131+ }
132+ }
133+ for i := len(unlocks) - 1; i >= 0; i-- {
134+ unlocks[i]()
135+ }
136+ r.mu.Lock()
137+ r.events = append(r.events, "publish")
138+ r.outsideLocks = outsideLocks
139+ r.mu.Unlock()
140+ return r.publish
141+}
142+
143+func (r *movePersistenceRepository) DeleteIncarnation(_ context.Context, _ domain.IncarnationID) error {
144+ r.mu.Lock()
145+ r.events = append(r.events, "purge")
146+ r.mu.Unlock()
147+ return nil
148+}
149+
150+func TestMoveTabPersistenceMatrix(t *testing.T) {
151+ tests := []struct {
152+ name string
153+ sourceNamed bool
154+ destinationNamed bool
155+ finalSource bool
156+ wantSourceGeneration bool
157+ wantDestinationGeneration bool
158+ wantMetadata []string
159+ wantPurge bool
160+ }{
161+ {name: "named to named", sourceNamed: true, destinationNamed: true, wantSourceGeneration: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:work", "metadata:destination"}},
162+ {name: "ephemeral to named", destinationNamed: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}},
163+ {name: "named to named final source", sourceNamed: true, destinationNamed: true, finalSource: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}, wantPurge: true},
164+ {name: "named to ephemeral final source", sourceNamed: true, finalSource: true, wantPurge: true},
165+ {name: "ephemeral to ephemeral", finalSource: true},
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ ptyCount := 2
171+ if tt.finalSource {
172+ ptyCount = 1
173+ }
174+ ptys := make([]ports.PTY, ptyCount)
175+ for i := range ptys {
176+ ptys[i] = newQuietPTY()
177+ }
178+ d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
179+ if tt.finalSource {
180+ source.client = nil
181+ client.setSession(nil)
182+ }
183+ source.incarnation = domain.IncarnationID{1}
184+ source.ephemeral = !tt.sourceNamed
185+ source.snapEligible.Store(tt.sourceNamed)
186+ source.snapshotWake = d.snapshotWake
187+ moved := source.tabs[0]
188+ moved.stableID = "moved-tab"
189+
190+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
191+ destination.ephemeral = !tt.destinationNamed
192+ destination.snapEligible.Store(tt.destinationNamed)
193+ destination.snapshotWake = d.snapshotWake
194+
195+ catalogue := &movePersistenceCatalogue{
196+ records: map[string]domain.CatalogueRecord{},
197+ source: source,
198+ destination: destination,
199+ requireSourceDirty: tt.sourceNamed && !tt.finalSource,
200+ requireDestDirty: tt.destinationNamed,
201+ dirtyBeforeUpdate: true,
202+ }
203+ if tt.sourceNamed {
204+ catalogue.records[source.name] = domain.CatalogueRecord{Name: source.name, IncarnationID: source.incarnation}
205+ }
206+ if tt.destinationNamed {
207+ catalogue.records[destination.name] = domain.CatalogueRecord{Name: destination.name, IncarnationID: destination.incarnation}
208+ }
209+ repository := &movePersistenceRepository{}
210+ WithCatalogue(catalogue, nil)(d)
211+ WithSnapshotRepository(repository)(d)
212+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
213+
214+ require.NoError(t, d.moveTab(moveTabRequest{
215+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
216+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
217+ }))
218+
219+ source.snapshotMu.Lock()
220+ sourceGeneration := source.snapshotGeneration
221+ source.snapshotMu.Unlock()
222+ destination.snapshotMu.Lock()
223+ destinationGeneration := destination.snapshotGeneration
224+ destination.snapshotMu.Unlock()
225+ require.Equal(t, tt.wantSourceGeneration, sourceGeneration > 0)
226+ require.Equal(t, tt.wantDestinationGeneration, destinationGeneration > 0)
227+ catalogue.mu.Lock()
228+ metadata := append([]string(nil), catalogue.events...)
229+ dirtyBeforeUpdate := catalogue.dirtyBeforeUpdate
230+ catalogue.mu.Unlock()
231+ require.True(t, dirtyBeforeUpdate, "snapshot dirtiness must be admitted before catalogue metadata")
232+ if tt.wantPurge {
233+ require.Equal(t, append(append([]string(nil), tt.wantMetadata...), "delete:"+source.name), metadata)
234+ } else {
235+ require.ElementsMatch(t, tt.wantMetadata, metadata)
236+ }
237+ repository.mu.Lock()
238+ purged := len(repository.events) == 1 && repository.events[0] == "purge"
239+ repository.mu.Unlock()
240+ require.Equal(t, tt.wantPurge, purged)
241+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1])
242+ })
243+ }
244+}
245+
246+func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
247+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
248+ source.ephemeral = true
249+ moved := source.tabs[0]
250+ moved.stableID = "moved-tab"
251+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
252+ destination.ephemeral = false
253+ destination.snapEligible.Store(true)
254+ destination.snapshotWake = d.snapshotWake
255+
256+ repository := &movePersistenceRepository{
257+ publish: errors.New("injected publication failure"),
258+ daemon: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(),
259+ }
260+ WithSnapshotRepository(repository)(d)
261+ startSnapshotEncodeWorker(t, d)
262+
263+ require.NoError(t, d.moveTab(moveTabRequest{
264+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
265+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
266+ }))
267+ d.scheduleEligibleRepositorySnapshots()
268+ awaitSnapshotIdle(t, destination)
269+
270+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "repository failure must not roll back live membership")
271+ require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
272+ require.True(t, destination.snapDirty.Load(), "failed publication remains scheduler-retryable")
273+ repository.mu.Lock()
274+ require.Equal(t, []string{"publish"}, repository.events, "move persistence uses only the existing snapshot publication format")
275+ require.True(t, repository.outsideLocks, "repository publication must run outside architecture locks")
276+ repository.mu.Unlock()
277+ history := d.notices.history()
278+ require.NotEmpty(t, history)
279+ require.Equal(t, domain.NoticeSnapshotWrite, history[len(history)-1].Code)
280+}
A internal/usecase/daemon/move_picker.go +174 -0
@@ -0,0 +1,174 @@
1+// Package daemon holds vev's server-side session multiplexer use case.
2+package daemon
3+
4+import (
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/usecase/picker"
7+)
8+
9+func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10+ if d == nil || source.Session.ID == "" || source.TabID == "" {
11+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12+ }
13+ sess := d.sessionByID(source.Session.ID)
14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
15+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16+ }
17+ sess.mu.Lock()
18+ defer sess.mu.Unlock()
19+ if source.Client != nil && sess.client != source.Client {
20+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
21+ }
22+ tb := findMoveTabLocked(sess, source.TabID)
23+ if tb == nil {
24+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
25+ }
26+ if source.PaneID == "" {
27+ return nil
28+ }
29+ tb.mu.Lock()
30+ defer tb.mu.Unlock()
31+ if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
32+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
33+ }
34+ return nil
35+}
36+
37+type pickerIntent uint8
38+
39+const (
40+ pickerNavigate pickerIntent = iota
41+ pickerMovePane
42+ pickerMoveTab
43+)
44+
45+type moveSessionLocator struct {
46+ ID domain.SessionID
47+ Incarnation domain.IncarnationID
48+ Name string
49+}
50+
51+type moveSourceLocator struct {
52+ Session moveSessionLocator
53+ TabID domain.TabStableID
54+ PaneID domain.PaneStableID
55+ Client *attachedClient
56+}
57+
58+// enterPickerForIntent returns errNoMoveDestination only for move intents.
59+func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
60+ model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
61+ if intent != pickerNavigate {
62+ if _, ok := model.Selected(); !ok {
63+ return errNoMoveDestination
64+ }
65+ }
66+
67+ d.publishPicker(sess, ac, model, intent, source)
68+ return nil
69+}
70+
71+func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
72+ switch intent {
73+ case pickerMovePane:
74+ return picker.SelectMovePaneTab
75+ case pickerMoveTab:
76+ return picker.SelectMoveTabSession
77+ default:
78+ return picker.SelectNavigationTab
79+ }
80+}
81+
82+func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
83+ destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
84+ switch intent {
85+ case pickerMovePane:
86+ if target.TabID == "" {
87+ return errMovePaneInvalid
88+ }
89+ return d.movePane(movePaneRequest{
90+ Source: source.Session,
91+ SourceTabID: source.TabID,
92+ SourcePaneID: source.PaneID,
93+ Destination: destination,
94+ DestinationTabID: target.TabID,
95+ })
96+ case pickerMoveTab:
97+ return d.moveTab(moveTabRequest{
98+ Source: source.Session,
99+ SourceTabID: source.TabID,
100+ Destination: destination,
101+ })
102+ default:
103+ return errMovePaneInvalid
104+ }
105+}
106+
107+func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
108+ d.mu.Lock()
109+ sess := d.sessions[target.Session]
110+ if sess == nil {
111+ d.mu.Unlock()
112+ return nil, nil
113+ }
114+ sess.mu.Lock()
115+ d.mu.Unlock()
116+ defer sess.mu.Unlock()
117+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
118+ return nil, nil
119+ }
120+ if intent == pickerMoveTab {
121+ if sess.active < 0 || sess.active >= len(sess.tabs) {
122+ return nil, nil
123+ }
124+ return sess, sess.tabs[sess.active]
125+ }
126+ if target.TabID != "" {
127+ for _, tb := range sess.tabs {
128+ if domain.TabStableID(tb.stableID) == target.TabID {
129+ return sess, tb
130+ }
131+ }
132+ return nil, nil
133+ }
134+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
135+ return nil, nil
136+ }
137+ return sess, sess.tabs[target.TabIndex]
138+}
139+
140+func (d *Daemon) refreshPicker(ac *attachedClient) {
141+ sess := ac.currentSession()
142+ if sess == nil {
143+ return
144+ }
145+ rt := ac.overlays
146+ rt.pickerMu.Lock()
147+ if rt.picker == nil {
148+ rt.pickerMu.Unlock()
149+ return
150+ }
151+ intent, source := rt.pickerIntent, rt.pickerSource
152+ current := picker.SourceFilter{}
153+ if intent != pickerNavigate {
154+ selected, _ := rt.picker.Selected()
155+ current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156+ }
157+ rt.pickerMu.Unlock()
158+ model := d.newPickerModel(sess, intent, source, current)
159+ if intent != pickerNavigate {
160+ if _, ok := model.Selected(); !ok {
161+ d.closePicker(ac)
162+ return
163+ }
164+ }
165+ rt.pickerMu.Lock()
166+ updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
167+ if updated {
168+ rt.picker = model
169+ }
170+ rt.pickerMu.Unlock()
171+ if updated {
172+ d.registerPreviewForSelection(ac)
173+ }
174+}
A internal/usecase/daemon/move_picker_test.go +329 -0
@@ -0,0 +1,329 @@
1+package daemon
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
15+ t.Helper()
16+ return setupMovePickerSessionsWithClock(t, stubClock{}, extraDestinationTabs)
17+}
18+
19+func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
20+ t.Helper()
21+ sourcePTY, releaseSource := newBlockingPTY(t)
22+ d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY)
23+ source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}
24+ d.mu.Lock()
25+ delete(d.sessions, domain.SessionID("manual"))
26+ d.sessions[source.id] = source
27+ d.mu.Unlock()
28+ sourceTab := source.tabs[0]
29+ sourceTab.stableID = "source-tab"
30+ sourcePane := sourceTab.focusedPane()
31+ sourcePane.stableID = "source-pane"
32+
33+ releases := []func(){releaseSource}
34+ destPTY, releaseDest := newBlockingPTY(t)
35+ releases = append(releases, releaseDest)
36+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
37+ publishTiledPaneOwners(source, sourceTab)
38+ destination := &session{
39+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
40+ ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0,
41+ }
42+ publishTiledPaneOwners(destination, destinationTab)
43+ for range extraDestinationTabs {
44+ extraPTY, releaseExtra := newBlockingPTY(t)
45+ releases = append(releases, releaseExtra)
46+ extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23})
47+ destination.tabs = append(destination.tabs, extraTab)
48+ publishTiledPaneOwners(destination, extraTab)
49+ }
50+ d.mu.Lock()
51+ d.sessions[destination.id] = destination
52+ d.mu.Unlock()
53+ return d, source, ac, destination, destinationTab, releases
54+}
55+
56+func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
57+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
58+ defer releaseAll(releases)
59+
60+ d.handleInput(source, ac, []byte("\x1b "))
61+ require.True(t, ac.overlays.paletteActive())
62+ d.handleInput(source, ac, []byte("MPN\r"))
63+ require.False(t, ac.overlays.paletteActive())
64+ require.True(t, ac.overlays.pickerActive())
65+
66+ ac.overlays.pickerMu.Lock()
67+ intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource
68+ ac.overlays.pickerMu.Unlock()
69+ require.Equal(t, pickerMovePane, intent)
70+ require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
71+ require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
72+ require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
73+ require.Same(t, ac, captured.Client)
74+}
75+
76+func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
77+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
78+ defer releaseAll(releases)
79+ sourceTab := source.tabs[0]
80+ sourceTab.stableID = "active-tab"
81+
82+ d.handleInput(source, ac, []byte("\x1b "))
83+ d.handleInput(source, ac, []byte("MTB\r"))
84+ require.True(t, ac.overlays.pickerActive())
85+
86+ ac.overlays.pickerMu.Lock()
87+ intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource
88+ ac.overlays.pickerMu.Unlock()
89+ require.Equal(t, pickerMoveTab, intent)
90+ require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
91+ require.Equal(t, domain.PaneStableID(""), captured.PaneID)
92+ require.Same(t, ac, captured.Client)
93+}
94+
95+func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
96+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
97+ defer releaseAll(releases)
98+ d.mu.Lock()
99+ delete(d.sessions, domain.SessionID("destination"))
100+ d.mu.Unlock()
101+
102+ d.handleInput(source, ac, []byte("\x1b "))
103+ d.handleInput(source, ac, []byte("MPN\r"))
104+
105+ require.False(t, ac.overlays.pickerActive())
106+ history := d.notices.history()
107+ require.NotEmpty(t, history)
108+ require.Equal(t, "No destination available.", history[0].Message)
109+}
110+
111+func TestMovePickerEnterCommitsMovePane(t *testing.T) {
112+ d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
113+ defer releaseAll(releases)
114+ source.mu.Lock()
115+ source.client = nil
116+ source.mu.Unlock()
117+ sourceTab := source.tabs[0]
118+ moved := sourceTab.focusedPane()
119+
120+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
121+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
122+ TabID: "source-tab", PaneID: "source-pane",
123+ }))
124+ d.handlePickerInput(ac, []byte("\r"))
125+
126+ require.False(t, ac.overlays.pickerActive())
127+ require.Nil(t, source.tabs)
128+ require.Same(t, moved, destinationTab.panes[moved.id])
129+ require.Same(t, destination, moved.ownerSnapshot().session)
130+}
131+
132+func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
133+ d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
134+ defer releaseAll(releases)
135+ source.mu.Lock()
136+ source.client = nil
137+ source.mu.Unlock()
138+ sourceTab := source.tabs[0]
139+ moved := sourceTab.focusedPane()
140+
141+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
142+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
143+ TabID: "source-tab", PaneID: "source-pane",
144+ }))
145+ ac.overlays.pickerMu.Lock()
146+ target, ok := ac.overlays.picker.Selected()
147+ ac.overlays.pickerMu.Unlock()
148+ require.True(t, ok)
149+ require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{
150+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
151+ TabID: "source-tab", PaneID: "source-pane",
152+ }, target))
153+
154+ require.Nil(t, source.tabs)
155+ require.Same(t, moved, destinationTab.panes[moved.id])
156+ require.Same(t, destination, moved.ownerSnapshot().session)
157+}
158+
159+func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
160+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
161+ defer releaseAll(releases)
162+ source.mu.Lock()
163+ source.client = nil
164+ source.mu.Unlock()
165+ movedTab := source.tabs[0]
166+ movedTab.stableID = "moved-tab"
167+
168+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{
169+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
170+ TabID: "moved-tab",
171+ }))
172+ d.handlePickerInput(ac, []byte("\r"))
173+
174+ require.False(t, ac.overlays.pickerActive())
175+ require.Nil(t, source.tabs)
176+ require.Len(t, destination.tabs, 2)
177+ require.Same(t, movedTab, destination.tabs[1])
178+}
179+
180+func TestMovePickerEscapePerformsNoMutation(t *testing.T) {
181+ clk := &signalClock{timers: make(chan *signalTimer, 16)}
182+ d, source, ac, _, _, releases := setupMovePickerSessionsWithClock(t, clk, 0)
183+ defer releaseAll(releases)
184+ before := len(source.tabs)
185+
186+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
187+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
188+ TabID: "source-tab", PaneID: "source-pane",
189+ }))
190+ for len(clk.timers) > 0 {
191+ <-clk.timers
192+ }
193+ d.handlePickerInput(ac, []byte("\x1b"))
194+ timer := <-clk.timers
195+ timer.ch <- time.Now()
196+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
197+ require.Len(t, source.tabs, before)
198+}
199+
200+func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {
201+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
202+ defer releaseAll(releases)
203+
204+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
205+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
206+ TabID: "source-tab", PaneID: "source-pane",
207+ }))
208+ d.mu.Lock()
209+ delete(d.sessions, destination.id)
210+ d.mu.Unlock()
211+
212+ d.handlePickerInput(ac, []byte("\r"))
213+
214+ require.False(t, ac.overlays.pickerActive())
215+ history := d.notices.history()
216+ require.NotEmpty(t, history)
217+ require.Equal(t, "Destination is no longer available.", history[0].Message)
218+ require.Len(t, source.tabs, 1)
219+}
220+
221+func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
222+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
223+ defer releaseAll(releases)
224+ sourceTab := source.tabs[0]
225+
226+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
227+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
228+ TabID: "source-tab", PaneID: "source-pane",
229+ }))
230+ sourceTab.mu.Lock()
231+ delete(sourceTab.panes, sourceTab.tree.Focus)
232+ sourceTab.mu.Unlock()
233+
234+ d.handlePickerInput(ac, []byte("\r"))
235+
236+ require.True(t, ac.overlays.pickerActive())
237+ history := d.notices.history()
238+ require.NotEmpty(t, history)
239+ require.Equal(t, "Pane no longer exists.", history[0].Message)
240+}
241+
242+func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
243+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
244+ defer releaseAll(releases)
245+
246+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{
247+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
248+ TabID: "source-tab",
249+ }))
250+ source.mu.Lock()
251+ source.tabs = nil
252+ source.mu.Unlock()
253+
254+ d.handlePickerInput(ac, []byte("\r"))
255+
256+ require.True(t, ac.overlays.pickerActive())
257+ history := d.notices.history()
258+ require.NotEmpty(t, history)
259+ require.Equal(t, "Tab no longer exists.", history[0].Message)
260+}
261+
262+func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) {
263+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
264+ defer releaseAll(releases)
265+
266+ require.NoError(t, paletteExec{d: d, sess: source, ac: ac}.OpenMovePanePicker())
267+ replacement := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
268+ replacement.initOverlays()
269+ replacement.setSession(source)
270+ source.mu.Lock()
271+ source.client = replacement
272+ source.mu.Unlock()
273+
274+ d.handlePickerInput(ac, []byte("\r"))
275+
276+ require.True(t, ac.overlays.pickerActive())
277+ require.Len(t, source.tabs, 1)
278+ require.Len(t, destination.tabs, 1)
279+ history := d.notices.history()
280+ require.NotEmpty(t, history)
281+ require.Equal(t, "Source client is no longer active.", history[0].Message)
282+}
283+
284+func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) {
285+ d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0)
286+ defer releaseAll(releases)
287+ follower.replaceTransport(&closeTrackingTransport{})
288+ require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
289+
290+ displacedTransport := &closeTrackingTransport{}
291+ displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size}
292+ displaced.initOverlays()
293+ displaced.setSession(destination)
294+ destination.mu.Lock()
295+ destination.client = displaced
296+ destination.mu.Unlock()
297+ require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
298+
299+ var pickerClosed atomic.Bool
300+ d.afterDisplacedCleanupStarted = func() {
301+ require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization")
302+ pickerClosed.Store(true)
303+ }
304+ defer func() { d.afterDisplacedCleanupStarted = nil }()
305+
306+ require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{
307+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
308+ TabID: "source-tab", PaneID: "source-pane",
309+ }))
310+ d.handlePickerInput(follower, []byte("\r"))
311+ d.attachmentCleanupWg.Wait()
312+
313+ require.True(t, pickerClosed.Load())
314+ require.False(t, follower.overlays.pickerActive())
315+ frames := displacedTransport.Sends()
316+ require.NotEmpty(t, frames)
317+ require.Equal(t, ports.MsgOutput, frames[0].Type)
318+}
319+
320+func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
321+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
322+ defer releaseAll(releases)
323+
324+ d.handleInput(source, ac, []byte("\x1b "))
325+ d.handleInput(source, ac, []byte("MPN\r"))
326+ require.True(t, ac.overlays.pickerActive())
327+ require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open")
328+ source.dispatchMu.Unlock()
329+}
A internal/usecase/daemon/move_rejection.go +187 -0
@@ -0,0 +1,187 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+)
10+
11+// moveRejectionReason is the daemon API category for a rejected move. It keeps
12+// callers independent of topology and layout implementation errors.
13+type moveRejectionReason uint8
14+
15+const (
16+ moveRejectionInvalid moveRejectionReason = iota + 1
17+ moveRejectionNoDestination
18+ moveRejectionFloatingWarming
19+ moveRejectionFinalSourceFloating
20+ moveRejectionStaleTarget
21+ moveRejectionTooSmall
22+ moveRejectionPaneIDExhausted
23+)
24+
25+// moveRejection carries a stable category while Unwrap preserves identities
26+// relied on by lower-level move tests and callers.
27+type moveRejection struct {
28+ Reason moveRejectionReason
29+ msg string
30+ cause error
31+}
32+
33+func (e *moveRejection) Error() string { return e.msg }
34+func (e *moveRejection) Unwrap() error { return e.cause }
35+
36+// moveRejectionDescriptor is the canonical presentation for one rejection
37+// reason. Palette and control adapters only wrap these values for their
38+// respective transports.
39+type moveRejectionDescriptor struct {
40+ Reason moveRejectionReason
41+ PaletteCode domain.NoticeCode
42+ PaletteSeverity domain.NoticeSeverity
43+ PaletteText string
44+ CommandCode uint16
45+ CommandText string
46+}
47+
48+func moveRejectionDescriptorFor(err error) (moveRejectionDescriptor, bool) {
49+ reason, ok := moveRejectionReasonFor(err)
50+ if !ok {
51+ return moveRejectionDescriptor{}, false
52+ }
53+ return moveRejectionDescriptorByReason(reason), true
54+}
55+
56+func moveRejectionReasonFor(err error) (moveRejectionReason, bool) {
57+ var rejection *moveRejection
58+ if errors.As(err, &rejection) {
59+ return rejection.Reason, true
60+ }
61+ if errors.Is(err, errMoveLifecycleUnavailable) {
62+ return moveRejectionStaleTarget, true
63+ }
64+ return 0, false
65+}
66+
67+func moveRejectionDescriptorByReason(reason moveRejectionReason) moveRejectionDescriptor {
68+ descriptor := moveRejectionDescriptor{
69+ Reason: reason,
70+ PaletteCode: domain.NoticeInternal,
71+ PaletteSeverity: domain.NoticeError,
72+ PaletteText: "Move failed.",
73+ CommandCode: ports.ErrNoSuchTarget,
74+ CommandText: "move request is invalid",
75+ }
76+ switch reason {
77+ case moveRejectionNoDestination:
78+ descriptor.PaletteCode = domain.NoticeSessionUnavailable
79+ descriptor.PaletteSeverity = domain.NoticeWarn
80+ descriptor.PaletteText = "No destination available."
81+ descriptor.CommandText = "no move destination available"
82+ case moveRejectionFloatingWarming:
83+ descriptor.PaletteCode = domain.NoticeSessionUnavailable
84+ descriptor.PaletteSeverity = domain.NoticeWarn
85+ descriptor.PaletteText = "Wait for the floating pane to finish opening."
86+ descriptor.CommandText = "wait for the floating pane to finish opening"
87+ case moveRejectionFinalSourceFloating:
88+ descriptor.PaletteCode = domain.NoticeLayoutTooSmall
89+ descriptor.PaletteSeverity = domain.NoticeWarn
90+ descriptor.PaletteText = "Close the floating pane or move the whole tab."
91+ descriptor.CommandText = "close the floating pane or move the whole tab"
92+ case moveRejectionStaleTarget:
93+ descriptor.PaletteCode = domain.NoticeSessionUnavailable
94+ descriptor.PaletteSeverity = domain.NoticeWarn
95+ descriptor.PaletteText = "Destination is no longer available."
96+ descriptor.CommandText = "move target is no longer available"
97+ case moveRejectionTooSmall:
98+ descriptor.PaletteCode = domain.NoticeLayoutTooSmall
99+ descriptor.PaletteSeverity = domain.NoticeWarn
100+ descriptor.PaletteText = "Not enough space in destination tab."
101+ descriptor.CommandText = "not enough space in destination tab"
102+ case moveRejectionPaneIDExhausted:
103+ descriptor.PaletteText = "Destination pane IDs are exhausted."
104+ descriptor.CommandText = "destination pane IDs are exhausted"
105+ }
106+ return descriptor
107+}
108+
109+func movePickerUserError(err error) error {
110+ if err == nil {
111+ return nil
112+ }
113+ var userErr *domain.UserError
114+ if errors.As(err, &userErr) {
115+ return err
116+ }
117+ descriptor, ok := moveRejectionDescriptorFor(err)
118+ if !ok {
119+ descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid)
120+ }
121+ if descriptor.PaletteSeverity == domain.NoticeWarn {
122+ return domain.UserWarn(descriptor.PaletteCode, descriptor.PaletteText, err)
123+ }
124+ return domain.UserErr(descriptor.PaletteCode, descriptor.PaletteText, err)
125+}
126+
127+func isMoveCommandError(err error) bool {
128+ _, ok := moveRejectionReasonFor(err)
129+ return ok
130+}
131+
132+func moveCommandFailure(err error) ports.CommandResult {
133+ descriptor, ok := moveRejectionDescriptorFor(err)
134+ if !ok {
135+ descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid)
136+ }
137+ return commandFailure(descriptor.CommandCode, descriptor.CommandText)
138+}
139+
140+func normalizeMoveRejection(err error) error {
141+ if err == nil {
142+ return nil
143+ }
144+ var rejection *moveRejection
145+ if errors.As(err, &rejection) {
146+ return err
147+ }
148+ if errors.Is(err, layout.ErrTooSmall) {
149+ return errMoveTooSmall
150+ }
151+ if errors.Is(err, errMoveLifecycleUnavailable) {
152+ return &moveRejection{Reason: moveRejectionStaleTarget, msg: errMoveStaleTarget.msg, cause: errors.Join(errMovePaneInvalid, err)}
153+ }
154+ return &moveRejection{Reason: moveRejectionInvalid, msg: errMovePaneInvalid.msg, cause: errors.Join(errMovePaneInvalid, err)}
155+}
156+
157+var (
158+ errMovePaneInvalid = &moveRejection{Reason: moveRejectionInvalid, msg: "move request is invalid"}
159+ errNoMoveDestination = &moveRejection{
160+ Reason: moveRejectionNoDestination,
161+ msg: "no eligible move destination",
162+ }
163+ errMoveFloatingWarming = &moveRejection{
164+ Reason: moveRejectionFloatingWarming,
165+ msg: "floating pane is still opening",
166+ }
167+ errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane")
168+ errMoveFinalSourceFloating = &moveRejection{
169+ Reason: moveRejectionFinalSourceFloating,
170+ msg: "final source pane cannot move while its tab has a floating pane",
171+ cause: errMovePaneFloatingSibling,
172+ }
173+ errMoveStaleTarget = &moveRejection{
174+ Reason: moveRejectionStaleTarget,
175+ msg: "move target is no longer available",
176+ cause: errMovePaneInvalid,
177+ }
178+ errMoveTooSmall = &moveRejection{
179+ Reason: moveRejectionTooSmall,
180+ msg: "move destination is too small",
181+ cause: layout.ErrTooSmall,
182+ }
183+ errMovePaneIDExhausted = &moveRejection{
184+ Reason: moveRejectionPaneIDExhausted,
185+ msg: "destination pane IDs exhausted",
186+ }
187+)
A internal/usecase/daemon/move_rejection_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ err error
18+ reason moveRejectionReason
19+ legacy error
20+ }{
21+ {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination},
22+ {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming},
23+ {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, legacy: errMovePaneFloatingSibling},
24+ {name: "stale target", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, legacy: errMovePaneInvalid},
25+ {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, legacy: layout.ErrTooSmall},
26+ {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted},
27+ {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ var rejection *moveRejection
32+ require.ErrorAs(t, tt.err, &rejection)
33+ require.Equal(t, tt.reason, rejection.Reason)
34+ if tt.legacy != nil {
35+ require.True(t, errors.Is(tt.err, tt.legacy))
36+ }
37+ })
38+ }
39+ require.NotErrorIs(t, errMoveFloatingWarming, errMoveFinalSourceFloating)
40+}
41+
42+func TestNormalizeMoveRejectionDoesNotLeakInternalErrors(t *testing.T) {
43+ internal := errors.New("layout implementation detail")
44+ got := normalizeMoveRejection(internal)
45+ var rejection *moveRejection
46+ require.ErrorAs(t, got, &rejection)
47+ require.Equal(t, moveRejectionInvalid, rejection.Reason)
48+ require.Equal(t, "move request is invalid", got.Error())
49+ require.ErrorIs(t, got, internal)
50+ require.ErrorIs(t, got, errMovePaneInvalid)
51+}
52+
53+func TestMoveRejectionPresentationParity(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ err error
57+ reason moveRejectionReason
58+ paletteCode domain.NoticeCode
59+ paletteSeverity domain.NoticeSeverity
60+ paletteText string
61+ commandText string
62+ }{
63+ {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "No destination available.", commandText: "no move destination available"},
64+ {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Wait for the floating pane to finish opening.", commandText: "wait for the floating pane to finish opening"},
65+ {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Close the floating pane or move the whole tab.", commandText: "close the floating pane or move the whole tab"},
66+ {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Not enough space in destination tab.", commandText: "not enough space in destination tab"},
67+ {name: "stale", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"},
68+ {name: "lifecycle target", err: errMoveLifecycleUnavailable, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"},
69+ {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Destination pane IDs are exhausted.", commandText: "destination pane IDs are exhausted"},
70+ {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Move failed.", commandText: "move request is invalid"},
71+ }
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ descriptor, ok := moveRejectionDescriptorFor(tt.err)
75+ require.True(t, ok)
76+ require.Equal(t, tt.reason, descriptor.Reason)
77+
78+ paletteErr := movePickerUserError(tt.err)
79+ var userErr *domain.UserError
80+ require.ErrorAs(t, paletteErr, &userErr)
81+ require.Equal(t, descriptor.PaletteCode, userErr.Code)
82+ require.Equal(t, descriptor.PaletteSeverity, userErr.Severity)
83+ require.Equal(t, descriptor.PaletteText, userErr.Msg)
84+ require.Equal(t, tt.paletteCode, userErr.Code)
85+ require.Equal(t, tt.paletteSeverity, userErr.Severity)
86+ require.Equal(t, tt.paletteText, userErr.Msg)
87+
88+ result := moveCommandFailure(tt.err)
89+ require.False(t, result.OK)
90+ require.Equal(t, descriptor.CommandCode, result.Code)
91+ require.Equal(t, descriptor.CommandText, result.Text)
92+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
93+ require.Equal(t, tt.commandText, result.Text)
94+ })
95+ }
96+}
A internal/usecase/daemon/move_resize_fences.go +207 -0
@@ -0,0 +1,207 @@
1+package daemon
2+
3+import (
4+ "cmp"
5+ "slices"
6+)
7+
8+// moveResizeFences excludes concurrent layout publishers while a move
9+// revalidates and publishes ownership. It is not an architecture lock. Fences
10+// are always acquired session -> tab -> pane by immutable stable identity and
11+// released in exact reverse order.
12+type moveResizeFences struct {
13+ sessions []*session
14+ tabs []*tab
15+ panes []*pane
16+ held bool
17+}
18+
19+// newMovePaneResizeFences covers both sessions, both affected layouts, and
20+// only the pane whose owner will change. Stable-identity deduplication also
21+// handles moves between tabs in the same session.
22+func newMovePaneResizeFences(source, destination *session, sourceTab, destinationTab *tab, moved *pane) *moveResizeFences {
23+ return newMoveResizeFences(
24+ []*session{source, destination},
25+ []*tab{sourceTab, destinationTab},
26+ []*pane{moved},
27+ )
28+}
29+
30+// newMoveTabResizeFences snapshots every process-bearing pane transferred with
31+// a tab. The acquire callback must revalidate this exact tiled membership and
32+// installed floating slot after all fences are held, because this snapshot is
33+// intentionally taken before waiting on any resize transaction.
34+func newMoveTabResizeFences(source, destination *session, moved *tab) *moveResizeFences {
35+ if moved == nil {
36+ return newMoveResizeFences([]*session{source, destination}, nil, nil)
37+ }
38+ moved.mu.Lock()
39+ panes := make([]*pane, 0, len(moved.panes)+1)
40+ for _, p := range moved.panes {
41+ panes = append(panes, p)
42+ }
43+ if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
44+ panes = append(panes, moved.floating.pane)
45+ }
46+ moved.mu.Unlock()
47+ return newMoveResizeFences([]*session{source, destination}, []*tab{moved}, panes)
48+}
49+
50+func newMoveResizeFences(sessions []*session, tabs []*tab, panes []*pane) *moveResizeFences {
51+ byID := make(map[string]*session, len(sessions))
52+ for _, sess := range sessions {
53+ if sess == nil {
54+ continue
55+ }
56+ if _, exists := byID[string(sess.id)]; !exists {
57+ byID[string(sess.id)] = sess
58+ }
59+ }
60+ ordered := make([]*session, 0, len(byID))
61+ for _, sess := range byID {
62+ ordered = append(ordered, sess)
63+ }
64+ slices.SortFunc(ordered, func(left, right *session) int {
65+ return cmp.Compare(left.id, right.id)
66+ })
67+
68+ tabsByID := make(map[string]*tab, len(tabs))
69+ for _, tb := range tabs {
70+ if tb == nil {
71+ continue
72+ }
73+ if _, exists := tabsByID[tb.stableID]; !exists {
74+ tabsByID[tb.stableID] = tb
75+ }
76+ }
77+ orderedTabs := make([]*tab, 0, len(tabsByID))
78+ for _, tb := range tabsByID {
79+ orderedTabs = append(orderedTabs, tb)
80+ }
81+ slices.SortFunc(orderedTabs, func(left, right *tab) int {
82+ return cmp.Compare(left.stableID, right.stableID)
83+ })
84+
85+ panesByID := make(map[string]*pane, len(panes))
86+ for _, p := range panes {
87+ if p == nil {
88+ continue
89+ }
90+ if _, exists := panesByID[p.stableID]; !exists {
91+ panesByID[p.stableID] = p
92+ }
93+ }
94+ orderedPanes := make([]*pane, 0, len(panesByID))
95+ for _, p := range panesByID {
96+ orderedPanes = append(orderedPanes, p)
97+ }
98+ slices.SortFunc(orderedPanes, func(left, right *pane) int {
99+ return cmp.Compare(left.stableID, right.stableID)
100+ })
101+ return &moveResizeFences{sessions: ordered, tabs: orderedTabs, panes: orderedPanes}
102+}
103+
104+// acquire waits for every resize fence before invoking revalidateAndPublish.
105+// Callers must enter with no daemon, session, tab, or pane architecture lock
106+// held. The callback is the only architecture-lock window: under the canonical
107+// architecture lock order it must revalidate the sessions, tabs, tiled pane
108+// memberships, and installed floating slot captured before this wait; publish
109+// every changed pane owner; and bump each affected tab's layoutGeneration. A
110+// false result (or panic) releases every acquired fence in reverse order. A true
111+// result retains all fences so no resize can observe half-published ownership;
112+// the caller must Release after architecture locks are dropped and before PTY
113+// I/O, applyTabLayout, or applySessionLayout.
114+func (f *moveResizeFences) acquire(revalidateAndPublish func() bool) (accepted bool) {
115+ if f == nil || f.held {
116+ return false
117+ }
118+ for _, sess := range f.sessions {
119+ sess.layoutApplyMu.Lock()
120+ }
121+ for _, tb := range f.tabs {
122+ tb.layoutApplyMu.Lock()
123+ }
124+ for _, p := range f.panes {
125+ p.resizeMu.Lock()
126+ }
127+ f.held = true
128+ defer func() {
129+ if !accepted {
130+ f.Release()
131+ }
132+ }()
133+ if revalidateAndPublish == nil {
134+ return false
135+ }
136+ accepted = revalidateAndPublish()
137+ return accepted
138+}
139+
140+func (d *Daemon) observeBeforeResizeOwnerPostEffect(effect resizeOwnerPostEffect) {
141+ if d != nil && d.beforeResizeOwnerPostEffect != nil {
142+ d.beforeResizeOwnerPostEffect(effect)
143+ }
144+}
145+
146+// acquireResizeOwnerPostEffectFences acquires and validates the complete
147+// canonical session/tab/pane fence set. It performs no callbacks or external
148+// operations and returns the fences held on success.
149+func acquireResizeOwnerPostEffectFences(members []resizeMember) *moveResizeFences {
150+ if len(members) == 0 {
151+ return nil
152+ }
153+ sessions := make([]*session, 0, len(members))
154+ tabs := make([]*tab, 0, len(members))
155+ panes := make([]*pane, 0, len(members))
156+ for i := range members {
157+ member := &members[i]
158+ sessions = append(sessions, member.session)
159+ tabs = append(tabs, member.tab)
160+ panes = append(panes, member.pane)
161+ }
162+ fences := newMoveResizeFences(sessions, tabs, panes)
163+ if !fences.acquire(func() bool { return resizeMembersOwnerCurrent(members) }) {
164+ return nil
165+ }
166+ return fences
167+}
168+
169+// publishResizeOwnerPostEffect linearizes an in-memory resize effect with pane
170+// ownership publication. The deterministic seam runs before fence acquisition,
171+// then the complete canonical session/tab/pane fence set is acquired and every
172+// immutable owner lease is revalidated. The callback must publish in-memory
173+// state only; timer, PTY, transport, repository, and renderer work belongs
174+// after this method returns.
175+func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect resizeOwnerPostEffect, publish func()) bool {
176+ if publish == nil {
177+ return false
178+ }
179+ d.observeBeforeResizeOwnerPostEffect(effect)
180+ if len(members) == 0 {
181+ publish()
182+ return true
183+ }
184+ fences := acquireResizeOwnerPostEffectFences(members)
185+ if fences == nil {
186+ return false
187+ }
188+ defer fences.Release()
189+ publish()
190+ return true
191+}
192+
193+func (f *moveResizeFences) Release() {
194+ if f == nil || !f.held {
195+ return
196+ }
197+ for i := len(f.panes) - 1; i >= 0; i-- {
198+ f.panes[i].resizeMu.Unlock()
199+ }
200+ for i := len(f.tabs) - 1; i >= 0; i-- {
201+ f.tabs[i].layoutApplyMu.Unlock()
202+ }
203+ for i := len(f.sessions) - 1; i >= 0; i-- {
204+ f.sessions[i].layoutApplyMu.Unlock()
205+ }
206+ f.held = false
207+}
A internal/usecase/daemon/move_resize_fences_test.go +323 -0
@@ -0,0 +1,323 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
14+ t.Helper()
15+ require.Eventually(t, func() bool {
16+ if !mu.TryLock() {
17+ return true
18+ }
19+ mu.Unlock()
20+ return false
21+ }, time.Second, time.Millisecond, message)
22+}
23+
24+func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25+ first := &session{id: "a-first"}
26+ second := &session{id: "z-second"}
27+ fences := newMoveResizeFences([]*session{second, first}, nil, nil)
28+
29+ first.layoutApplyMu.Lock()
30+ acquired := make(chan bool, 1)
31+ go func() {
32+ acquired <- fences.acquire(func() bool { return true })
33+ }()
34+
35+ require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID")
36+ second.layoutApplyMu.Unlock()
37+ first.layoutApplyMu.Unlock()
38+
39+ require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire"))
40+ fences.Release()
41+ require.True(t, first.layoutApplyMu.TryLock())
42+ first.layoutApplyMu.Unlock()
43+ require.True(t, second.layoutApplyMu.TryLock())
44+ second.layoutApplyMu.Unlock()
45+}
46+
47+func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48+ sess := &session{id: "session"}
49+ first := &tab{stableID: "a-first"}
50+ second := &tab{stableID: "z-second"}
51+ fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
52+
53+ first.layoutApplyMu.Lock()
54+ acquired := make(chan bool, 1)
55+ go func() {
56+ acquired <- fences.acquire(func() bool { return true })
57+ }()
58+
59+ requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence")
60+ require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID")
61+ second.layoutApplyMu.Unlock()
62+ select {
63+ case <-acquired:
64+ t.Fatal("resize fences ignored the blocked first tab")
65+ default:
66+ }
67+ first.layoutApplyMu.Unlock()
68+
69+ require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire"))
70+ fences.Release()
71+}
72+
73+func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74+ sess := &session{id: "session"}
75+ tb := &tab{stableID: "tab"}
76+ first := &pane{stableID: "a-first"}
77+ second := &pane{stableID: "z-second"}
78+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first})
79+
80+ first.resizeMu.Lock()
81+ acquired := make(chan bool, 1)
82+ go func() {
83+ acquired <- fences.acquire(func() bool { return true })
84+ }()
85+
86+ requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait")
87+ requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait")
88+ require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID")
89+ second.resizeMu.Unlock()
90+ select {
91+ case <-acquired:
92+ t.Fatal("resize fences ignored the blocked first pane")
93+ default:
94+ }
95+ first.resizeMu.Unlock()
96+
97+ require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire"))
98+ fences.Release()
99+}
100+
101+func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102+ sess := &session{id: "session"}
103+ tb := &tab{stableID: "tab"}
104+ p := &pane{stableID: "pane"}
105+
106+ fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p})
107+
108+ require.Len(t, fences.sessions, 1)
109+ require.Len(t, fences.tabs, 1)
110+ require.Len(t, fences.panes, 1)
111+ require.True(t, fences.acquire(func() bool { return true }))
112+ fences.Release()
113+}
114+
115+func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116+ resolved := &session{id: "same"}
117+ collision := &session{id: "same"}
118+
119+ fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120+
121+ require.Len(t, fences.sessions, 1)
122+ require.Same(t, resolved, fences.sessions[0])
123+}
124+
125+func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) {
126+ resolved := &tab{stableID: "same"}
127+ collision := &tab{stableID: "same"}
128+
129+ fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil)
130+
131+ require.Len(t, fences.tabs, 1)
132+ require.Same(t, resolved, fences.tabs[0])
133+}
134+
135+func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) {
136+ resolved := &pane{stableID: "same"}
137+ collision := &pane{stableID: "same"}
138+
139+ fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision})
140+
141+ require.Len(t, fences.panes, 1)
142+ require.Same(t, resolved, fences.panes[0])
143+}
144+
145+func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146+ sess := &session{id: "session"}
147+ tb := &tab{stableID: "tab"}
148+ p := &pane{stableID: "pane"}
149+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
150+
151+ membershipCurrent := true
152+ p.resizeMu.Lock()
153+ acquired := make(chan bool, 1)
154+ callbackCalled := make(chan struct{})
155+ go func() {
156+ acquired <- fences.acquire(func() bool {
157+ close(callbackCalled)
158+ return membershipCurrent
159+ })
160+ }()
161+ requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
162+ membershipCurrent = false
163+ p.resizeMu.Unlock()
164+
165+ require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return"))
166+ awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition")
167+ for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
168+ require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence")
169+ lock.Unlock()
170+ }
171+}
172+
173+func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174+ sess := &session{id: "session"}
175+ tb := &tab{stableID: "tab"}
176+ p := &pane{stableID: "pane"}
177+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
178+
179+ require.Panics(t, func() {
180+ fences.acquire(func() bool { panic("publication failed") })
181+ })
182+ for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
183+ require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set")
184+ lock.Unlock()
185+ }
186+}
187+
188+func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) {
189+ d := &Daemon{}
190+ sess := &session{id: "session"}
191+ tb := &tab{stableID: "tab"}
192+ p := &pane{stableID: "pane"}
193+ members := []resizeMember{{session: sess, tab: tb, pane: p}}
194+
195+ var recovered any
196+ func() {
197+ defer func() { recovered = recover() }()
198+ d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
199+ panic("post effect failed")
200+ })
201+ }()
202+ require.Equal(t, "post effect failed", recovered)
203+
204+ for _, fence := range []struct {
205+ name string
206+ mu *sync.Mutex
207+ }{
208+ {name: "session", mu: &sess.layoutApplyMu},
209+ {name: "tab", mu: &tb.layoutApplyMu},
210+ {name: "pane", mu: &p.resizeMu},
211+ } {
212+ require.True(t, fence.mu.TryLock(), "%s fence leaked after panic recovery", fence.name)
213+ fence.mu.Unlock()
214+ }
215+}
216+
217+func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
218+ d := &Daemon{}
219+ sess := &session{id: "session"}
220+ tb := &tab{stableID: "tab"}
221+ p := &pane{stableID: "pane"}
222+ fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
223+
224+ p.resizeMu.Lock()
225+ acquired := make(chan bool, 1)
226+ go func() {
227+ acquired <- fences.acquire(func() bool { return true })
228+ }()
229+ requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
230+
231+ for name, lock := range map[string]*sync.Mutex{
232+ "daemon": &d.mu,
233+ "session": &sess.mu,
234+ "tab": &tb.mu,
235+ } {
236+ require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name)
237+ lock.Unlock()
238+ }
239+ p.resizeMu.Unlock()
240+
241+ require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released"))
242+ fences.Release()
243+}
244+
245+func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
246+ source := &session{id: "source"}
247+ destination := &session{id: "destination"}
248+ sourceTab := &tab{stableID: "a-source-tab"}
249+ destinationTab := &tab{stableID: "z-destination-tab"}
250+ p := &pane{stableID: "pane"}
251+ fences := newMoveResizeFences(
252+ []*session{source, destination},
253+ []*tab{sourceTab, destinationTab},
254+ []*pane{p},
255+ )
256+
257+ require.True(t, fences.acquire(func() bool {
258+ // This callback is the move's architecture-lock window: membership is
259+ // revalidated before owner publication and both affected generations bump.
260+ sourceTab.mu.Lock()
261+ destinationTab.mu.Lock()
262+ sourceTab.bumpLayoutGenerationLocked()
263+ destinationTab.bumpLayoutGenerationLocked()
264+ destinationTab.mu.Unlock()
265+ sourceTab.mu.Unlock()
266+ return true
267+ }))
268+ require.Equal(t, uint64(1), sourceTab.layoutGeneration)
269+ require.Equal(t, uint64(1), destinationTab.layoutGeneration)
270+ for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
271+ require.False(t, lock.TryLock(), "publication returned without retaining every resize fence")
272+ }
273+
274+ // Move callers release here, before applyTabLayout or any other PTY I/O.
275+ fences.Release()
276+ for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
277+ require.True(t, lock.TryLock(), "publication fence did not release before layout application")
278+ lock.Unlock()
279+ }
280+}
281+
282+func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
283+ source := &session{id: "source"}
284+ destination := &session{id: "destination"}
285+ sourceTab := &tab{stableID: "source-tab"}
286+ destinationTab := &tab{stableID: "destination-tab"}
287+ moved := &pane{stableID: "moved-pane"}
288+
289+ fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved)
290+
291+ require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
292+ require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs)
293+ require.Equal(t, []*pane{moved}, fences.panes)
294+}
295+
296+func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
297+ for _, tc := range []struct {
298+ name string
299+ state floatingState
300+ }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
301+ t.Run(tc.name, func(t *testing.T) {
302+ source := &session{id: "source"}
303+ destination := &session{id: "destination"}
304+ first := &pane{stableID: "first"}
305+ second := &pane{stableID: "second"}
306+ floating := &pane{stableID: "floating"}
307+ movedTab := &tab{
308+ stableID: "moved-tab",
309+ panes: map[layout.PaneID]*pane{
310+ "pane-1": first,
311+ "pane-2": second,
312+ },
313+ floating: floatingSlot{state: tc.state, pane: floating, generation: 7},
314+ }
315+
316+ fences := newMoveTabResizeFences(source, destination, movedTab)
317+
318+ require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
319+ require.Equal(t, []*tab{movedTab}, fences.tabs)
320+ require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes)
321+ })
322+ }
323+}
A internal/usecase/daemon/move_stale_destination_test.go +59 -0
@@ -0,0 +1,59 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) {
12+ movedPTY, releaseMoved := newBlockingPTY(t)
13+ destinationPTY, releaseDestination := newBlockingPTY(t)
14+ defer releaseMoved()
15+ defer releaseDestination()
16+
17+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
18+ sourceTab := source.tabs[0]
19+ sourceTab.stableID = "source-tab"
20+ moved := sourceTab.focusedPane()
21+ destination := &session{
22+ id: "destination", name: "destination", ephemeral: true,
23+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
24+ }
25+ destinationTab := destination.tabs[0]
26+ publishTiledPaneOwners(destination, destinationTab)
27+ d.mu.Lock()
28+ d.sessions[destination.id] = destination
29+ d.mu.Unlock()
30+
31+ sourceTab.mu.Lock()
32+ beforeSource := sourceTab.tree.Clone()
33+ sourceTab.mu.Unlock()
34+ destinationTab.mu.Lock()
35+ beforeDestination := destinationTab.tree.Clone()
36+ destinationTab.mu.Unlock()
37+ beforeOwner := moved.ownerSnapshot()
38+ staleIncarnation := destination.incarnation
39+ staleIncarnation[0]++
40+ err := d.movePane(movePaneRequest{
41+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
42+ SourceTabID: domain.TabStableID(sourceTab.stableID),
43+ SourcePaneID: domain.PaneStableID(moved.stableID),
44+ Destination: moveSessionLocator{ID: destination.id, Incarnation: staleIncarnation},
45+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
46+ })
47+ require.ErrorIs(t, err, errMoveStaleTarget)
48+ sourceTab.mu.Lock()
49+ sourceAfter := sourceTab.tree.Clone()
50+ sourceMoved := sourceTab.panes[moved.id]
51+ sourceTab.mu.Unlock()
52+ destinationTab.mu.Lock()
53+ destinationAfter := destinationTab.tree.Clone()
54+ destinationTab.mu.Unlock()
55+ require.Equal(t, beforeSource, sourceAfter)
56+ require.Equal(t, beforeDestination, destinationAfter)
57+ require.Same(t, beforeOwner, moved.ownerSnapshot())
58+ require.Same(t, moved, sourceMoved)
59+}
A internal/usecase/daemon/move_tab.go +425 -0
@@ -0,0 +1,425 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+)
9+
10+type moveTabAdmission struct {
11+ tab *tab
12+ sourceIndex int
13+ sourceClient *attachedClient
14+ sourceSnatched []*attachedClient
15+ layoutGeneration uint64
16+ floatingState floatingState
17+ floatingPane *pane
18+ floatingGeneration uint64
19+ panes []*pane
20+ destinationActive *tab
21+ destinationSize domain.Size
22+ finalSource bool
23+}
24+
25+type moveTabCommit struct {
26+ req moveTabRequest
27+ source *session
28+ destination *session
29+ admission moveTabAdmission
30+ handoffFrozen bool
31+ sourceRolesFrozen bool
32+ frozenRoles frozenRoleEffectGates
33+ handoffReq attachmentTransitionRequest
34+ handoffPublication *attachmentPublication
35+ handoffResult attachmentTransitionResult
36+ syncCleanup syncTimerCleanup
37+ sourceCleanupToken attachmentRoleToken
38+ oldTabCancel context.CancelFunc
39+ sourceEmpty bool
40+ retiredParked []parkedAttachmentRetirement
41+ retiredAttachments []detachedAttachmentSnapshot
42+ sourceMetadata domain.CatalogueMetadataUpdate
43+ sourceMetadataValid bool
44+ destMetadata domain.CatalogueMetadataUpdate
45+ destMetadataValid bool
46+ err error
47+}
48+
49+func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) {
50+ if sess == nil || len(admitted) == 0 {
51+ return nil, nil
52+ }
53+ d.mu.Lock()
54+ defer d.mu.Unlock()
55+ sess.mu.Lock()
56+ defer sess.mu.Unlock()
57+ clients := append([]*attachedClient(nil), admitted...)
58+ interrupts := make([]roleTransportInterrupt, 0, len(clients))
59+ for _, ac := range clients {
60+ if ac == nil {
61+ continue
62+ }
63+ if transport := ac.transportSnapshot(); transport.transport != nil {
64+ interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
65+ }
66+ }
67+ return clients, interrupts
68+}
69+
70+func (d *Daemon) moveTab(req moveTabRequest) (result error) {
71+ defer func() { result = normalizeMoveRejection(result) }()
72+ if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
73+ return errMovePaneInvalid
74+ }
75+ d.mu.Lock()
76+ source := moveSessionForLocatorLocked(d, req.Source)
77+ destination := moveSessionForLocatorLocked(d, req.Destination)
78+ d.mu.Unlock()
79+ if source == nil || destination == nil {
80+ return errMoveStaleTarget
81+ }
82+ if source == destination {
83+ return errMovePaneInvalid
84+ }
85+ reservation, err := d.reserveMoveLifecycles(source, destination)
86+ if err != nil {
87+ return errMoveStaleTarget
88+ }
89+ reservationHeld := true
90+ defer func() {
91+ if reservationHeld {
92+ reservation.Release()
93+ }
94+ }()
95+ if d.afterMoveLifecycleReserved != nil {
96+ d.afterMoveLifecycleReserved()
97+ }
98+ unlockDispatch := lockMoveDispatch(source, destination)
99+ dispatchHeld := true
100+ defer func() {
101+ if dispatchHeld {
102+ unlockDispatch()
103+ }
104+ }()
105+
106+ admission, err := d.snapshotMoveTabAdmission(req, source, destination)
107+ if err != nil {
108+ return err
109+ }
110+ if d.afterMoveTabSourceSnapshot != nil {
111+ d.afterMoveTabSourceSnapshot()
112+ }
113+ commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
114+ var frozen frozenRoleEffectGates
115+ if admission.finalSource && admission.sourceClient != nil {
116+ transport := admission.sourceClient.transportSnapshot()
117+ token := source.attachmentToken(admission.sourceClient, transport.transport)
118+ if token.role != attachmentActive || token.transport.transport == nil {
119+ return errMovePaneInvalid
120+ }
121+ commit.handoffReq = attachmentTransitionRequest{
122+ source: source, target: destination, next: admission.sourceClient,
123+ expectedRole: attachmentActive, targetRole: attachmentActive,
124+ expectedTransport: token.transport, sourceToken: &token,
125+ expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",
126+ }
127+ var participants attachmentTransitionParticipants
128+ commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq)
129+ if err != nil {
130+ return err
131+ }
132+ snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
133+ participants.clients = append(participants.clients, snatchedClients...)
134+ participants.interrupts = append(participants.interrupts, snatchedInterrupts...)
135+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
136+ if d.afterRoleEffectGateFrozen != nil {
137+ d.afterRoleEffectGateFrozen("move-tab", ac)
138+ }
139+ }}, participants.clients...)
140+ if !frozen.acquired || !frozen.drained {
141+ frozen.unfreeze()
142+ return errMovePaneInvalid
143+ }
144+ commit.handoffFrozen = true
145+ commit.sourceRolesFrozen = true
146+ commit.handoffReq.roleEffectsFrozen = true
147+ } else if admission.finalSource && len(admission.sourceSnatched) > 0 {
148+ clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
149+ frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...)
150+ if !frozen.acquired || !frozen.drained {
151+ frozen.unfreeze()
152+ return errMovePaneInvalid
153+ }
154+ commit.sourceRolesFrozen = true
155+ }
156+ commit.frozenRoles = frozen
157+ rolesFrozen := commit.sourceRolesFrozen
158+ defer func() {
159+ if rolesFrozen {
160+ frozen.unfreeze()
161+ }
162+ }()
163+
164+ fences := newMoveTabResizeFences(source, destination, admission.tab)
165+ if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {
166+ commit.releasePublication()
167+ if commit.err != nil {
168+ return commit.err
169+ }
170+ return errMoveStaleTarget
171+ }
172+ fences.Release()
173+ postcommit := movePanePostcommitPlan{
174+ source: source,
175+ destination: destination,
176+ sourceTab: admission.tab,
177+ destinationTab: admission.tab,
178+ movedPane: firstMovePane(admission.panes),
179+ movedPanes: admission.panes,
180+ sourceCleanupToken: commit.sourceCleanupToken,
181+ handoffResult: commit.handoffResult,
182+ syncCleanup: commit.syncCleanup,
183+ frozenRoles: frozen,
184+ rolesFrozen: rolesFrozen,
185+ unlockDispatch: unlockDispatch,
186+ reservation: reservation,
187+ oldTabCancel: commit.oldTabCancel,
188+ sourceTabWasActive: !commit.sourceEmpty,
189+ sourceTabRemoved: true,
190+ sourceEmpty: commit.sourceEmpty,
191+ retiredParked: commit.retiredParked,
192+ retiredAttachments: commit.retiredAttachments,
193+ sourceMetadata: commit.sourceMetadata,
194+ sourceMetadataValid: commit.sourceMetadataValid,
195+ destinationMetadata: commit.destMetadata,
196+ destinationMetadataValid: commit.destMetadataValid,
197+ }
198+ rolesFrozen = false
199+ dispatchHeld = false
200+ reservationHeld = false
201+ postcommit.execute(d)
202+ return nil
203+}
204+
205+func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) {
206+ d.mu.Lock()
207+ defer d.mu.Unlock()
208+ if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
209+ return nil, errMoveStaleTarget
210+ }
211+ unlockSessions := lockAttachmentSessions(source, destination)
212+ defer unlockSessions()
213+ if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
214+ return nil, errMoveStaleTarget
215+ }
216+ moved := findMoveTabLocked(source, req.SourceTabID)
217+ if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {
218+ return nil, errMoveStaleTarget
219+ }
220+ destinationActive := destination.tabs[destination.active]
221+ destinationActive.mu.Lock()
222+ destinationSize := destinationActive.size
223+ destinationActive.mu.Unlock()
224+ moved.mu.Lock()
225+ defer moved.mu.Unlock()
226+ if !moved.floatingTransferableLocked() {
227+ return nil, errMoveFloatingWarming
228+ }
229+ if moved.tree == nil || moved.tree.Root == nil {
230+ return nil, errMovePaneInvalid
231+ }
232+ if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
233+ return nil, errMoveTooSmall
234+ }
235+ panes := make([]*pane, 0, len(moved.panes)+1)
236+ for _, p := range moved.panes {
237+ panes = append(panes, p)
238+ }
239+ if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
240+ panes = append(panes, moved.floating.pane)
241+ }
242+ idx := indexMoveTabLocked(source, moved)
243+ return &moveTabAdmission{
244+ tab: moved, sourceIndex: idx,
245+ sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source),
246+ layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
247+ floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
248+ panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
249+ finalSource: len(source.tabs) == 1,
250+ }, nil
251+}
252+
253+func (c *moveTabCommit) releasePublication() {
254+ if c.handoffPublication != nil {
255+ c.handoffPublication.unlockCoordinators()
256+ c.handoffPublication = nil
257+ }
258+}
259+
260+func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
261+ d.mu.Lock()
262+ if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
263+ d.mu.Unlock()
264+ return false
265+ }
266+ d.notices.routingMu.Lock()
267+ unlockSessions := lockAttachmentSessions(c.source, c.destination)
268+ defer unlockSessions()
269+ defer d.notices.routingMu.Unlock()
270+ defer d.mu.Unlock()
271+ if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
272+ return false
273+ }
274+ moved := c.admission.tab
275+ if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
276+ len(c.source.tabs) == 1 != c.admission.finalSource {
277+ return false
278+ }
279+ // Attachment validation inspects the expected source tab and therefore must
280+ // precede moved.mu. The resize fences and ordered session locks keep topology
281+ // stable until the tab/pane checks below complete.
282+ if c.handoffFrozen {
283+ if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {
284+ return false
285+ }
286+ publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
287+ if err != nil {
288+ c.err = err
289+ return false
290+ }
291+ c.handoffPublication = publication
292+ defer c.releasePublication()
293+ }
294+ var retirement frozenMoveAttachmentRetirement
295+ if c.admission.finalSource {
296+ if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||
297+ (c.admission.sourceClient != nil) != c.handoffFrozen {
298+ return false
299+ }
300+ var retirementOK bool
301+ retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles)
302+ if !retirementOK {
303+ return false
304+ }
305+ }
306+ if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {
307+ return false
308+ }
309+ c.admission.destinationActive.mu.Lock()
310+ destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize
311+ c.admission.destinationActive.mu.Unlock()
312+ if !destinationSizeCurrent {
313+ return false
314+ }
315+ moved.mu.Lock()
316+ defer moved.mu.Unlock()
317+ if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() ||
318+ moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane ||
319+ moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) {
320+ return false
321+ }
322+ for _, p := range fencedPanes {
323+ p.mu.Lock()
324+ defer p.mu.Unlock()
325+ owner := p.ownerSnapshot()
326+ if owner == nil || owner.session != c.source || owner.tab != moved {
327+ return false
328+ }
329+ }
330+
331+ idx := c.admission.sourceIndex
332+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
333+ if len(c.source.tabs) == 0 {
334+ c.source.active = -1
335+ } else if c.source.active > idx {
336+ c.source.active--
337+ } else if c.source.active == idx {
338+ if idx >= len(c.source.tabs) {
339+ idx = len(c.source.tabs) - 1
340+ }
341+ c.source.active = idx
342+ }
343+ c.destination.tabs = append(c.destination.tabs, moved)
344+ moved.size = c.admission.destinationSize
345+ moved.bumpLayoutGenerationLocked()
346+ c.oldTabCancel = moved.cancel
347+ parent := c.destination.ctx
348+ if parent == nil {
349+ parent = d.serveCtx
350+ }
351+ moved.ctx, moved.cancel = context.WithCancel(parent)
352+ type ownerChange struct {
353+ pane *pane
354+ oldOwner paneEffectLease
355+ newOwner paneEffectLease
356+ }
357+ ownerChanges := make([]ownerChange, 0, len(fencedPanes))
358+ for _, p := range fencedPanes {
359+ floatingGeneration := uint64(0)
360+ if p == moved.floating.pane {
361+ floatingGeneration = moved.floating.generation
362+ }
363+ oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration)
364+ ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner})
365+ }
366+ if d.beforeMoveTabCommit != nil {
367+ d.beforeMoveTabCommit()
368+ }
369+ if c.handoffPublication != nil {
370+ c.destination.active = len(c.destination.tabs) - 1
371+ c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
372+ c.handoffPublication.unlockCoordinators()
373+ c.handoffPublication = nil
374+ }
375+ // Attachment publication retains source/destination coordinator locks.
376+ // Release those first, then migrate synchronized-output batches exactly as
377+ // Move pane does, while pane parsing remains fenced.
378+ for _, change := range ownerChanges {
379+ c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
380+ }
381+ if c.handoffResult.published.ac != nil {
382+ c.sourceCleanupToken = c.handoffResult.published
383+ } else if c.source.client != nil {
384+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
385+ }
386+ if len(c.source.tabs) == 0 {
387+ delete(d.sessions, c.source.id)
388+ c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
389+ c.retiredParked = d.purgeParkedForSessionLocked(c.source)
390+ }
391+ c.sourceEmpty = len(c.source.tabs) == 0
392+ if !c.source.ephemeral {
393+ c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
394+ c.sourceMetadataValid = true
395+ }
396+ if !c.destination.ephemeral {
397+ c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
398+ c.destMetadataValid = true
399+ }
400+ return true
401+}
402+
403+func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
404+ current := make(map[*pane]struct{}, len(tb.panes)+1)
405+ for _, p := range tb.panes {
406+ current[p] = struct{}{}
407+ }
408+ if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) {
409+ current[tb.floating.pane] = struct{}{}
410+ }
411+ if len(current) != len(admitted) || len(current) != len(fenced) {
412+ return false
413+ }
414+ for _, p := range admitted {
415+ if _, ok := current[p]; !ok {
416+ return false
417+ }
418+ }
419+ for _, p := range fenced {
420+ if _, ok := current[p]; !ok {
421+ return false
422+ }
423+ }
424+ return true
425+}
A internal/usecase/daemon/move_tab_handoff_test.go +339 -0
@@ -0,0 +1,339 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+)
12+
13+func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) {
14+ t.Helper()
15+ d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
16+ follower.replaceTransport(&closeTrackingTransport{})
17+ moved := source.tabs[0]
18+ moved.stableID = "moved-tab"
19+ require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
20+
21+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
22+ displacedTransport := &closeTrackingTransport{}
23+ displaced := &attachedClient{
24+ tr: displacedTransport, output: newOutputStateStream(), size: follower.size,
25+ }
26+ displaced.initOverlays()
27+ displaced.setSession(destination)
28+ destination.mu.Lock()
29+ destination.client = displaced
30+ destination.mu.Unlock()
31+ require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
32+
33+ return d, source, follower, destination, displaced, displacedTransport, moved
34+}
35+
36+func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
37+ t.Helper()
38+ require.NoError(t, d.moveTab(moveTabRequest{
39+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
40+ SourceTabID: domain.TabStableID(moved.stableID),
41+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42+ }))
43+}
44+
45+func detachedWithReason(frames []ports.Frame, reason uint8) bool {
46+ for _, frame := range frames {
47+ if frame.Type != ports.MsgDetached {
48+ continue
49+ }
50+ detached, err := ports.UnmarshalDetached(frame.Payload)
51+ if err == nil && detached.Reason == reason {
52+ return true
53+ }
54+ }
55+ return false
56+}
57+
58+func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59+ d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t)
60+
61+ moveTabFinalSource(t, d, source, destination, moved)
62+ d.attachmentCleanupWg.Wait()
63+
64+ destination.mu.Lock()
65+ require.Same(t, follower, destination.client)
66+ require.Contains(t, destination.snatched, displaced)
67+ require.Same(t, moved, destination.tabs[destination.active])
68+ destination.mu.Unlock()
69+ require.Same(t, destination, follower.currentSession())
70+ require.Equal(t, attachmentActive, destination.attachmentRole(follower))
71+ require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced))
72+
73+ frames := displacedTransport.Sends()
74+ require.Len(t, frames, 1)
75+ require.Equal(t, ports.MsgOutput, frames[0].Type)
76+ output, err := ports.UnmarshalOutput(frames[0].Payload)
77+ require.NoError(t, err)
78+ require.Contains(t, string(output.Data), "Session snatched")
79+ require.False(t, detachedWithReason(frames, ports.ReasonReplaced))
80+ require.False(t, displacedTransport.Closed())
81+}
82+
83+func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
84+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
85+
86+ waitingTransport := &closeTrackingTransport{}
87+ waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
88+ waiting.initOverlays()
89+ waiting.setSession(destination)
90+ destination.mu.Lock()
91+ destination.addSnatchedLocked(waiting)
92+ destination.mu.Unlock()
93+
94+ moveTabFinalSource(t, d, source, destination, moved)
95+ d.attachmentCleanupWg.Wait()
96+
97+ destination.mu.Lock()
98+ require.Contains(t, destination.snatched, displaced)
99+ require.Contains(t, destination.snatched, waiting)
100+ require.Len(t, destination.snatched, 2)
101+ destination.mu.Unlock()
102+ requireSingleOwnerAndLease(t, destination, follower, displaced, waiting)
103+}
104+
105+func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
106+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
107+
108+ parkedTransport := &closeTrackingTransport{}
109+ parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
110+ parked.initOverlays()
111+ require.True(t, d.parkAttachment(source, parked))
112+
113+ sourceSnatchedTransport := &closeTrackingTransport{}
114+ sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size}
115+ sourceSnatched.initOverlays()
116+ sourceSnatched.setSession(source)
117+ source.mu.Lock()
118+ source.addSnatchedLocked(sourceSnatched)
119+ source.mu.Unlock()
120+
121+ moveTabFinalSource(t, d, source, destination, moved)
122+ d.attachmentCleanupWg.Wait()
123+ d.waitNotifies()
124+
125+ d.mu.Lock()
126+ require.Empty(t, d.parked)
127+ d.mu.Unlock()
128+ require.True(t, parkedTransport.Closed())
129+ require.True(t, sourceSnatchedTransport.Closed())
130+ require.Nil(t, sourceSnatched.currentSession())
131+}
132+
133+func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
134+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
135+ movedPane := moved.focusedPane()
136+
137+ follower.overlays.copyMu.Lock()
138+ follower.overlays.copyPane = movedPane
139+ follower.overlays.copyMode = &scopy.Mode{}
140+ follower.overlays.copyMu.Unlock()
141+ follower.sendMu.Lock()
142+ follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}}
143+ follower.sendMu.Unlock()
144+
145+ otherPane := destination.tabs[0].focusedPane()
146+ waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size}
147+ waiting.initOverlays()
148+ waiting.setSession(destination)
149+ waiting.overlays.copyMu.Lock()
150+ waiting.overlays.copyPane = otherPane
151+ waiting.overlays.copyMode = &scopy.Mode{}
152+ waiting.overlays.copyMu.Unlock()
153+ waiting.sendMu.Lock()
154+ waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}}
155+ waiting.sendMu.Unlock()
156+ destination.mu.Lock()
157+ destination.addSnatchedLocked(waiting)
158+ destination.mu.Unlock()
159+
160+ moveTabFinalSource(t, d, source, destination, moved)
161+
162+ follower.overlays.copyMu.Lock()
163+ require.Nil(t, follower.overlays.copyMode)
164+ require.Nil(t, follower.overlays.copyPane)
165+ follower.overlays.copyMu.Unlock()
166+ follower.sendMu.Lock()
167+ followerCapture, followerCaptured := follower.captureFrames[movedPane]
168+ follower.sendMu.Unlock()
169+ if followerCaptured {
170+ require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint")
171+ }
172+
173+ waiting.overlays.copyMu.Lock()
174+ require.NotNil(t, waiting.overlays.copyMode)
175+ require.Same(t, otherPane, waiting.overlays.copyPane)
176+ waiting.overlays.copyMu.Unlock()
177+ waiting.sendMu.Lock()
178+ _, waitingCaptured := waiting.captureFrames[otherPane]
179+ waiting.sendMu.Unlock()
180+ require.True(t, waitingCaptured)
181+}
182+
183+func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
184+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
185+ followerRebased := make(chan struct{})
186+ follower.renderStages.handoffRebase = func() { close(followerRebased) }
187+
188+ displaced.sendMu.Lock()
189+ panelCleanupStarted := make(chan struct{})
190+ d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) }
191+ defer func() { d.afterDisplacedCleanupStarted = nil }()
192+
193+ moveDone := make(chan struct{})
194+ go func() {
195+ moveTabFinalSource(t, d, source, destination, moved)
196+ close(moveDone)
197+ }()
198+
199+ awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start")
200+ awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup")
201+ awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint")
202+
203+ displaced.sendMu.Unlock()
204+ d.attachmentCleanupWg.Wait()
205+}
206+
207+func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
208+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
209+
210+ paintAdmitted := make(chan struct{})
211+ releasePaint := make(chan struct{})
212+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
213+ if token.ac == follower && token.role == attachmentActive {
214+ close(paintAdmitted)
215+ <-releasePaint
216+ }
217+ }
218+ defer func() { d.afterRoleEffectAdmitted = nil }()
219+
220+ moveDone := make(chan error, 1)
221+ go func() {
222+ moveDone <- d.moveTab(moveTabRequest{
223+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
224+ SourceTabID: domain.TabStableID(moved.stableID),
225+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
226+ })
227+ }()
228+ awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted")
229+
230+ displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport)
231+ reclaimDone := make(chan struct{})
232+ go func() {
233+ d.handleSnatchedClientFrame(displacedToken, ports.Frame{
234+ Type: ports.MsgInput,
235+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
236+ })
237+ close(reclaimDone)
238+ }()
239+
240+ close(releasePaint)
241+ require.NoError(t, <-moveDone)
242+ awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish")
243+ d.attachmentCleanupWg.Wait()
244+
245+ requireSingleOwnerAndLease(t, destination, follower, displaced)
246+ destination.mu.Lock()
247+ require.Same(t, displaced, destination.client)
248+ destination.mu.Unlock()
249+ require.Equal(t, attachmentSnatched, destination.attachmentRole(follower))
250+ require.Equal(t, attachmentActive, destination.attachmentRole(displaced))
251+}
252+
253+func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
254+ d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
255+
256+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
257+ if token.ac == follower {
258+ token.ac.roleGeneration.Add(1)
259+ }
260+ }
261+ defer func() { d.afterRoleEffectAdmitted = nil }()
262+
263+ beforeTabs := append([]*tab(nil), destination.tabs...)
264+ moveTabFinalSource(t, d, source, destination, moved)
265+
266+ require.Nil(t, source.tabs)
267+ require.Same(t, destination, follower.currentSession())
268+ require.Same(t, follower, destination.client)
269+ require.Equal(t, append(beforeTabs, moved), destination.tabs)
270+ require.Same(t, moved, destination.tabs[destination.active])
271+}
272+
273+func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
274+ d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
275+
276+ commitEntered := make(chan struct{})
277+ releaseCommit := make(chan struct{})
278+ d.beforeMoveTabCommit = func() {
279+ close(commitEntered)
280+ <-releaseCommit
281+ }
282+ defer func() { d.beforeMoveTabCommit = nil }()
283+
284+ moveDone := make(chan error, 1)
285+ go func() {
286+ moveDone <- d.moveTab(moveTabRequest{
287+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
288+ SourceTabID: domain.TabStableID(moved.stableID),
289+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
290+ })
291+ }()
292+ <-commitEntered
293+
294+ type observation struct {
295+ sourceRegistered bool
296+ movedInSource bool
297+ movedInDestination bool
298+ followerOnDestination bool
299+ displacedSnatched bool
300+ movedActive bool
301+ }
302+ read := func() observation {
303+ d.mu.Lock()
304+ _, sourceLive := d.sessions[source.id]
305+ d.mu.Unlock()
306+ source.mu.Lock()
307+ movedInSource := indexMoveTabLocked(source, moved) >= 0
308+ source.mu.Unlock()
309+ destination.mu.Lock()
310+ obs := observation{
311+ sourceRegistered: sourceLive,
312+ movedInSource: movedInSource,
313+ movedInDestination: indexMoveTabLocked(destination, moved) >= 0,
314+ followerOnDestination: destination.client == follower,
315+ displacedSnatched: false,
316+ movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved,
317+ }
318+ if displaced != nil {
319+ _, obs.displacedSnatched = destination.snatched[displaced]
320+ }
321+ destination.mu.Unlock()
322+ return obs
323+ }
324+
325+ require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication")
326+ require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication")
327+ require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication")
328+
329+ close(releaseCommit)
330+ require.NoError(t, <-moveDone)
331+
332+ final := read()
333+ require.False(t, final.sourceRegistered)
334+ require.False(t, final.movedInSource)
335+ require.True(t, final.movedInDestination)
336+ require.True(t, final.followerOnDestination)
337+ require.True(t, final.displacedSnatched)
338+ require.True(t, final.movedActive)
339+}
A internal/usecase/daemon/move_tab_test.go +584 -0
@@ -0,0 +1,584 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
18+ for _, destinationEphemeral := range []bool{false, true} {
19+ t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) {
20+ movedPTY := newQuietPTY()
21+ remainingPTY := newQuietPTY()
22+ destinationPTY := newQuietPTY()
23+ floatingPTY := newQuietPTY()
24+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
25+ moved := source.tabs[0]
26+ moved.stableID = "moved-tab"
27+ moved.name = "preserved-name"
28+ remaining := source.tabs[1]
29+ remaining.stableID = "remaining-tab"
30+ source.active = 0
31+
32+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
33+ t.Cleanup(destinationCancel)
34+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
35+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
36+ destination := &session{
37+ id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral,
38+ ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0,
39+ }
40+ publishTiledPaneOwners(destination, destinationTab)
41+
42+ floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
43+ moved.mu.Lock()
44+ moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7}
45+ moved.mu.Unlock()
46+ publishPaneOwner(floating, source, moved, 7)
47+
48+ d.mu.Lock()
49+ d.sessions[destination.id] = destination
50+ d.mu.Unlock()
51+
52+ tiled := moved.focusedPane()
53+ tiledScreen := tiled.screen
54+ tiledOwnerGeneration := tiled.ownerGeneration
55+ floatingOwnerGeneration := floating.ownerGeneration
56+ oldWorkerCtx := moved.ctx
57+ oldTree := moved.tree
58+ oldPanes := moved.panes
59+ beforeDestinationActive := destination.tabs[destination.active]
60+
61+ err := d.moveTab(moveTabRequest{
62+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
63+ SourceTabID: domain.TabStableID(moved.stableID),
64+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
65+ })
66+ require.NoError(t, err)
67+
68+ require.Equal(t, []*tab{remaining}, source.tabs)
69+ require.Zero(t, source.active)
70+ require.Same(t, remaining, source.tabs[source.active])
71+ require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)
72+ require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background")
73+ require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
74+ require.Equal(t, "preserved-name", moved.name)
75+ require.Same(t, oldTree, moved.tree)
76+ require.Equal(t, oldPanes, moved.panes)
77+ require.Same(t, movedPTY, tiled.pty)
78+ require.Same(t, tiledScreen, tiled.screen)
79+ require.Same(t, floating, moved.floating.pane)
80+ require.Same(t, floatingPTY, floating.pty)
81+ require.Equal(t, floatingHidden, moved.floating.state)
82+ require.Equal(t, uint64(7), moved.floating.generation)
83+ require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
84+ require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
85+ require.Same(t, destination, tiled.ownerSnapshot().session)
86+ require.Same(t, destination, floating.ownerSnapshot().session)
87+ require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled")
88+ require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination")
89+ })
90+ }
91+}
92+
93+func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) {
94+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
95+ moved := source.tabs[0]
96+ moved.stableID = "moved-tab"
97+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
98+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
99+
100+ floatingPTY := newQuietPTY()
101+ floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
102+ floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx)
103+ t.Cleanup(floatingCancel)
104+ floating.ctx, floating.cancel = floatingCtx, floatingCancel
105+ moved.mu.Lock()
106+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
107+ moved.mu.Unlock()
108+ publishPaneOwner(floating, source, moved, 7)
109+ d.applyTabLayout(source, moved)
110+
111+ beforeScreen := floating.screen
112+ beforeHistory := floating.history
113+ beforeContext := floating.ctx
114+ beforeOwnerGeneration := floating.ownerGeneration
115+
116+ require.NoError(t, d.moveTab(moveTabRequest{
117+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
118+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
119+ }))
120+
121+ require.Same(t, moved, destination.tabs[destination.active])
122+ require.Same(t, client, destination.client)
123+ require.Same(t, destination, client.currentSession())
124+ moved.mu.Lock()
125+ require.Equal(t, floatingVisible, moved.floating.state)
126+ require.True(t, moved.floating.desiredVisible)
127+ require.Equal(t, uint64(7), moved.floating.generation)
128+ require.Same(t, floating, moved.floating.pane)
129+ moved.mu.Unlock()
130+ expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig())
131+ floating.mu.Lock()
132+ require.Same(t, floatingPTY, floating.pty)
133+ require.Same(t, beforeScreen, floating.screen)
134+ require.Same(t, beforeHistory, floating.history)
135+ require.Equal(t, beforeContext, floating.ctx)
136+ require.Equal(t, expectedGeometry, floating.popupGeometry)
137+ require.Equal(t, expectedGeometry.Inner, floating.rect)
138+ require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration)
139+ floating.mu.Unlock()
140+ owner := floating.ownerSnapshot()
141+ require.Same(t, destination, owner.session)
142+ require.Same(t, moved, owner.tab)
143+ require.Equal(t, floating.ownerGeneration, owner.generation)
144+ require.Equal(t, uint64(7), owner.floatingSlotGeneration)
145+ require.NoError(t, floating.ctx.Err(), "floating process context remains live")
146+}
147+
148+func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
149+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
150+ moved := source.tabs[0]
151+ moved.stableID = "moved-tab"
152+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
153+ rebased := false
154+ client.renderStages.handoffRebase = func() { rebased = true }
155+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
156+ oldDestinationActive := destination.tabs[0]
157+
158+ require.NoError(t, d.moveTab(moveTabRequest{
159+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
160+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
161+ }))
162+
163+ require.Nil(t, source.tabs)
164+ require.Same(t, destination, client.currentSession())
165+ require.Same(t, client, destination.client)
166+ require.Equal(t, attachmentActive, destination.attachmentRole(client))
167+ require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
168+ require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab")
169+ require.True(t, rebased)
170+}
171+
172+func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
173+ tests := []struct {
174+ name string
175+ mutate func(source, destination *session, moved *tab, req *moveTabRequest)
176+ wantErr error
177+ }{
178+ {name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
179+ req.Destination = req.Source
180+ }},
181+ {name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
182+ req.Destination.Incarnation[0]++
183+ }},
184+ {name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
185+ source.tabs = source.tabs[1:]
186+ _ = moved
187+ }},
188+ {name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
189+ moved.floating.state = floatingWarming
190+ moved.floating.generation = 3
191+ }},
192+ {name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
193+ destination.teardownMu.Lock()
194+ destination.teardownActive = true
195+ destination.teardownMu.Unlock()
196+ }},
197+ }
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
201+ moved := source.tabs[0]
202+ moved.stableID = "moved-tab"
203+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
204+ req := moveTabRequest{
205+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
206+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
207+ }
208+ tt.mutate(source, destination, moved, &req)
209+ beforeDestination := append([]*tab(nil), destination.tabs...)
210+ beforeOwner := moved.focusedPane().ownerSnapshot()
211+
212+ err := d.moveTab(req)
213+ destination.teardownMu.Lock()
214+ destination.teardownActive = false
215+ destination.teardownMu.Unlock()
216+ require.ErrorIs(t, err, tt.wantErr)
217+ var rejection *moveRejection
218+ require.ErrorAs(t, err, &rejection)
219+ require.Equal(t, beforeDestination, destination.tabs)
220+ require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot())
221+ })
222+ }
223+}
224+
225+func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) {
226+ for _, tt := range []struct {
227+ name string
228+ mutate func(*tab)
229+ }{
230+ {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }},
231+ {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }},
232+ } {
233+ t.Run(tt.name, func(t *testing.T) {
234+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
235+ moved := source.tabs[0]
236+ moved.stableID = "moved-tab"
237+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
238+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
239+ floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
240+ moved.mu.Lock()
241+ moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
242+ moved.mu.Unlock()
243+ publishPaneOwner(floating, source, moved, 7)
244+
245+ source.layoutApplyMu.Lock()
246+ admitted := make(chan struct{})
247+ d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
248+ if action == "move-tab" && ac == client {
249+ close(admitted)
250+ }
251+ }
252+ moveDone := make(chan error, 1)
253+ go func() {
254+ moveDone <- d.moveTab(moveTabRequest{
255+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
256+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
257+ })
258+ }()
259+ awaitTestCompletion(t, admitted, "move-tab did not finish admission")
260+ moved.mu.Lock()
261+ tt.mutate(moved)
262+ moved.mu.Unlock()
263+ source.layoutApplyMu.Unlock()
264+
265+ require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
266+ require.NotContains(t, destination.tabs, moved)
267+ require.Same(t, source, floating.ownerSnapshot().session)
268+ d.afterRoleEffectGateFrozen = nil
269+ })
270+ }
271+}
272+
273+func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) {
274+ pty := &transactionalResizePTY{}
275+ d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY())
276+ moved := source.tabs[0]
277+ moved.stableID = "moved-tab"
278+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
279+ destination.tabs[0].mu.Lock()
280+ destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30}
281+ destination.tabs[0].mu.Unlock()
282+ moved.mu.Lock()
283+ moved.size = domain.Size{Cols: 90, Rows: 20}
284+ moved.bumpLayoutGenerationLocked()
285+ moved.mu.Unlock()
286+
287+ resizeEntered := make(chan struct{})
288+ releaseResize := make(chan struct{})
289+ var once sync.Once
290+ pty.onResize = func() {
291+ once.Do(func() {
292+ close(resizeEntered)
293+ <-releaseResize
294+ })
295+ }
296+ layoutDone := make(chan bool, 1)
297+ go func() { layoutDone <- d.applyTabLayout(source, moved) }()
298+ <-resizeEntered
299+ reserved := make(chan struct{})
300+ d.afterMoveLifecycleReserved = func() { close(reserved) }
301+ defer func() { d.afterMoveLifecycleReserved = nil }()
302+ moveDone := make(chan error, 1)
303+ go func() {
304+ moveDone <- d.moveTab(moveTabRequest{
305+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
306+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
307+ })
308+ }()
309+ <-reserved
310+ select {
311+ case err := <-moveDone:
312+ t.Fatalf("move crossed blocked source resize: %v", err)
313+ default:
314+ }
315+ close(releaseResize)
316+ <-layoutDone // the old transaction may lose owner publication after its PTY call
317+ require.NoError(t, <-moveDone)
318+
319+ pty.mu.Lock()
320+ sizes := append([]domain.Size(nil), pty.sizes...)
321+ pty.mu.Unlock()
322+ require.GreaterOrEqual(t, len(sizes), 2)
323+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize")
324+}
325+
326+func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) {
327+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
328+ moved := source.tabs[0]
329+ moved.stableID = "moved-tab"
330+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
331+ p := moved.focusedPane()
332+ sourceCoordinator := d.ensureRenderCoordinator(source)
333+ destinationCoordinator := d.ensureRenderCoordinator(destination)
334+
335+ p.mu.Lock()
336+ p.screen.Write([]byte(renderer.SyncStartCSI))
337+ generation := source.syncGen.Add(1)
338+ p.syncGen = generation
339+ oldLease := p.effectLeaseLocked()
340+ p.resizeRetry = true
341+ p.mu.Unlock()
342+ sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil)
343+ oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}})
344+
345+ require.NoError(t, d.moveTab(moveTabRequest{
346+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
347+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
348+ }))
349+
350+ require.False(t, oldLease.Current(), "source effects are revoked at owner publication")
351+ require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer")
352+ require.Same(t, destination, p.ownerSnapshot().session)
353+ sourceCoordinator.mu.Lock()
354+ require.NotContains(t, sourceCoordinator.syncBatches, p)
355+ sourceCoordinator.mu.Unlock()
356+ destinationCoordinator.mu.Lock()
357+ require.Contains(t, destinationCoordinator.syncBatches, p)
358+ destinationCoordinator.mu.Unlock()
359+ require.Equal(t, destination.syncGen.Load(), p.syncGen)
360+ p.mu.Lock()
361+ require.False(t, p.resizeRetry, "required resize work is completed under the destination owner")
362+ p.mu.Unlock()
363+}
364+
365+func TestMoveTabExitReapsOnlyDestination(t *testing.T) {
366+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
367+ moved := source.tabs[0]
368+ moved.stableID = "moved-tab"
369+ remaining := source.tabs[1]
370+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
371+ p := moved.focusedPane()
372+
373+ require.NoError(t, d.moveTab(moveTabRequest{
374+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
375+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
376+ }))
377+ d.reapPaneOwner(p)
378+
379+ require.Equal(t, []*tab{remaining}, source.tabs)
380+ require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner")
381+ require.Nil(t, p.ownerSnapshot())
382+}
383+
384+func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
385+ d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
386+ left.tabs[0].stableID = "left-moved"
387+ right := addMoveTabTestSession(d, "right", "right-stays")
388+ rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
389+ rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
390+ publishTiledPaneOwners(right, rightMoved)
391+ right.tabs = append(right.tabs, rightMoved)
392+
393+ start := make(chan struct{})
394+ done := make(chan error, 2)
395+ go func() {
396+ <-start
397+ done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}})
398+ }()
399+ go func() {
400+ <-start
401+ done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}})
402+ }()
403+ close(start)
404+ for range 2 {
405+ select {
406+ case err := <-done:
407+ require.NoError(t, err)
408+ case <-time.After(5 * time.Second):
409+ t.Fatal("opposite-direction move tabs deadlocked")
410+ }
411+ }
412+}
413+
414+type moveTabPurgeCatalogue struct {
415+ mu sync.Mutex
416+ records map[string]domain.CatalogueRecord
417+ events []string
418+}
419+
420+func (c *moveTabPurgeCatalogue) Records() ([]domain.CatalogueRecord, error) {
421+ c.mu.Lock()
422+ defer c.mu.Unlock()
423+ records := make([]domain.CatalogueRecord, 0, len(c.records))
424+ for _, record := range c.records {
425+ records = append(records, record)
426+ }
427+ return records, nil
428+}
429+
430+func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
431+ c.mu.Lock()
432+ defer c.mu.Unlock()
433+ record, ok := c.records[name]
434+ return record, ok, nil
435+}
436+
437+func (c *moveTabPurgeCatalogue) Create(domain.CatalogueRecord) error {
438+ return errors.New("move tab purge catalogue: Create is unsupported")
439+}
440+
441+func (c *moveTabPurgeCatalogue) Replace(string, domain.CatalogueRecord) error {
442+ return errors.New("move tab purge catalogue: Replace is unsupported")
443+}
444+
445+func (c *moveTabPurgeCatalogue) Rename(string, domain.CatalogueRecord) error {
446+ return errors.New("move tab purge catalogue: Rename is unsupported")
447+}
448+
449+func (c *moveTabPurgeCatalogue) Delete(name string) error {
450+ c.mu.Lock()
451+ defer c.mu.Unlock()
452+ delete(c.records, name)
453+ c.events = append(c.events, "catalogue-delete:"+name)
454+ return nil
455+}
456+
457+func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
458+ c.mu.Lock()
459+ defer c.mu.Unlock()
460+ c.events = append(c.events, "metadata:"+update.Name)
461+ return nil
462+}
463+
464+func (c *moveTabPurgeCatalogue) Sync() error { return nil }
465+func (c *moveTabPurgeCatalogue) Close() error { return nil }
466+
467+type moveTabPurgeRepository struct {
468+ noOpSnapshotRepository
469+ d *Daemon
470+ source *session
471+ destination *session
472+ moved *tab
473+ pane *pane
474+ client *attachedClient
475+ catalogue *moveTabPurgeCatalogue
476+ err error
477+ deleted []domain.IncarnationID
478+ outside bool
479+ dirty bool
480+}
481+
482+func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
483+ r.deleted = append(r.deleted, id)
484+ daemonUnlocked := r.d.mu.TryLock()
485+ routingUnlocked := r.d.notices.routingMu.TryLock()
486+ sourceUnlocked := r.source.mu.TryLock()
487+ destinationUnlocked := r.destination.mu.TryLock()
488+ tabUnlocked := r.moved.mu.TryLock()
489+ paneUnlocked := r.pane.mu.TryLock()
490+ sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
491+ destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
492+ tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
493+ roleUnlocked := r.client.roleEffects.mu.TryLock()
494+ roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable
495+ r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
496+ sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
497+ if roleUnlocked {
498+ r.client.roleEffects.mu.Unlock()
499+ }
500+ if tabFenceUnlocked {
501+ r.moved.layoutApplyMu.Unlock()
502+ }
503+ if destinationFenceUnlocked {
504+ r.destination.layoutApplyMu.Unlock()
505+ }
506+ if sourceFenceUnlocked {
507+ r.source.layoutApplyMu.Unlock()
508+ }
509+ if paneUnlocked {
510+ r.pane.mu.Unlock()
511+ }
512+ if tabUnlocked {
513+ r.moved.mu.Unlock()
514+ }
515+ if destinationUnlocked {
516+ r.destination.mu.Unlock()
517+ }
518+ if sourceUnlocked {
519+ r.source.mu.Unlock()
520+ }
521+ if routingUnlocked {
522+ r.d.notices.routingMu.Unlock()
523+ }
524+ if daemonUnlocked {
525+ r.d.mu.Unlock()
526+ }
527+ r.destination.snapshotMu.Lock()
528+ r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0
529+ r.destination.snapshotMu.Unlock()
530+ r.catalogue.mu.Lock()
531+ r.catalogue.events = append(r.catalogue.events, "repository-delete")
532+ r.catalogue.mu.Unlock()
533+ return r.err
534+}
535+
536+func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
537+ d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
538+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
539+ source.incarnation = domain.IncarnationID{4}
540+ moved := source.tabs[0]
541+ moved.stableID = "moved-tab"
542+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
543+ destination.ephemeral = false
544+ destination.snapEligible.Store(true)
545+
546+ catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{
547+ source.name: {Name: source.name, IncarnationID: source.incarnation},
548+ destination.name: {Name: destination.name, IncarnationID: destination.incarnation},
549+ }}
550+ failure := errors.New("repository delete failed")
551+ repository := &moveTabPurgeRepository{
552+ d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client,
553+ catalogue: catalogue, err: failure,
554+ }
555+ WithCatalogue(catalogue, nil)(d)
556+ WithSnapshotRepository(repository)(d)
557+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
558+
559+ require.NoError(t, d.moveTab(moveTabRequest{
560+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
561+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
562+ }))
563+
564+ require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted)
565+ require.True(t, repository.outside, "repository purge must run outside architecture and owner locks")
566+ require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge")
567+ catalogue.mu.Lock()
568+ require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events)
569+ catalogue.mu.Unlock()
570+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership")
571+ require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
572+}
573+
574+func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session {
575+ ctx, cancel := context.WithCancel(d.serveCtx)
576+ tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
577+ tb.ctx, tb.cancel = context.WithCancel(ctx)
578+ sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
579+ publishTiledPaneOwners(sess, tb)
580+ d.mu.Lock()
581+ d.sessions[sess.id] = sess
582+ d.mu.Unlock()
583+ return sess
584+}
A internal/usecase/daemon/move_test.go +392 -0
@@ -0,0 +1,392 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "os"
6+ "sync/atomic"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+)
14+
15+func TestMovePaneMutationSameSession(t *testing.T) {
16+ p1, release1 := newBlockingPTY(t)
17+ p2, release2 := newBlockingPTY(t)
18+ defer release1()
19+ defer release2()
20+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
21+ sourceTab, destinationTab := sess.tabs[0], sess.tabs[1]
22+ sourceTab.stableID = "source-tab"
23+ destinationTab.stableID = "destination-tab"
24+ moved := sourceTab.focusedPane()
25+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
26+ moved.ctx, moved.cancel = movedCtx, movedCancel
27+
28+ err := d.movePane(movePaneRequest{
29+ Source: moveSessionLocator{ID: sess.id},
30+ SourceTabID: domain.TabStableID(sourceTab.stableID),
31+ SourcePaneID: domain.PaneStableID(moved.stableID),
32+ Destination: moveSessionLocator{ID: sess.id},
33+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
34+ })
35+ require.NoError(t, err)
36+ require.Same(t, moved, destinationTab.panes[moved.id])
37+ require.Equal(t, layout.PaneID("pane-2"), moved.id)
38+ require.Equal(t, layout.PaneID("pane-2"), destinationTab.tree.Focus)
39+ require.NotContains(t, sourceTab.panes, layout.PaneID("pane-1"))
40+ require.Len(t, sess.tabs, 1)
41+ require.Same(t, destinationTab, sess.tabs[0])
42+ require.NoError(t, moved.ctx.Err(), "a same-session move must not retire the moved pane process")
43+}
44+
45+func TestMovePaneMutationCrossSession(t *testing.T) {
46+ p1, release1 := newBlockingPTY(t)
47+ p2, release2 := newBlockingPTY(t)
48+ defer release1()
49+ defer release2()
50+ d, source, _, _ := newManualSessionWithPTYs(t, p1)
51+ source.mu.Lock()
52+ source.client = nil
53+ source.mu.Unlock()
54+ sourceTab := source.tabs[0]
55+ sourceTab.stableID = "source-tab"
56+ moved := sourceTab.focusedPane()
57+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
58+ moved.ctx, moved.cancel = movedCtx, movedCancel
59+
60+ destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
61+ destinationTab := destination.tabs[0]
62+ destinationTab.stableID = "destination-tab"
63+ publishTiledPaneOwners(destination, destinationTab)
64+ d.mu.Lock()
65+ d.sessions[destination.id] = destination
66+ d.mu.Unlock()
67+
68+ err := d.movePane(movePaneRequest{
69+ Source: moveSessionLocator{ID: source.id},
70+ SourceTabID: domain.TabStableID(sourceTab.stableID),
71+ SourcePaneID: domain.PaneStableID(moved.stableID),
72+ Destination: moveSessionLocator{ID: destination.id},
73+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
74+ })
75+ require.NoError(t, err)
76+ require.Nil(t, source.tabs)
77+ require.Same(t, moved, destinationTab.panes[moved.id])
78+ require.Equal(t, destinationTab, destination.tabs[0])
79+ require.Same(t, destination, moved.ownerSnapshot().session)
80+ require.Same(t, destinationTab, moved.ownerSnapshot().tab)
81+ require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
82+}
83+
84+func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
85+ p1, release1 := newBlockingPTY(t)
86+ p2, release2 := newBlockingPTY(t)
87+ defer release1()
88+ defer release2()
89+ d, source, client, _ := newManualSessionWithPTYs(t, p1)
90+ sourceTab := source.tabs[0]
91+ sourceTab.stableID = "source-tab"
92+ moved := sourceTab.focusedPane()
93+ movedTabCtx, movedTabCancel := context.WithCancel(d.paneProcessCtx)
94+ moved.ctx, moved.cancel = movedTabCtx, movedTabCancel
95+
96+ rc := d.attachCoordinator(source, nil, client, true)
97+ require.NotNil(t, rc)
98+ var rebased atomic.Bool
99+ client.renderStages.handoffRebase = func() { rebased.Store(true) }
100+ destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
101+ destinationTab := destination.tabs[0]
102+ destinationTab.stableID = "destination-tab"
103+ publishTiledPaneOwners(destination, destinationTab)
104+ d.mu.Lock()
105+ d.sessions[destination.id] = destination
106+ d.mu.Unlock()
107+
108+ err := d.movePane(movePaneRequest{
109+ Source: moveSessionLocator{ID: source.id},
110+ SourceTabID: domain.TabStableID(sourceTab.stableID),
111+ SourcePaneID: domain.PaneStableID(moved.stableID),
112+ Destination: moveSessionLocator{ID: destination.id},
113+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
114+ })
115+ require.NoError(t, err)
116+ require.Nil(t, source.tabs)
117+ require.Same(t, destination, client.currentSession())
118+ require.Same(t, client, destination.client)
119+ require.Equal(t, attachmentActive, destination.attachmentRole(client))
120+ require.Same(t, destinationTab, destination.tabs[destination.active])
121+ require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
122+ require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive")
123+}
124+
125+func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
126+ movedPTY := newQuietPTY()
127+ d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
128+ clip, err := os.CreateTemp(t.TempDir(), "move-clip-")
129+ require.NoError(t, err)
130+ require.NoError(t, clip.Close())
131+ source.mu.Lock()
132+ source.client = nil
133+ source.clipFiles = []string{clip.Name()}
134+ source.mu.Unlock()
135+ active.setSession(nil)
136+ moved := source.tabs[0].focusedPane()
137+ movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
138+ moved.ctx, moved.cancel = movedCtx, movedCancel
139+ movedTab := source.tabs[0]
140+ movedTab.stableID = "source-tab"
141+
142+ destinationPTY := newQuietPTY()
143+ destination := &session{
144+ id: "destination", name: "destination", ephemeral: true,
145+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
146+ active: 0,
147+ }
148+ publishTiledPaneOwners(destination, destination.tabs[0])
149+ d.mu.Lock()
150+ d.sessions[destination.id] = destination
151+ d.mu.Unlock()
152+
153+ err = d.movePane(movePaneRequest{
154+ Source: moveSessionLocator{ID: source.id},
155+ SourceTabID: domain.TabStableID(movedTab.stableID),
156+ SourcePaneID: domain.PaneStableID(moved.stableID),
157+ Destination: moveSessionLocator{ID: destination.id},
158+ DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
159+ })
160+ require.NoError(t, err)
161+ select {
162+ case <-movedPTY.done:
163+ t.Fatal("retiring the source closed the transferred PTY")
164+ default:
165+ }
166+ select {
167+ case <-moved.ctx.Done():
168+ t.Fatal("retiring the source cancelled the transferred pane process")
169+ default:
170+ }
171+ select {
172+ case <-source.ctx.Done():
173+ default:
174+ t.Fatal("empty source lifecycle was not cancelled")
175+ }
176+ _, err = os.Stat(clip.Name())
177+ require.ErrorIs(t, err, os.ErrNotExist, "retired source clipboard resources must be removed")
178+}
179+
180+func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
181+ movedPTY := newQuietPTY()
182+ d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
183+ source.mu.Lock()
184+ source.client = nil
185+ source.mu.Unlock()
186+ moved := source.tabs[0].focusedPane()
187+ movedTab := source.tabs[0]
188+ movedTab.stableID = "source-tab"
189+
190+ transport := &closeTrackingTransport{}
191+ parked := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, resumeCapable: true}
192+ parked.initOverlays()
193+ require.True(t, d.parkAttachment(source, parked))
194+ destinationPTY := newQuietPTY()
195+ destination := &session{
196+ id: "destination", name: "destination", ephemeral: true,
197+ tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
198+ active: 0,
199+ }
200+ publishTiledPaneOwners(destination, destination.tabs[0])
201+ d.mu.Lock()
202+ d.sessions[destination.id] = destination
203+ d.mu.Unlock()
204+
205+ require.NoError(t, d.movePane(movePaneRequest{
206+ Source: moveSessionLocator{ID: source.id},
207+ SourceTabID: domain.TabStableID(movedTab.stableID),
208+ SourcePaneID: domain.PaneStableID(moved.stableID),
209+ Destination: moveSessionLocator{ID: destination.id},
210+ DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
211+ }))
212+ d.mu.Lock()
213+ require.Empty(t, d.parked, "retired source parked clients must leave the daemon registry")
214+ d.mu.Unlock()
215+ require.True(t, transport.Closed(), "retired source parked transport was not closed")
216+}
217+
218+func TestPrepareMovePaneCandidateIsPure(t *testing.T) {
219+ t.Parallel()
220+
221+ newPaneRef := func(id layout.PaneID, stableID string) *pane {
222+ return &pane{id: id, stableID: stableID}
223+ }
224+ horizontal := func(ids ...layout.PaneID) *layout.Node {
225+ children := make([]*layout.Node, 0, len(ids))
226+ for _, id := range ids {
227+ children = append(children, layout.NewLeaf(id))
228+ }
229+ return &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: children}
230+ }
231+ stack := func(ids ...layout.PaneID) *layout.Node {
232+ children := make([]*layout.Node, 0, len(ids))
233+ for _, id := range ids {
234+ children = append(children, layout.NewLeaf(id))
235+ }
236+ return &layout.Node{Kind: layout.Stack, Children: children, Expanded: ids[0]}
237+ }
238+
239+ tests := []struct {
240+ name string
241+ sourceTree *layout.Tree
242+ sourceFloating floatingState
243+ destinationTree *layout.Tree
244+ destinationSize domain.Size
245+ destinationNext int
246+ wantErr error
247+ wantRemoveTab bool
248+ wantSourceFocus layout.PaneID
249+ wantDestination layout.PaneID
250+ wantDestinationNext int
251+ wantDestinationIDs []layout.PaneID
252+ }{
253+ {
254+ name: "split source closes on clone and destination inserts right of focus",
255+ sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
256+ destinationTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-1"},
257+ destinationSize: domain.Size{Cols: 100, Rows: 4},
258+ destinationNext: 2,
259+ wantSourceFocus: "pane-1",
260+ wantDestination: "pane-3",
261+ wantDestinationNext: 4,
262+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3", "pane-2"},
263+ },
264+ {
265+ name: "stack source remains valid after close",
266+ sourceTree: &layout.Tree{Root: stack("pane-2", "pane-1"), Focus: "pane-2"},
267+ destinationTree: layout.NewTree("pane-1"),
268+ destinationSize: domain.Size{Cols: 41, Rows: 4},
269+ destinationNext: 2,
270+ wantSourceFocus: "pane-1",
271+ wantDestination: "pane-3",
272+ wantDestinationNext: 4,
273+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
274+ },
275+ {
276+ name: "final tiled pane requests source tab removal",
277+ sourceTree: layout.NewTree("pane-2"),
278+ destinationTree: layout.NewTree("pane-1"),
279+ destinationSize: domain.Size{Cols: 41, Rows: 2},
280+ destinationNext: 2,
281+ wantRemoveTab: true,
282+ wantDestination: "pane-3",
283+ wantDestinationNext: 4,
284+ wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"},
285+ },
286+ {
287+ name: "final tiled pane rejects warming floating sibling",
288+ sourceTree: layout.NewTree("pane-2"),
289+ sourceFloating: floatingWarming,
290+ destinationTree: layout.NewTree("pane-1"),
291+ destinationSize: domain.Size{Cols: 41, Rows: 2},
292+ destinationNext: 2,
293+ wantErr: errMoveFloatingWarming,
294+ },
295+ {
296+ name: "final tiled pane rejects hidden floating sibling",
297+ sourceTree: layout.NewTree("pane-2"),
298+ sourceFloating: floatingHidden,
299+ destinationTree: layout.NewTree("pane-1"),
300+ destinationSize: domain.Size{Cols: 41, Rows: 2},
301+ destinationNext: 2,
302+ wantErr: errMovePaneFloatingSibling,
303+ },
304+ {
305+ name: "final tiled pane rejects visible floating sibling",
306+ sourceTree: layout.NewTree("pane-2"),
307+ sourceFloating: floatingVisible,
308+ destinationTree: layout.NewTree("pane-1"),
309+ destinationSize: domain.Size{Cols: 41, Rows: 2},
310+ destinationNext: 2,
311+ wantErr: errMovePaneFloatingSibling,
312+ },
313+ {
314+ name: "destination too small rejects candidate",
315+ sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
316+ destinationTree: layout.NewTree("pane-1"),
317+ destinationSize: domain.Size{Cols: 40, Rows: 2},
318+ destinationNext: 2,
319+ wantErr: layout.ErrTooSmall,
320+ },
321+ }
322+
323+ for _, tt := range tests {
324+ t.Run(tt.name, func(t *testing.T) {
325+ moved := newPaneRef("pane-2", "stable-moved")
326+ sourcePanes := map[layout.PaneID]*pane{"pane-2": moved}
327+ for _, id := range layout.LeafIDs(tt.sourceTree.Root) {
328+ if id != moved.id {
329+ sourcePanes[id] = newPaneRef(id, "source-"+string(id))
330+ }
331+ }
332+ destinationPanes := make(map[layout.PaneID]*pane)
333+ for _, id := range layout.LeafIDs(tt.destinationTree.Root) {
334+ destinationPanes[id] = newPaneRef(id, "destination-"+string(id))
335+ }
336+ source := &tab{
337+ tree: tt.sourceTree,
338+ panes: sourcePanes,
339+ nextPaneID: 17,
340+ size: domain.Size{Cols: 80, Rows: 4},
341+ floating: floatingSlot{state: tt.sourceFloating, generation: 9},
342+ }
343+ destination := &tab{
344+ tree: tt.destinationTree,
345+ panes: destinationPanes,
346+ nextPaneID: tt.destinationNext,
347+ size: tt.destinationSize,
348+ }
349+ sourceBefore := source.tree.Clone()
350+ destinationBefore := destination.tree.Clone()
351+ sourceNextBefore, destinationNextBefore := source.nextPaneID, destination.nextPaneID
352+ movedIDBefore, movedStableIDBefore := moved.id, moved.stableID
353+
354+ candidate, err := prepareMovePaneCandidate(source, destination, moved)
355+
356+ require.ErrorIs(t, err, tt.wantErr)
357+ require.Equal(t, sourceBefore, source.tree, "preparation must not mutate the source tree")
358+ require.Equal(t, destinationBefore, destination.tree, "preparation must not mutate the destination tree")
359+ require.Equal(t, sourceNextBefore, source.nextPaneID)
360+ require.Equal(t, destinationNextBefore, destination.nextPaneID, "preparation must not advance the destination counter")
361+ require.Equal(t, movedIDBefore, moved.id, "preparation must not publish the new local ID")
362+ require.Equal(t, movedStableIDBefore, moved.stableID)
363+ require.Same(t, moved, source.panes[movedIDBefore])
364+ for id, p := range destinationPanes {
365+ require.Same(t, p, destination.panes[id])
366+ }
367+ if tt.wantErr != nil {
368+ require.Nil(t, candidate)
369+ return
370+ }
371+
372+ require.Same(t, moved, candidate.pane)
373+ require.Equal(t, movedIDBefore, candidate.sourceID)
374+ require.Equal(t, tt.wantDestination, candidate.destinationID)
375+ require.NotEqual(t, candidate.sourceID, candidate.destinationID)
376+ require.Equal(t, tt.wantRemoveTab, candidate.removeSourceTab)
377+ require.Equal(t, tt.wantDestinationNext, candidate.destinationNextPaneID, "candidate counter should follow the allocated ID")
378+ require.Equal(t, tt.wantDestinationIDs, layout.LeafIDs(candidate.destinationTree.Root))
379+ require.Equal(t, tt.wantDestination, candidate.destinationTree.Focus)
380+ require.NotEmpty(t, candidate.destinationPlacements)
381+ if tt.wantRemoveTab {
382+ require.Nil(t, candidate.sourceTree)
383+ require.Nil(t, candidate.sourcePlacements)
384+ } else {
385+ require.Equal(t, tt.wantSourceFocus, candidate.sourceTree.Focus)
386+ require.True(t, layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus))
387+ require.NotEmpty(t, candidate.sourcePlacements)
388+ }
389+ require.Equal(t, "stable-moved", candidate.pane.stableID)
390+ })
391+ }
392+}
A internal/usecase/daemon/navigation_role_handoff_test.go +199 -0
@@ -0,0 +1,199 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ action string
19+ run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error
20+ check func(*testing.T, *Daemon, *session, *session)
21+ }{
22+ {
23+ name: "picker selection", action: "picker-select",
24+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
25+ d.enterPicker(source, ac)
26+ return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
27+ },
28+ },
29+ {
30+ name: "palette session", action: "palette-session",
31+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
32+ return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
33+ },
34+ },
35+ {
36+ name: "palette recent session", action: "palette-recent-session",
37+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
38+ return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
39+ },
40+ },
41+ {
42+ name: "session overflow", action: "overflow-session",
43+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
44+ source.mu.Lock()
45+ source.name = "alpha"
46+ source.mu.Unlock()
47+ target.mu.Lock()
48+ target.name = "charlie"
49+ target.mu.Unlock()
50+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
51+ return d.focusDir(source, ac, layout.Down, effect)
52+ },
53+ },
54+ {
55+ name: "stopped session resume", action: "stopped-session",
56+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
57+ d.mu.Lock()
58+ delete(d.sessions, target.id)
59+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
60+ d.mu.Unlock()
61+ expectedCreatedAt := int64(9)
62+ return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
63+ },
64+ check: func(t *testing.T, d *Daemon, _, _ *session) {
65+ d.mu.Lock()
66+ _, stopped := d.stopped["stopped"]
67+ resumed := d.findByNameLocked("stopped")
68+ d.mu.Unlock()
69+ require.True(t, stopped)
70+ require.Nil(t, resumed)
71+ },
72+ },
73+ {
74+ name: "transition prompt creation", action: "create-session",
75+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error {
76+ return d.createSessionAndSwitchForRole(effect.roleToken(), "created")
77+ },
78+ check: func(t *testing.T, d *Daemon, _, _ *session) {
79+ d.mu.Lock()
80+ created := d.findByNameLocked("created")
81+ d.mu.Unlock()
82+ require.Nil(t, created, "stale prompt submission created a target session")
83+ },
84+ },
85+ }
86+
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ d, source, old, _, releases := newManualTabSession(t, 2)
90+ defer releaseAll(releases)
91+ d.ptys = newFactorySeq(t)
92+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
93+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
94+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
95+ }}
96+ d.mu.Lock()
97+ d.sessions[target.id] = target
98+ d.mu.Unlock()
99+
100+ rc := d.attachCoordinator(source, nil, old, true)
101+ token := source.attachmentToken(old, old.transport())
102+ token.lease = rc.attachmentLease(old)
103+ old.publishRoleCapability(token)
104+ effect, admitted := old.beginRoleEffect(token)
105+ require.True(t, admitted)
106+
107+ admissionEnded := make(chan struct{})
108+ releaseAction := make(chan struct{})
109+ var admissionEndedOnce sync.Once
110+ d.afterActionRoleEffectEnded = func(action string) {
111+ if action == tt.action {
112+ admissionEndedOnce.Do(func() { close(admissionEnded) })
113+ <-releaseAction
114+ }
115+ }
116+ actionDone := make(chan error, 1)
117+ go func() { actionDone <- tt.run(d, source, target, old, effect) }()
118+ select {
119+ case <-admissionEnded:
120+ case <-time.After(time.Second):
121+ t.Fatal("handoff did not release its role ticket")
122+ }
123+
124+ replacement, err := d.transitionAttachment(attachmentTransitionRequest{
125+ source: source, target: source, next: old,
126+ expectedRole: attachmentActive, targetRole: attachmentActive,
127+ expectedTransport: old.transportSnapshot(), ready: true,
128+ })
129+ require.NoError(t, err)
130+ close(releaseAction)
131+ require.Error(t, <-actionDone)
132+
133+ require.Same(t, source, old.currentSession())
134+ source.mu.Lock()
135+ require.Same(t, old, source.client)
136+ source.mu.Unlock()
137+ target.mu.Lock()
138+ require.Nil(t, target.client, "stale handoff attached to the target")
139+ require.Zero(t, target.active, "stale handoff changed target tab focus")
140+ target.mu.Unlock()
141+ if tt.check != nil {
142+ tt.check(t, d, source, target)
143+ }
144+ d.deferAttachmentTransitionCleanups(replacement)
145+ })
146+ }
147+}
148+
149+func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
150+ d, source, old, _ := newManualSessionWithPTYs(t, nil)
151+ d.mu.Lock()
152+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153+ d.mu.Unlock()
154+
155+ rc := d.attachCoordinator(source, nil, old, true)
156+ token := source.attachmentToken(old, old.transport())
157+ token.lease = rc.attachmentLease(old)
158+ old.publishRoleCapability(token)
159+ effect, admitted := old.beginRoleEffect(token)
160+ require.True(t, admitted)
161+
162+ ended := make(chan struct{})
163+ release := make(chan struct{})
164+ var endedOnce sync.Once
165+ d.afterActionRoleEffectEnded = func(action string) {
166+ if action == "picker-stopped" {
167+ endedOnce.Do(func() { close(ended) })
168+ <-release
169+ }
170+ }
171+ expectedCreatedAt := int64(7)
172+ done := make(chan error, 1)
173+ go func() {
174+ done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
175+ }()
176+ select {
177+ case <-ended:
178+ case <-time.After(time.Second):
179+ t.Fatal("stopped-session handoff did not release its role ticket")
180+ }
181+
182+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
183+ next.initOverlays()
184+ replacement, err := d.transitionAttachment(attachmentTransitionRequest{
185+ target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
186+ expectedTransport: next.transportSnapshot(), ready: true,
187+ })
188+ require.NoError(t, err)
189+ close(release)
190+ require.Error(t, <-done)
191+
192+ d.mu.Lock()
193+ _, stillStopped := d.stopped["stopped"]
194+ resumed := d.findByNameLocked("stopped")
195+ d.mu.Unlock()
196+ require.True(t, stillStopped)
197+ require.Nil(t, resumed)
198+ d.deferAttachmentTransitionCleanups(replacement)
199+}
A internal/usecase/daemon/navigation_role_incarnation_test.go +81 -0
@@ -0,0 +1,81 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/picker"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ rebind func(*testing.T, *Daemon, *session, *attachedClient)
15+ }{
16+ {
17+ name: "role generation and lease replaced on same attachment",
18+ rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {
19+ result, err := d.transitionAttachment(attachmentTransitionRequest{
20+ source: source, target: source, next: ac,
21+ expectedRole: attachmentActive, targetRole: attachmentActive,
22+ expectedTransport: ac.transportSnapshot(), ready: true,
23+ })
24+ require.NoError(t, err)
25+ d.deferAttachmentTransitionCleanups(result)
26+ },
27+ },
28+ {
29+ name: "transport incarnation rebound on same attachment",
30+ rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) {
31+ ac.replaceTransport(&closeTrackingTransport{})
32+ current := source.attachmentToken(ac, ac.transport())
33+ current.lease = source.renderCoordinator().attachmentLease(ac)
34+ ac.publishRoleCapability(current)
35+ },
36+ },
37+ }
38+
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
43+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
44+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
45+ }}
46+ d.mu.Lock()
47+ d.sessions[target.id] = target
48+ d.mu.Unlock()
49+
50+ rc := d.attachCoordinator(source, nil, ac, true)
51+ token := source.attachmentToken(ac, ac.transport())
52+ token.lease = rc.attachmentLease(ac)
53+ ac.publishRoleCapability(token)
54+ effect, admitted := ac.beginRoleEffect(token)
55+ require.True(t, admitted)
56+
57+ ended := make(chan struct{})
58+ release := make(chan struct{})
59+ d.afterActionRoleEffectEnded = func(action string) {
60+ if action == "test-handoff" {
61+ close(ended)
62+ <-release
63+ }
64+ }
65+ done := make(chan error, 1)
66+ go func() {
67+ done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
68+ }()
69+ awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
70+ tt.rebind(t, d, source, ac)
71+ close(release)
72+ require.ErrorIs(t, awaitTestValue(t, done, "test handoff did not finish"), errAttachmentTransition)
73+
74+ require.Same(t, source, ac.currentSession())
75+ target.mu.Lock()
76+ require.Nil(t, target.client)
77+ require.Zero(t, target.active)
78+ target.mu.Unlock()
79+ })
80+ }
81+}
M internal/usecase/daemon/notify.go +5 -6
@@ -443,24 +443,23 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.
443 443
444 // dismissToast removes only the specified visible toast. In particular, a444 // dismissToast removes only the specified visible toast. In particular, a
445 // link-connected event must not clear clipboard or other notice state.445 // link-connected event must not clear clipboard or other notice state.
446-func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) {446+func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool {
447 if ac == nil || ac.overlays == nil {447 if ac == nil || ac.overlays == nil {
448- return448+ return false
449 }449 }
450 rt := ac.overlays450 rt := ac.overlays
451 rt.noticeMu.Lock()451 rt.noticeMu.Lock()
452+ defer rt.noticeMu.Unlock()
452 i := rt.indexOfToastLocked(code, sid)453 i := rt.indexOfToastLocked(code, sid)
453 if i < 0 {454 if i < 0 {
454- rt.noticeMu.Unlock()455+ return false
455- return
456 }456 }
457 rt.noticeToasts[i].timer.stop()457 rt.noticeToasts[i].timer.stop()
458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
459 if len(rt.noticeToasts) == 0 {459 if len(rt.noticeToasts) == 0 {
460 rt.noticeOverflow = 0460 rt.noticeOverflow = 0
461 }461 }
462- rt.noticeMu.Unlock()462+ return true
463- d.repaintForNotice(ac)
464 }463 }
465 464
466 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the465 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
M internal/usecase/daemon/notify_test.go +20 -24
@@ -71,23 +71,23 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
71 tr, _ := newCapturingTransport(t)71 tr, _ := newCapturingTransport(t)
72 replaced := make(chan *attachedClient, 1)72 replaced := make(chan *attachedClient, 1)
73 go func() {73 go func() {
74- current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})74+ current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
75 replaced <- current75 replaced <- current
76 }()76 }()
77 77
78 // Releasing the mutation lets the already-validated notice publish before78 // Releasing the mutation lets the already-validated notice publish before
79- // replacement can own the session. Thus no stale attachment can mutate a79+ // replacement can own the session. Snatch cleanup then clears the displaced
80- // toast stack after sess.client changes.80+ // attachment's presentation state without moving the notice to the new one.
81 close(releaseMutation)81 close(releaseMutation)
82 <-noticeDone82 <-noticeDone
83 current := <-replaced83 current := <-replaced
84+ d.attachmentCleanupWg.Wait()
84 85
85 history := d.notices.history()86 history := d.notices.history()
86 require.Len(t, history, 1)87 require.Len(t, history, 1)
87 require.Equal(t, domain.NoticeClipboard, history[0].Code)88 require.Equal(t, domain.NoticeClipboard, history[0].Code)
88 oldToasts, _ := visibleToasts(old)89 oldToasts, _ := visibleToasts(old)
89- require.Len(t, oldToasts, 1)90+ require.Empty(t, oldToasts)
90- require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code)
91 newToasts, _ := visibleToasts(current)91 newToasts, _ := visibleToasts(current)
92 require.Empty(t, newToasts)92 require.Empty(t, newToasts)
93 sess.mu.Lock()93 sess.mu.Lock()
@@ -97,10 +97,9 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
97 97
98 func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {98 func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
99 for _, tt := range []struct {99 for _, tt := range []struct {
100- name string100+ name string
101- setup func(*Daemon, *attachedClient, domain.SessionID)101+ setup func(*Daemon, *attachedClient, domain.SessionID)
102- notice ports.ClientNotice102+ notice ports.ClientNotice
103- wantToast domain.NoticeCode
104 }{103 }{
105 {104 {
106 name: "record",105 name: "record",
@@ -111,28 +110,23 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
111 setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {110 setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {
112 d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))111 d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))
113 },112 },
114- notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},113+ notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
115- wantToast: domain.NoticeConnection,
116 },114 },
117 } {115 } {
118 t.Run(tt.name, func(t *testing.T) {116 t.Run(tt.name, func(t *testing.T) {
119 d, sess, old, _ := newNoticeFixture(t, newNoticeClock())117 d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
120- tr, _ := newCapturingTransport(t)
121- current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
122 if tt.setup != nil {118 if tt.setup != nil {
123 tt.setup(d, old, sess.id)119 tt.setup(d, old, sess.id)
124 }120 }
121+ tr, _ := newCapturingTransport(t)
122+ current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
123+ d.attachmentCleanupWg.Wait()
125 124
126 d.handleClientNotice(sess, old, tt.notice)125 d.handleClientNotice(sess, old, tt.notice)
127 126
128 require.Empty(t, d.notices.history(), "a replaced client must not record notices")127 require.Empty(t, d.notices.history(), "a replaced client must not record notices")
129 oldToasts, _ := visibleToasts(old)128 oldToasts, _ := visibleToasts(old)
130- if tt.setup == nil {129+ require.Empty(t, oldToasts, "snatch cleanup must remove displaced notice presentation")
131- require.Empty(t, oldToasts)
132- } else {
133- require.Len(t, oldToasts, 1)
134- require.Equal(t, tt.wantToast, oldToasts[0].Code, "a replaced client must not dismiss toasts")
135- }
136 currentToasts, _ := visibleToasts(current)130 currentToasts, _ := visibleToasts(current)
137 require.Empty(t, currentToasts)131 require.Empty(t, currentToasts)
138 })132 })
@@ -140,7 +134,8 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
140 }134 }
141 135
142 func TestMalformedClientNoticeIsIgnored(t *testing.T) {136 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
143- d, _, ac, _ := newNoticeFixture(t, &noticeClock{})137+ d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
138+ d.attachCoordinator(sess, nil, ac, true)
144 tr, ok := ac.tr.(*portsmocks.MockTransport)139 tr, ok := ac.tr.(*portsmocks.MockTransport)
145 require.True(t, ok, "attached client transport must be a MockTransport")140 require.True(t, ok, "attached client transport must be a MockTransport")
146 frames := []ports.Frame{141 frames := []ports.Frame{
@@ -757,17 +752,18 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
757 tr, _ := newCapturingTransport(t)752 tr, _ := newCapturingTransport(t)
758 replaced := make(chan *attachedClient, 1)753 replaced := make(chan *attachedClient, 1)
759 go func() {754 go func() {
760- ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})755+ ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
761 replaced <- ac756 replaced <- ac
762 }()757 }()
763 close(releaseRoute)758 close(releaseRoute)
764 <-globalDone759 <-globalDone
765 current := <-replaced760 current := <-replaced
761+ d.attachmentCleanupWg.Wait()
766 762
767- // Replacement cannot make old a stale delivery target: it publishes only763+ // Replacement publishes only after delivery, then snatch cleanup removes
768- // after the selected attachment has received the global toast.764+ // the displaced attachment's presentation state.
769 oldToasts, _ := visibleToasts(old)765 oldToasts, _ := visibleToasts(old)
770- require.Len(t, oldToasts, 1)766+ require.Empty(t, oldToasts)
771 newToasts, _ := visibleToasts(current)767 newToasts, _ := visibleToasts(current)
772 require.Empty(t, newToasts)768 require.Empty(t, newToasts)
773 sess.mu.Lock()769 sess.mu.Lock()
M internal/usecase/daemon/overlay_runtime.go +117 -8
@@ -18,6 +18,8 @@ type overlayRuntime struct {
18 18
19 pickerMu sync.Mutex19 pickerMu sync.Mutex
20 picker *picker.Model20 picker *picker.Model
21+ pickerIntent pickerIntent
22+ pickerSource moveSourceLocator
21 pickerPreview *tab23 pickerPreview *tab
22 pickerPreviewSession *session24 pickerPreviewSession *session
23 pickerPreviewGeneration uint6425 pickerPreviewGeneration uint64
@@ -32,10 +34,11 @@ type overlayRuntime struct {
32 paletteFeedback string34 paletteFeedback string
33 palettePending []byte35 palettePending []byte
34 36
35- promptMu sync.Mutex37+ promptMu sync.Mutex
36- prompt *promptui.Model38+ prompt *promptui.Model
37- promptSubmit func(string) error39+ promptSubmit func(string) error
38- promptPending []byte40+ promptTransitionSubmit func(string, attachmentRoleToken) error
41+ promptPending []byte
39 42
40 resizeMu sync.Mutex43 resizeMu sync.Mutex
41 resizeActive bool44 resizeActive bool
@@ -228,21 +231,127 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
228 return active || prePublication231 return active || prePublication
229 }232 }
230 233
231-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {234+// clearForSnatch clears each overlay family independently. Every family
235+// revalidates the displaced role generation while holding only its own mutex,
236+// so cleanup can neither participate in cross-family lock inversion nor erase
237+// state published by a later promotion.
238+func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
239+ if token.ac == nil || token.ac.overlays == nil {
240+ return false
241+ }
242+ rt := token.ac.overlays
243+ current := func() bool {
244+ return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport)
245+ }
246+ afterFamily := func(family string) {
247+ if d.afterSnatchOverlayFamily != nil {
248+ d.afterSnatchOverlayFamily(family)
249+ }
250+ }
251+
252+ rt.pickerMu.Lock()
253+ if !current() {
254+ rt.pickerMu.Unlock()
255+ return false
256+ }
257+ previewSession := rt.pickerPreviewSession
258+ previewGeneration := rt.pickerPreviewGeneration
259+ rt.picker = nil
260+ rt.pickerIntent = pickerNavigate
261+ rt.pickerSource = moveSourceLocator{}
262+ rt.pickerPending = nil
263+ rt.pickerESC.stop()
264+ rt.pickerPreviewGeneration++
265+ rt.pickerPreview = nil
266+ rt.pickerPreviewSession = nil
267+ rt.pickerMu.Unlock()
268+ afterFamily("picker")
269+ // Subscription teardown may enter a coordinator and therefore runs outside
270+ // every overlay-family lock.
271+ d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
272+
273+ rt.paletteMu.Lock()
274+ if !current() {
275+ rt.paletteMu.Unlock()
276+ return false
277+ }
278+ token.ac.clearPaletteLocked()
279+ rt.paletteMu.Unlock()
280+ afterFamily("palette")
281+
282+ rt.promptMu.Lock()
283+ if !current() {
284+ rt.promptMu.Unlock()
285+ return false
286+ }
287+ rt.prompt = nil
288+ rt.promptSubmit = nil
289+ rt.promptTransitionSubmit = nil
290+ rt.promptPending = nil
291+ rt.promptMu.Unlock()
292+ afterFamily("prompt")
293+
294+ rt.resizeMu.Lock()
295+ if !current() {
296+ rt.resizeMu.Unlock()
297+ return false
298+ }
299+ rt.resizeActive = false
300+ rt.resizePending = nil
301+ rt.resizeESC.stop()
302+ rt.resizeMu.Unlock()
303+ afterFamily("resize")
304+
305+ rt.copyMu.Lock()
306+ if !current() {
307+ rt.copyMu.Unlock()
308+ return false
309+ }
310+ d.stopCopyPendingTimerLocked(token.ac)
311+ rt.copyPending = nil
312+ rt.clearCopyModeLocked()
313+ rt.statusFeedback = ""
314+ rt.copyMu.Unlock()
315+ afterFamily("copy")
316+
317+ rt.noticeMu.Lock()
318+ if !current() {
319+ rt.noticeMu.Unlock()
320+ return false
321+ }
322+ for i := range rt.noticeToasts {
323+ rt.noticeToasts[i].timer.stop()
324+ }
325+ rt.noticeToasts = nil
326+ rt.noticeOverflow = 0
327+ rt.noticeSeq++
328+ rt.noticesOverlay = nil
329+ rt.noticesPending = nil
330+ rt.noticesESC.stop()
331+ rt.noticeMu.Unlock()
332+ afterFamily("notice")
333+ return true
334+}
335+
336+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool {
232 if rt == nil || rt.ac == nil {337 if rt == nil || rt.ac == nil {
233 return false338 return false
234 }339 }
235 ac := rt.ac340 ac := rt.ac
341+ var effect *roleEffectTicket
342+ if len(effects) != 0 {
343+ effect = effects[0]
344+ }
236 if rt.promptActive() {345 if rt.promptActive() {
237- d.handlePromptInput(ac, data)346+ d.handlePromptInput(ac, data, effect)
238 return true347 return true
239 }348 }
240 if rt.paletteActive() {349 if rt.paletteActive() {
241- d.handlePaletteInput(ac, data)350+ d.handlePaletteInput(ac, data, effect)
242 return true351 return true
243 }352 }
244 if rt.pickerActive() {353 if rt.pickerActive() {
245- d.handlePickerInput(ac, data)354+ d.handlePickerInput(ac, data, effect)
246 return true355 return true
247 }356 }
248 if rt.noticesActive() {357 if rt.noticesActive() {
M internal/usecase/daemon/palette.go +97 -9
@@ -148,7 +148,7 @@ func (d *Daemon) recordPaletteUse(code string) {
148 d.paletteRecent = recent148 d.paletteRecent = recent
149 }149 }
150 150
151-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {151+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
152 sess := ac.currentSession()152 sess := ac.currentSession()
153 if sess == nil {153 if sess == nil {
154 return154 return
@@ -161,6 +161,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
161 var generation uint64161 var generation uint64
162 var rawQuery string162 var rawQuery string
163 changed, cancel, execute := false, false, false163 changed, cancel, execute := false, false, false
164+ var effect *roleEffectTicket
165+ if len(effects) != 0 {
166+ effect = effects[0]
167+ }
164 168
165 ac.overlays.paletteMu.Lock()169 ac.overlays.paletteMu.Lock()
166 if ac.overlays.palette == nil {170 if ac.overlays.palette == nil {
@@ -271,7 +275,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
271 } else {275 } else {
272 target.Stopped = true276 target.Stopped = true
273 }277 }
274- if err := d.switchToTarget(sess, ac, target); err != nil {278+ var err error
279+ if effect != nil {
280+ err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")
281+ } else {
282+ err = d.switchToTarget(sess, ac, target)
283+ }
284+ if effect != nil && errors.Is(err, errAttachmentTransition) {
285+ return
286+ }
287+ if err != nil {
275 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")288 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
276 d.invalidateRender(sess, ac, true, "palette.go")289 d.invalidateRender(sess, ac, true, "palette.go")
277 return290 return
@@ -289,8 +302,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
289 d.invalidateRender(sess, ac, true, "palette.go")302 d.invalidateRender(sess, ac, true, "palette.go")
290 return303 return
291 }304 }
292- exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}305+ exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect}
293 if err := exec.JumpRecentSession(rank); err != nil {306 if err := exec.JumpRecentSession(rank); err != nil {
307+ if errors.Is(err, errAttachmentTransition) {
308+ return
309+ }
294 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")310 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
295 d.invalidateRender(sess, ac, true, "palette.go")311 d.invalidateRender(sess, ac, true, "palette.go")
296 return312 return
@@ -301,20 +317,45 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
301 }317 }
302 return318 return
303 }319 }
304- if !ac.closeExecutedPalette(generation, rawQuery) {320+ roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
321+ if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
305 return322 return
306 }323 }
307 sess.dispatchMu.Lock()324 sess.dispatchMu.Lock()
308- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args)325+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
309 sess.dispatchMu.Unlock()326 sess.dispatchMu.Unlock()
327+ if roleHandoff {
328+ if current := ac.currentSession(); current != nil {
329+ currentToken := current.attachmentToken(ac, ac.transport())
330+ fresh, admitted := ac.beginRoleEffect(currentToken)
331+ if ac.closeExecutedPalette(generation, rawQuery) {
332+ d.invalidateRender(current, ac, true, "palette.go")
333+ }
334+ if admitted {
335+ fresh.End()
336+ }
337+ } else {
338+ ac.closeExecutedPalette(generation, rawQuery)
339+ }
340+ }
341+ if errors.Is(err, errAttachmentTransition) {
342+ return
343+ }
310 if err != nil {344 if err != nil {
311 d.log.Error("palette command failed", "err", err, "command", cmd.Code)345 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
312- d.reportError(sess, err)346+ d.reportError(sess, paletteCommandNoticeError(cmd, err))
313 } else {347 } else {
314 d.recordPaletteUse(cmd.Code)348 d.recordPaletteUse(cmd.Code)
315 }349 }
316 }350 }
317 351
352+func paletteCommandNoticeError(cmd command.Command, err error) error {
353+ if cmd.Scope == command.CommandScopeCrossSession {
354+ return movePickerUserError(err)
355+ }
356+ return err
357+}
358+
318 func paletteArgs(query string, cmd command.Command) []string {359 func paletteArgs(query string, cmd command.Command) []string {
319 action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)360 action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
320 if !ok {361 if !ok {
@@ -357,6 +398,7 @@ type paletteExec struct {
357 ac *attachedClient398 ac *attachedClient
358 recent []recentSession399 recent []recentSession
359 actions daemonActionRunner400 actions daemonActionRunner
401+ effect *roleEffectTicket
360 redrawClosedPalette bool402 redrawClosedPalette bool
361 }403 }
362 404
@@ -389,8 +431,11 @@ func (e paletteExec) CreateTab() error {
389 }431 }
390 432
391 func (e paletteExec) CreateSession() error {433 func (e paletteExec) CreateSession() error {
392- e.d.enterPrompt(e.sess, e.ac, " Create session ", "", func(name string) error {434+ e.d.enterTransitionPrompt(e.sess, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
393- return e.d.createSessionAndSwitch(e.sess, e.ac, name)435+ if token.ac == nil {
436+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
437+ }
438+ return e.d.createSessionAndSwitchForRole(token, name)
394 })439 })
395 return nil440 return nil
396 }441 }
@@ -429,6 +474,31 @@ func (e paletteExec) ClosePane() error {
429 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})474 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
430 }475 }
431 476
477+func (e paletteExec) OpenMovePanePicker() error {
478+ target := resolveDaemonActionTarget(e.sess)
479+ if target.tab == nil || target.pane == nil {
480+ return errMovePaneInvalid
481+ }
482+ return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{
483+ Session: sessionMoveLocator(e.sess),
484+ TabID: domain.TabStableID(target.tab.stableID),
485+ PaneID: domain.PaneStableID(target.pane.stableID),
486+ Client: e.ac,
487+ })
488+}
489+
490+func (e paletteExec) OpenMoveTabPicker() error {
491+ target := resolveDaemonActionTarget(e.sess)
492+ if target.tab == nil {
493+ return errMovePaneInvalid
494+ }
495+ return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{
496+ Session: sessionMoveLocator(e.sess),
497+ TabID: domain.TabStableID(target.tab.stableID),
498+ Client: e.ac,
499+ })
500+}
501+
432 func (e paletteExec) focus(direction layout.Direction) error {502 func (e paletteExec) focus(direction layout.Direction) error {
433 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})503 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
434 }504 }
@@ -463,11 +533,20 @@ func (e paletteExec) ToggleFloatingPane() error {
463 }533 }
464 534
465 func (e paletteExec) BackSession() error {535 func (e paletteExec) BackSession() error {
536+ if e.effect != nil {
537+ return e.d.backSessionForRole(e.effect.roleToken())
538+ }
466 e.d.backSession(e.sess, e.ac)539 e.d.backSession(e.sess, e.ac)
467 return nil540 return nil
468 }541 }
469 542
470 func (e paletteExec) Detach() error {543 func (e paletteExec) Detach() error {
544+ if e.effect != nil {
545+ if !e.d.clientGoneForRole(e.effect.roleToken(), true) {
546+ return errAttachmentTransition
547+ }
548+ return nil
549+ }
471 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)550 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
472 return nil551 return nil
473 }552 }
@@ -535,7 +614,16 @@ func (e paletteExec) JumpRecentSession(rank int) error {
535 if e.d.beforeRecentSessionHandoff != nil {614 if e.d.beforeRecentSessionHandoff != nil {
536 e.d.beforeRecentSessionHandoff()615 e.d.beforeRecentSessionHandoff()
537 }616 }
538- if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {617+ var err error
618+ if e.effect != nil {
619+ err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
620+ } else {
621+ err = e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
622+ }
623+ if e.effect != nil && errors.Is(err, errAttachmentTransition) {
624+ return err
625+ }
626+ if err != nil {
539 return command.ErrInvalidArguments627 return command.ErrInvalidArguments
540 }628 }
541 return nil629 return nil
M internal/usecase/daemon/palette_test.go +123 -6
@@ -143,6 +143,58 @@ func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
143 require.Equal(t, domain.NoticeTabSpawn, history[0].Code)143 require.Equal(t, domain.NoticeTabSpawn, history[0].Code)
144 }144 }
145 145
146+func TestPaletteCommandNoticeErrorPreservesTypedErrorsAndMapsMoveFailures(t *testing.T) {
147+ typedTabSpawn := domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab", errors.New("open failed"))
148+ tests := []struct {
149+ name, slug, message string
150+ err error
151+ code domain.NoticeCode
152+ severity domain.NoticeSeverity
153+ wantSame bool
154+ }{
155+ {name: "CNT typed command error", slug: "new-tab", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true},
156+ {name: "MPN no destination", slug: "move-pane", err: errNoMoveDestination, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "No destination available."},
157+ {name: "MTB warming floating", slug: "move-tab", err: errMoveFloatingWarming, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Wait for the floating pane to finish opening."},
158+ {name: "MPN final pane with floating slot", slug: "move-pane", err: errMoveFinalSourceFloating, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Close the floating pane or move the whole tab."},
159+ {name: "MPN destination too small", slug: "move-pane", err: errMoveTooSmall, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Not enough space in destination tab."},
160+ {name: "MTB stale destination", slug: "move-tab", err: errMoveStaleTarget, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Destination is no longer available."},
161+ {name: "MPN generic invalid", slug: "move-pane", err: errMovePaneInvalid, code: domain.NoticeInternal, severity: domain.NoticeError, message: "Move failed."},
162+ {name: "MPN typed application error", slug: "move-pane", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true},
163+ }
164+ for _, tt := range tests {
165+ t.Run(tt.name, func(t *testing.T) {
166+ cmd, ok := command.BySlug(tt.slug)
167+ require.True(t, ok)
168+ got := paletteCommandNoticeError(cmd, tt.err)
169+ if tt.wantSame {
170+ require.Same(t, tt.err, got)
171+ }
172+ var userErr *domain.UserError
173+ require.ErrorAs(t, got, &userErr)
174+ require.Equal(t, tt.code, userErr.Code)
175+ require.Equal(t, tt.severity, userErr.Severity)
176+ require.Equal(t, tt.message, userErr.Msg)
177+ })
178+ }
179+}
180+
181+func TestPaletteCommandNoticeErrorUsesCommandScope(t *testing.T) {
182+ t.Run("future cross-session command inherits move rejection presentation", func(t *testing.T) {
183+ cmd := command.Command{Slug: "future-transfer", Scope: command.CommandScopeCrossSession}
184+ got := paletteCommandNoticeError(cmd, errMoveStaleTarget)
185+ var userErr *domain.UserError
186+ require.ErrorAs(t, got, &userErr)
187+ require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code)
188+ require.Equal(t, "Destination is no longer available.", userErr.Msg)
189+ })
190+
191+ t.Run("move-like slug without metadata is unchanged", func(t *testing.T) {
192+ err := errors.New("ordinary failure")
193+ got := paletteCommandNoticeError(command.Command{Slug: "move-pane"}, err)
194+ require.Same(t, err, got)
195+ })
196+}
197+
146 func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {198 func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
147 p, release := newBlockingPTY(t)199 p, release := newBlockingPTY(t)
148 defer release()200 defer release()
@@ -287,12 +339,19 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
287 339
288 d.handleInput(current, ac, []byte("\x1b "))340 d.handleInput(current, ac, []byte("\x1b "))
289 awaitFrame(t, sends, ports.MsgOutput)341 awaitFrame(t, sends, ports.MsgOutput)
342+ generation := ac.roleGeneration.Load()
290 d.handleInput(current, ac, []byte("stopped\r"))343 d.handleInput(current, ac, []byte("stopped\r"))
291 344
292- require.Equal(t, "stopped", ac.currentSession().name)345+ resumed := ac.currentSession()
293- require.Equal(t, int64(42), ac.currentSession().createdAt)346+ require.Equal(t, "stopped", resumed.name)
347+ require.Equal(t, int64(42), resumed.createdAt)
348+ require.Equal(t, attachmentActive, resumed.attachmentRole(ac))
349+ require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
294 require.False(t, ac.overlays.paletteActive())350 require.False(t, ac.overlays.paletteActive())
295- awaitFrame(t, sends, ports.MsgOutput)351+ firstPaint := awaitFrame(t, sends, ports.MsgOutput)
352+ firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
353+ require.NoError(t, err)
354+ require.Zero(t, firstOutput.BaseStateNum, "stopped-session first paint must reset moving output state")
296 awaitFrame(t, sends, ports.MsgOutput)355 awaitFrame(t, sends, ports.MsgOutput)
297 }356 }
298 357
@@ -394,6 +453,58 @@ func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
394 require.Same(t, current, ac.currentSession())453 require.Same(t, current, ac.currentSession())
395 }454 }
396 455
456+func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
457+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
458+ defer releaseAll(releases)
459+
460+ // Establish a valid back-session target, then remove that target immediately
461+ // after the command releases its role admission.
462+ target := d.sessions[domain.SessionID("recent")]
463+ ac.previousSession.Set(target)
464+ invalidations := installPaletteInvalidationObserver(current)
465+ d.enterPalette(current, ac)
466+ d.handlePaletteInput(ac, []byte("BSK"))
467+ d.afterActionRoleEffectEnded = func(action string) {
468+ if action == "back-session" {
469+ d.mu.Lock()
470+ delete(d.sessions, target.id)
471+ d.mu.Unlock()
472+ }
473+ }
474+
475+ token := current.attachmentToken(ac, ac.transport())
476+ effect, admitted := ac.beginRoleEffect(token)
477+ require.True(t, admitted)
478+ d.handlePaletteInput(ac, []byte("\r"), effect)
479+
480+ require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette")
481+ require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment")
482+ invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering")
483+ require.True(t, invalidation.reset)
484+}
485+
486+func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
487+ p, release := newBlockingPTY(t)
488+ defer release()
489+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
490+ invalidations := installPaletteInvalidationObserver(sess)
491+
492+ d.enterPalette(sess, ac)
493+ d.handlePaletteInput(ac, []byte("BSK"))
494+ // Make the attachment token detached while retaining currentSession. The
495+ // no-op BSK command succeeds, but its post-execution beginRoleEffect is
496+ // deterministically denied.
497+ sess.mu.Lock()
498+ sess.client = nil
499+ sess.mu.Unlock()
500+
501+ d.handlePaletteInput(ac, []byte("\r"))
502+
503+ require.False(t, ac.overlays.paletteActive(), "denied cleanup admission must still close the executed palette")
504+ invalidation := awaitTestValue(t, invalidations, "denied palette cleanup did not invalidate rendering")
505+ require.True(t, invalidation.reset)
506+}
507+
397 func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {508 func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
398 p, release := newBlockingPTY(t)509 p, release := newBlockingPTY(t)
399 defer release()510 defer release()
@@ -538,11 +649,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
538 require.True(t, ac.overlays.promptActive())649 require.True(t, ac.overlays.promptActive())
539 require.Contains(t, string(promptOutput.Data), "Create session")650 require.Contains(t, string(promptOutput.Data), "Create session")
540 651
652+ generation := ac.roleGeneration.Load()
541 d.handleInput(sess, ac, []byte("scratch\r"))653 d.handleInput(sess, ac, []byte("scratch\r"))
542 // The submit first paints the newly attached session while the prompt is654 // The submit first paints the newly attached session while the prompt is
543 // still open, then handlePromptInput closes the prompt and repaints the655 // still open, then handlePromptInput closes the prompt and repaints the
544 // client's current session. The final frame must be for the new session.656 // client's current session. The final frame must be for the new session.
545- awaitFrame(t, sends, ports.MsgOutput)657+ firstPaint := awaitFrame(t, sends, ports.MsgOutput)
658+ firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
659+ require.NoError(t, err)
660+ require.Zero(t, firstOutput.BaseStateNum, "new-session first paint must reset moving output state")
546 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)661 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
547 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)662 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
548 require.NoError(t, err)663 require.NoError(t, err)
@@ -555,6 +670,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
555 require.Equal(t, "scratch", newSess.name)670 require.Equal(t, "scratch", newSess.name)
556 require.False(t, newSess.ephemeral)671 require.False(t, newSess.ephemeral)
557 require.Same(t, ac, newSess.client)672 require.Same(t, ac, newSess.client)
673+ require.Equal(t, attachmentActive, newSess.attachmentRole(ac))
674+ require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
558 require.Contains(t, string(finalOutput.Data), "scratch")675 require.Contains(t, string(finalOutput.Data), "scratch")
559 require.NotContains(t, string(finalOutput.Data), "Create session")676 require.NotContains(t, string(finalOutput.Data), "Create session")
560 }677 }
@@ -625,12 +742,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
625 for i, cmd := range commands {742 for i, cmd := range commands {
626 codes[i] = cmd.Code743 codes[i] = cmd.Code
627 }744 }
628- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)745+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
629 }746 }
630 747
631 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {748 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
632 d := &Daemon{}749 d := &Daemon{}
633- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}750+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
634 751
635 var wg sync.WaitGroup752 var wg sync.WaitGroup
636 for range 50 {753 for range 50 {
M internal/usecase/daemon/pane.go +132 -17
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "sync/atomic"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
@@ -10,6 +11,97 @@ import (
10 "github.com/bnema/vev/pkg/vt"11 "github.com/bnema/vev/pkg/vt"
11 )12 )
12 13
14+// paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon
15+// parent lets an installed pane move between sessions without changing process
16+// lifetime. Temporary opening parents bound an unpublished Open; publish
17+// atomically detaches those parents before transferring cancellation to pane.
18+type paneProcessLifetime struct {
19+ ctx context.Context
20+ cancel context.CancelFunc
21+
22+ mu sync.Mutex
23+ opening bool
24+ openingParents []context.Context
25+ stopOpening []func() bool
26+}
27+
28+func (d *Daemon) newPaneProcessLifetime(openingParents ...context.Context) *paneProcessLifetime {
29+ parent := d.paneProcessCtx
30+ if parent == nil {
31+ parent = context.Background()
32+ }
33+ ctx, rawCancel := context.WithCancel(parent)
34+ var cancelOnce sync.Once
35+ lifetime := &paneProcessLifetime{
36+ ctx: ctx,
37+ cancel: func() { cancelOnce.Do(rawCancel) },
38+ opening: true,
39+ openingParents: append([]context.Context(nil), openingParents...),
40+ }
41+ for _, openingParent := range lifetime.openingParents {
42+ if openingParent == nil {
43+ continue
44+ }
45+ lifetime.stopOpening = append(lifetime.stopOpening, context.AfterFunc(openingParent, func() {
46+ lifetime.mu.Lock()
47+ if lifetime.opening {
48+ lifetime.cancel()
49+ }
50+ lifetime.mu.Unlock()
51+ }))
52+ }
53+ return lifetime
54+}
55+
56+// publish transfers a successfully opened process to p. A cancellation that
57+// wins before this call prevents publication; one that starts afterward no
58+// longer belongs to the source session or tab.
59+func (l *paneProcessLifetime) publish(p *pane) bool {
60+ if l == nil || p == nil {
61+ return false
62+ }
63+ l.mu.Lock()
64+ defer l.mu.Unlock()
65+ for _, parent := range l.openingParents {
66+ if parent != nil && parent.Err() != nil {
67+ l.opening = false
68+ l.stopOpeningParentsLocked()
69+ l.cancel()
70+ return false
71+ }
72+ }
73+ if l.ctx.Err() != nil {
74+ l.opening = false
75+ l.stopOpeningParentsLocked()
76+ l.cancel()
77+ return false
78+ }
79+ l.opening = false
80+ l.stopOpeningParentsLocked()
81+ p.ctx = l.ctx
82+ p.cancel = l.cancel
83+ return true
84+}
85+
86+func (l *paneProcessLifetime) abort() {
87+ if l == nil {
88+ return
89+ }
90+ l.mu.Lock()
91+ l.opening = false
92+ l.stopOpeningParentsLocked()
93+ l.cancel()
94+ l.mu.Unlock()
95+}
96+
97+func (l *paneProcessLifetime) stopOpeningParentsLocked() {
98+ for _, stop := range l.stopOpening {
99+ stop()
100+ }
101+ l.stopOpening = nil
102+ l.openingParents = nil
103+}
104+
13 // pane owns one PTY and its terminal screen state.105 // pane owns one PTY and its terminal screen state.
14 // Lock order when multiple locks are held is:106 // Lock order when multiple locks are held is:
15 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.107 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
@@ -18,31 +110,54 @@ type pane struct {
18 id layout.PaneID110 id layout.PaneID
19 stableID string111 stableID string
20 pty ports.PTY112 pty ports.PTY
21- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title113+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, title, and owner publication
22- resizeMu sync.Mutex // serializes PTY resizes without holding mu114+ owner atomic.Pointer[paneOwner]
23- screen *vt.Screen115+ // ownerGeneration is advanced only while mu is held. Readers consume the
24- history *vt.History116+ // generation through the immutable owner pointer above.
25- syncGen uint64117+ ownerGeneration uint64
26- rect domain.Rect118+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
119+ screen *vt.Screen
120+ history *vt.History
121+ syncGen uint64
122+ rect domain.Rect
27 // resizeApplying gates VT parsing across PTY.Resize. The reader continues123 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
28 // draining into resizePending so output is replayed against the target (or124 // draining into resizePending so output is replayed against the target (or
29 // retained old) screen only after apply resolves.125 // retained old) screen only after apply resolves.
30- resizeApplying bool126+ resizeApplying bool
31- resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.127+ resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.
32- resizePending []byte128+ resizePending []byte
33- ptyResponses []byte129+ ptyResponses []byte
34- ptyClipboards []string130+ ptyClipboards []string
35- ptyAttention bool131+ ptyAttention bool
36- popupGeometry floatingGeometry // last geometry committed after a successful floating resize132+ popupGeometry floatingGeometry // last geometry committed after a successful floating resize
37- title paneTitleState133+ title paneTitleState
38- ctx context.Context134+ ctx context.Context
39- cancel context.CancelFunc135+ cancel context.CancelFunc
40- floatingCloseOnce sync.Once // used only by floating lifecycle teardown paths136+ processCloseOnce sync.Once
41 // onExit is set before the reader starts and never changed. Floating panes137 // onExit is set before the reader starts and never changed. Floating panes
42 // use it to reap their independent slot without touching the layout tree.138 // use it to reap their independent slot without touching the layout tree.
43 onExit func()139 onExit func()
44 }140 }
45 141
142+func closePaneProcess(p *pane) {
143+ if p == nil {
144+ return
145+ }
146+ p.processCloseOnce.Do(func() {
147+ // Revocation shares pane.mu with VT parsing and owner publication, so no
148+ // effect lease remains current once terminal teardown begins.
149+ p.mu.Lock()
150+ p.clearOwnerLocked()
151+ p.mu.Unlock()
152+ if p.cancel != nil {
153+ p.cancel()
154+ }
155+ if p.pty != nil {
156+ _ = p.pty.Close()
157+ }
158+ })
159+}
160+
46 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {161 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
47 return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)162 return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)
48 }163 }
M internal/usecase/daemon/pane_actions.go +122 -14
@@ -1,9 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "context"
5 "errors"4 "errors"
6 "fmt"5 "fmt"
6+ "slices"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
@@ -80,24 +80,35 @@ func (d *Daemon) spawnPaneOpAt(
80 return fmt.Errorf("daemon: generating pane identity: %w", err)80 return fmt.Errorf("daemon: generating pane identity: %w", err)
81 }81 }
82 command, args := d.ptyCommand(env)82 command, args := d.ptyCommand(env)
83- pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))83+ lifetime := d.newPaneProcessLifetime(tb.ctx)
84+ pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
84 if err != nil {85 if err != nil {
86+ lifetime.abort()
87+ if pty != nil {
88+ _ = pty.Close()
89+ }
85 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")90 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
86 return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)91 return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
87 }92 }
88 93
89- pctx, cancel := context.WithCancel(tb.ctx)
90 p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))94 p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
91- p.ctx, p.cancel = pctx, cancel
92 p.rect = newRect95 p.rect = newRect
93 96
94 tb.mu.Lock()97 tb.mu.Lock()
95- if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {98+ if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) || tb.ctx != nil && tb.ctx.Err() != nil {
96 tb.mu.Unlock()99 tb.mu.Unlock()
97- cancel()100+ lifetime.abort()
98 _ = pty.Close()101 _ = pty.Close()
99 return layout.ErrNotFound102 return layout.ErrNotFound
100 }103 }
104+ if !lifetime.publish(p) {
105+ tb.mu.Unlock()
106+ _ = pty.Close()
107+ return layout.ErrNotFound
108+ }
109+ // The tab lock excludes membership observers while pane.mu publishes the
110+ // initial owner generation.
111+ publishPaneOwner(p, sess, tb, 0)
101 tb.tree = candidate112 tb.tree = candidate
102 tb.panes[newID] = p113 tb.panes[newID] = p
103 tb.bumpLayoutGenerationLocked()114 tb.bumpLayoutGenerationLocked()
@@ -193,11 +204,110 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
193 return d.closePane(sess, tb, id, ac, true)204 return d.closePane(sess, tb, id, ac, true)
194 }205 }
195 206
207+// reapPane retains the explicit-owner entry point for focused close tests. PTY
208+// readers use reapPaneOwner so an owner publication cannot redirect exit to a
209+// replacement that reused the same tab-local pane ID.
196 func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {210 func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
197 if p == nil {211 if p == nil {
198 return212 return
199 }213 }
200- _ = d.closePane(sess, tb, p.id, nil, true)214+ lease := p.effectLease()
215+ if lease.owner == nil {
216+ _ = d.closePane(sess, tb, p.id, nil, true)
217+ return
218+ }
219+ if lease.owner.session != sess || lease.owner.tab != tb {
220+ return
221+ }
222+ d.reapPaneOwner(p)
223+}
224+
225+// reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
226+// the immutable owner pointer; a successful attempt revokes owner publication
227+// under pane.mu before releasing membership locks, so a competing close or
228+// move can never reap the pane from a second owner.
229+func (d *Daemon) reapPaneOwner(p *pane) {
230+ for p != nil {
231+ lease := p.effectLease()
232+ if lease.owner == nil {
233+ return
234+ }
235+ if d.reapTiledPaneLease(lease) {
236+ return
237+ }
238+ }
239+}
240+
241+func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
242+ p, owner := lease.pane, lease.owner
243+ if p == nil || owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration != 0 {
244+ return true
245+ }
246+ sess, tb := owner.session, owner.tab
247+ sess.mu.Lock()
248+ ownedTab := slices.Contains(sess.tabs, tb)
249+ if p.owner.Load() != owner {
250+ sess.mu.Unlock()
251+ return false
252+ }
253+ if !ownedTab {
254+ sess.mu.Unlock()
255+ return true
256+ }
257+ ac := sess.client
258+ sessionName := sess.name
259+ tb.mu.Lock()
260+ if p.owner.Load() != owner {
261+ tb.mu.Unlock()
262+ sess.mu.Unlock()
263+ return false
264+ }
265+ if tb.panes[p.id] != p || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, p.id) {
266+ tb.mu.Unlock()
267+ sess.mu.Unlock()
268+ return true
269+ }
270+ p.mu.Lock()
271+ if p.owner.Load() != owner {
272+ p.mu.Unlock()
273+ tb.mu.Unlock()
274+ sess.mu.Unlock()
275+ return false
276+ }
277+ finalPane := len(layout.LeafIDs(tb.tree.Root)) <= 1
278+ if !finalPane {
279+ if err := tb.tree.Close(p.id); err != nil {
280+ p.mu.Unlock()
281+ tb.mu.Unlock()
282+ sess.mu.Unlock()
283+ return true
284+ }
285+ delete(tb.panes, p.id)
286+ tb.bumpLayoutGenerationLocked()
287+ }
288+ p.clearOwnerLocked()
289+ p.mu.Unlock()
290+ tb.mu.Unlock()
291+ sess.mu.Unlock()
292+
293+ if finalPane {
294+ _ = d.closeTab(sess, tb, true)
295+ return true
296+ }
297+ d.applyTabLayout(sess, tb)
298+ if ac != nil {
299+ ac.overlays.clearCopyModeForPane(p)
300+ ac.pruneCaptureFrames(p)
301+ }
302+ if rc := sess.renderCoordinator(); rc != nil {
303+ rc.noteSyncPaneRemoved(p)
304+ }
305+ closePaneProcess(p)
306+ d.log.Info("pane closed", "session", sessionName, "pane", p.id)
307+ if ac != nil {
308+ d.invalidateRender(sess, ac, true, "pane_actions.go")
309+ }
310+ return true
201 }311 }
202 312
203 func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {313 func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
@@ -239,12 +349,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
239 if rc := sess.renderCoordinator(); rc != nil {349 if rc := sess.renderCoordinator(); rc != nil {
240 rc.noteSyncPaneRemoved(p)350 rc.noteSyncPaneRemoved(p)
241 }351 }
242- if p.cancel != nil {352+ closePaneProcess(p)
243- p.cancel()
244- }
245- if p.pty != nil {
246- _ = p.pty.Close()
247- }
248 d.log.Info("pane closed", "session", sess.name, "pane", id)353 d.log.Info("pane closed", "session", sess.name, "pane", id)
249 if repaint {354 if repaint {
250 if ac != nil {355 if ac != nil {
@@ -254,7 +359,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
254 return nil359 return nil
255 }360 }
256 361
257-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {362+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {
258 target := resolveDaemonActionTarget(sess)363 target := resolveDaemonActionTarget(sess)
259 oldFocus := layout.PaneID("")364 oldFocus := layout.PaneID("")
260 if target.pane != nil {365 if target.pane != nil {
@@ -279,6 +384,9 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
279 if ac == nil {384 if ac == nil {
280 return errNoNeighbor385 return errNoNeighbor
281 }386 }
387+ if effect != nil {
388+ return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
389+ }
282 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
283 }391 }
284 392
M internal/usecase/daemon/pane_actions_test.go +2 -2
@@ -625,7 +625,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
625 ac.initOverlays()625 ac.initOverlays()
626 ac.overlays.copyMode = &scopy.Mode{}626 ac.overlays.copyMode = &scopy.Mode{}
627 627
628- require.NoError(t, d.focusDir(sess, ac, layout.Right))628+ require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
629 629
630 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)630 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
631 require.Nil(t, ac.overlays.copyMode)631 require.Nil(t, ac.overlays.copyMode)
@@ -726,7 +726,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
726 require.NoError(t, d.stackPane(sess, nil))726 require.NoError(t, d.stackPane(sess, nil))
727 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()727 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
728 728
729- require.NoError(t, d.focusDir(sess, nil, layout.Up))729+ require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
730 730
731 tb := sess.activeTab()731 tb := sess.activeTab()
732 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)732 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
A internal/usecase/daemon/pane_lifetime_test.go +335 -0
@@ -0,0 +1,335 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/usecase/layout"
17+)
18+
19+type lifetimePTY struct {
20+ ctx context.Context
21+ closed chan struct{}
22+ closeOnce sync.Once
23+ closes atomic.Int32
24+}
25+
26+func newLifetimePTY() *lifetimePTY {
27+ return &lifetimePTY{closed: make(chan struct{})}
28+}
29+
30+func (p *lifetimePTY) Read([]byte) (int, error) {
31+ select {
32+ case <-p.ctx.Done():
33+ return 0, p.ctx.Err()
34+ case <-p.closed:
35+ return 0, io.EOF
36+ }
37+}
38+
39+func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (*lifetimePTY) Resize(domain.Size) error { return nil }
41+func (*lifetimePTY) Pid() int { return 1 }
42+func (*lifetimePTY) ForegroundPgid() (int, error) {
43+ return 1, nil
44+}
45+func (p *lifetimePTY) Close() error {
46+ p.closes.Add(1)
47+ p.closeOnce.Do(func() { close(p.closed) })
48+ return nil
49+}
50+
51+type lifetimePTYFactory struct {
52+ mu sync.Mutex
53+ ptys []*lifetimePTY
54+ contexts []context.Context
55+}
56+
57+func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
58+ p := newLifetimePTY()
59+ p.ctx = ctx
60+ f.mu.Lock()
61+ f.ptys = append(f.ptys, p)
62+ f.contexts = append(f.contexts, ctx)
63+ f.mu.Unlock()
64+ return p, nil
65+}
66+
67+func (f *lifetimePTYFactory) opened(index int) (*lifetimePTY, context.Context) {
68+ f.mu.Lock()
69+ defer f.mu.Unlock()
70+ return f.ptys[index], f.contexts[index]
71+}
72+
73+func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
74+ type openCase struct {
75+ name string
76+ wantCode domain.NoticeCode
77+ run func(*testing.T, *Daemon) ([]*tab, func(), error)
78+ }
79+ tests := []openCase{
80+ {
81+ name: "initial pane",
82+ wantCode: domain.NoticeSessionSpawn,
83+ run: func(_ *testing.T, d *Daemon) ([]*tab, func(), error) {
84+ sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
85+ if sess != nil {
86+ return sess.tabs, func() { closeTabs(sess.tabs) }, err
87+ }
88+ return nil, d.serveCancel, err
89+ },
90+ },
91+ {
92+ name: "created tab",
93+ wantCode: domain.NoticeTabSpawn,
94+ run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
95+ sessCtx, cancelSession := context.WithCancel(t.Context())
96+ base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
97+ base.ctx, base.cancel = context.WithCancel(sessCtx)
98+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
99+ d.mu.Lock()
100+ d.sessions[sess.id] = sess
101+ d.mu.Unlock()
102+ err := d.createTab(sess, domain.Size{Cols: 80, Rows: 24})
103+ return sess.tabs, func() {
104+ cancelSession()
105+ closeTabs(sess.tabs)
106+ }, err
107+ },
108+ },
109+ {
110+ name: "split pane",
111+ wantCode: domain.NoticePaneSpawn,
112+ run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
113+ sessCtx, cancelSession := context.WithCancel(t.Context())
114+ tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})
115+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
116+ sess := &session{id: "work", name: "work", cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
117+ err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right)
118+ return []*tab{tb}, func() {
119+ cancelSession()
120+ tb.closeAllPanes()
121+ }, err
122+ },
123+ },
124+ {
125+ name: "restored pane",
126+ run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
127+ restoreCtx, cancelRestore := context.WithCancel(t.Context())
128+ sessCtx, cancelSession := context.WithCancel(t.Context())
129+ tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
130+ return tabs, func() {
131+ cancelRestore()
132+ cancelSession()
133+ closeRestoredTabs(tabs)
134+ }, err
135+ },
136+ },
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ cause := errors.New("open failed")
142+ pty := newFailedOpenPTY()
143+ cancelCallbacks := atomic.Int32{}
144+ cancelled := make(chan struct{})
145+ openFactory := paneErrorFactory{
146+ pty: pty,
147+ err: cause,
148+ onOpen: func(ctx context.Context) {
149+ pty.ctx = ctx
150+ context.AfterFunc(ctx, func() {
151+ cancelCallbacks.Add(1)
152+ close(cancelled)
153+ })
154+ },
155+ }
156+ d := newTestDaemon(t, &openFactory, stubClock{})
157+
158+ tabs, cleanup, err := tt.run(t, d)
159+ require.ErrorIs(t, err, cause)
160+ if tt.wantCode != 0 {
161+ var userErr *domain.UserError
162+ require.ErrorAs(t, err, &userErr)
163+ require.Equal(t, tt.wantCode, userErr.Code)
164+ }
165+ select {
166+ case <-cancelled:
167+ case <-time.After(time.Second):
168+ t.Fatal("failed pane Open process context was not cancelled")
169+ }
170+
171+ for _, tb := range tabs {
172+ if tb == nil {
173+ continue
174+ }
175+ tb.mu.Lock()
176+ floating := tb.floating.pane
177+ var floatingPTY ports.PTY
178+ if floating != nil {
179+ floating.mu.Lock()
180+ floatingPTY = floating.pty
181+ floating.mu.Unlock()
182+ }
183+ publishedFailedPTY := false
184+ for _, published := range tb.panes {
185+ publishedFailedPTY = publishedFailedPTY || published.pty == pty
186+ }
187+ tb.mu.Unlock()
188+ require.NotEqual(t, pty, floatingPTY)
189+ require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
190+ }
191+ require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
192+ require.Equal(t, int32(1), pty.closes.Load(), "failed Open PTY must close exactly once")
193+ require.Equal(t, int32(1), cancelCallbacks.Load(), "failed Open context must cancel exactly once")
194+
195+ cleanup()
196+ require.Equal(t, int32(1), pty.closes.Load(), "later teardown must not close an unpublished PTY again")
197+ require.Equal(t, int32(1), cancelCallbacks.Load(), "later teardown must not recancel an unpublished process")
198+ })
199+ }
200+}
201+
202+type paneErrorFactory struct {
203+ pty ports.PTY
204+ err error
205+ onOpen func(context.Context)
206+}
207+
208+func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
209+ if f.onOpen != nil {
210+ f.onOpen(ctx)
211+ }
212+ return f.pty, f.err
213+}
214+
215+func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
216+ type setupFunc func(*testing.T, *Daemon) (*pane, func(), func())
217+ tests := []struct {
218+ name string
219+ setup setupFunc
220+ }{
221+ {
222+ name: "initial pane",
223+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
224+ sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
225+ require.NoError(t, err)
226+ return sess.activeTab().focusedPane(), sess.cancel, func() {
227+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
228+ }
229+ },
230+ },
231+ {
232+ name: "created tab",
233+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
234+ sessCtx, cancelSession := context.WithCancel(t.Context())
235+ base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
236+ base.ctx, base.cancel = context.WithCancel(sessCtx)
237+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
238+ d.mu.Lock()
239+ d.sessions[sess.id] = sess
240+ d.mu.Unlock()
241+ require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
242+ created := sess.activeTab()
243+ return created.focusedPane(), cancelSession, created.closeAllPanes
244+ },
245+ },
246+ {
247+ name: "split pane",
248+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
249+ sessCtx, cancelSession := context.WithCancel(t.Context())
250+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
251+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
252+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
253+ target := tb.focusedPane()
254+ require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
255+ tb.mu.Lock()
256+ p := tb.panes["pane-2"]
257+ tb.mu.Unlock()
258+ require.NotNil(t, p)
259+ return p, cancelSession, tb.closeAllPanes
260+ },
261+ },
262+ {
263+ name: "floating pane",
264+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
265+ sessCtx, cancelSession := context.WithCancel(t.Context())
266+ tb := newFloatingTestTab(t)
267+ tb.ctx, tb.cancel = context.WithCancel(sessCtx)
268+ sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
269+ tb.mu.Lock()
270+ generation := tb.beginFloatingWarmLocked(true)
271+ tb.mu.Unlock()
272+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
273+ sessionName: "work", size: domain.Size{Cols: 20, Rows: 8},
274+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
275+ paneStableID: "p_floating", parentCtx: tb.ctx,
276+ }, generation)
277+ tb.mu.Lock()
278+ p := tb.floating.pane
279+ tb.mu.Unlock()
280+ require.NotNil(t, p)
281+ return p, cancelSession, func() { d.teardownFloating(tb, nil) }
282+ },
283+ },
284+ {
285+ name: "restored pane",
286+ setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
287+ restoreCtx, cancelRestore := context.WithCancel(t.Context())
288+ sessCtx, cancelSession := context.WithCancel(t.Context())
289+ tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
290+ require.NoError(t, err)
291+ require.Len(t, tabs, 1)
292+ return tabs[0].focusedPane(), cancelSession, func() {
293+ cancelRestore()
294+ closeRestoredTabs(tabs)
295+ }
296+ },
297+ },
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ factory := &lifetimePTYFactory{}
303+ d := newTestDaemon(t, factory, stubClock{})
304+ p, cancelSession, teardown := tt.setup(t, d)
305+ defer func() {
306+ if teardown != nil {
307+ teardown()
308+ }
309+ }()
310+
311+ require.NotNil(t, p)
312+ pty, openCtx := factory.opened(0)
313+ require.NotNil(t, p.ctx)
314+ require.Same(t, openCtx, p.ctx)
315+ require.NotNil(t, p.cancel)
316+
317+ cancelSession()
318+ select {
319+ case <-openCtx.Done():
320+ t.Fatal("session cancellation killed a published pane process")
321+ default:
322+ }
323+
324+ teardown()
325+ teardown = nil
326+ select {
327+ case <-openCtx.Done():
328+ case <-time.After(time.Second):
329+ t.Fatal("teardown did not cancel the pane process")
330+ }
331+ d.sessWg.Wait()
332+ require.Equal(t, int32(1), pty.closes.Load())
333+ })
334+ }
335+}
A internal/usecase/daemon/pane_owner.go +143 -0
@@ -0,0 +1,143 @@
1+package daemon
2+
3+// paneOwner is an immutable routing snapshot for one published pane. A zero
4+// floatingSlotGeneration identifies a tiled pane; installed floating panes
5+// carry the tab slot generation that owns them.
6+type paneOwner struct {
7+ session *session
8+ tab *tab
9+ generation uint64
10+ floatingSlotGeneration uint64
11+}
12+
13+// paneEffectLease binds an effect to the exact immutable owner generation that
14+// existed while the pane was parsed or otherwise inspected under pane.mu.
15+type paneEffectLease struct {
16+ pane *pane
17+ owner *paneOwner
18+}
19+
20+// Current reports whether the lease still names the published pane owner. For
21+// floating panes it also verifies that the same tab slot and slot generation
22+// remain installed. Callers must not hold pane.mu or tab.mu.
23+func (l paneEffectLease) Current() bool {
24+ return l.pane != nil && l.pane.ownerLeaseCurrent(l)
25+}
26+
27+// ownerSnapshot returns the immutable owner visible to lock-free routing
28+// readers. Ownership publication itself is serialized under pane.mu.
29+func (p *pane) ownerSnapshot() *paneOwner {
30+ if p == nil {
31+ return nil
32+ }
33+ return p.owner.Load()
34+}
35+
36+// effectLeaseLocked captures the current owner while pane.mu linearizes VT
37+// parsing against transfer. The caller must hold pane.mu.
38+func (p *pane) effectLeaseLocked() paneEffectLease {
39+ if p == nil {
40+ return paneEffectLease{}
41+ }
42+ return paneEffectLease{pane: p, owner: p.owner.Load()}
43+}
44+
45+// effectLease captures an owner generation outside an existing pane critical
46+// section. Callers use effectLeaseLocked when parsing already owns pane.mu.
47+func (p *pane) effectLease() paneEffectLease {
48+ if p == nil {
49+ return paneEffectLease{}
50+ }
51+ p.mu.Lock()
52+ lease := p.effectLeaseLocked()
53+ p.mu.Unlock()
54+ return lease
55+}
56+
57+// paneEffectSessionName snapshots log metadata only while the lease remains
58+// current. Holding session.mu across the final pointer check linearizes the
59+// snapshot with owner publication, whose move commit also owns the session.
60+func paneEffectSessionName(lease paneEffectLease) (string, bool) {
61+ if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || !lease.Current() {
62+ return "", false
63+ }
64+ lease.owner.session.mu.Lock()
65+ current := lease.pane.owner.Load() == lease.owner
66+ name := lease.owner.session.name
67+ lease.owner.session.mu.Unlock()
68+ return name, current
69+}
70+
71+// publishOwnerLocked publishes a new immutable owner generation. The caller
72+// must hold pane.mu, and must also hold tab.mu when publishing a floating owner.
73+func (p *pane) publishOwnerLocked(sess *session, tb *tab, floatingSlotGeneration uint64) (paneEffectLease, paneEffectLease) {
74+ if p == nil {
75+ return paneEffectLease{}, paneEffectLease{}
76+ }
77+ old := paneEffectLease{pane: p, owner: p.owner.Load()}
78+ p.ownerGeneration++
79+ next := &paneOwner{
80+ session: sess,
81+ tab: tb,
82+ generation: p.ownerGeneration,
83+ floatingSlotGeneration: floatingSlotGeneration,
84+ }
85+ p.owner.Store(next)
86+ return old, paneEffectLease{pane: p, owner: next}
87+}
88+
89+// clearOwnerLocked revokes the published owner during terminal teardown. The
90+// caller must hold pane.mu. The returned lease identifies the retired owner.
91+func (p *pane) clearOwnerLocked() paneEffectLease {
92+ if p == nil {
93+ return paneEffectLease{}
94+ }
95+ old := paneEffectLease{pane: p, owner: p.owner.Load()}
96+ if old.owner != nil {
97+ p.ownerGeneration++
98+ p.owner.Store(nil)
99+ }
100+ return old
101+}
102+
103+func (p *pane) ownerLeaseCurrent(lease paneEffectLease) bool {
104+ if p == nil || lease.pane != p || lease.owner == nil || p.owner.Load() != lease.owner {
105+ return false
106+ }
107+ owner := lease.owner
108+ if owner.session == nil || owner.tab == nil {
109+ return false
110+ }
111+ if owner.floatingSlotGeneration == 0 {
112+ return true
113+ }
114+ owner.tab.mu.Lock()
115+ current := owner.tab.floating.pane == p &&
116+ owner.tab.floating.generation == owner.floatingSlotGeneration &&
117+ (owner.tab.floating.state == floatingHidden || owner.tab.floating.state == floatingVisible)
118+ owner.tab.mu.Unlock()
119+ return current && p.owner.Load() == owner
120+}
121+
122+func publishPaneOwner(p *pane, sess *session, tb *tab, floatingSlotGeneration uint64) paneEffectLease {
123+ if p == nil {
124+ return paneEffectLease{}
125+ }
126+ p.mu.Lock()
127+ _, lease := p.publishOwnerLocked(sess, tb, floatingSlotGeneration)
128+ p.mu.Unlock()
129+ return lease
130+}
131+
132+// publishTiledPaneOwners initializes every pane in an unpublished tab before
133+// that tab is made visible or any reader starts.
134+func publishTiledPaneOwners(sess *session, tb *tab) {
135+ if sess == nil || tb == nil {
136+ return
137+ }
138+ tb.mu.Lock()
139+ for _, p := range tb.panes {
140+ publishPaneOwner(p, sess, tb, 0)
141+ }
142+ tb.mu.Unlock()
143+}
A internal/usecase/daemon/pane_owner_reader_test.go +187 -0
@@ -0,0 +1,187 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type ownerRoutingPTY struct {
15+ steps chan channelPTYStep
16+ processed chan struct{}
17+ activeReaders atomic.Int32
18+ maxReaders atomic.Int32
19+ reads atomic.Int32
20+ closes atomic.Int32
21+}
22+
23+func newOwnerRoutingPTY() *ownerRoutingPTY {
24+ return &ownerRoutingPTY{
25+ steps: make(chan channelPTYStep),
26+ processed: make(chan struct{}, 4),
27+ }
28+}
29+
30+func (p *ownerRoutingPTY) Read(buf []byte) (int, error) {
31+ active := p.activeReaders.Add(1)
32+ defer p.activeReaders.Add(-1)
33+ for {
34+ maximum := p.maxReaders.Load()
35+ if active <= maximum || p.maxReaders.CompareAndSwap(maximum, active) {
36+ break
37+ }
38+ }
39+ if p.reads.Add(1) > 1 {
40+ p.processed <- struct{}{}
41+ }
42+ step := <-p.steps
43+ return copy(buf, step.data), step.err
44+}
45+
46+func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil }
47+func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil }
48+func (*ownerRoutingPTY) Resize(domain.Size) error { return nil }
49+func (*ownerRoutingPTY) Pid() int { return 0 }
50+func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
51+ return 0, nil
52+}
53+
54+func installTiledPaneOwnerForTest(sess *session, tb *tab, p *pane) {
55+ tb.mu.Lock()
56+ old := tb.panes[layout.PaneID("pane-1")]
57+ delete(tb.panes, layout.PaneID("pane-1"))
58+ if old != nil {
59+ old.clearOwnerForTest()
60+ }
61+ p.id = layout.PaneID("pane-1")
62+ tb.panes[p.id] = p
63+ tb.tree = &layout.Tree{Root: layout.NewLeaf(p.id), Focus: p.id}
64+ publishPaneOwner(p, sess, tb, 0)
65+ tb.mu.Unlock()
66+}
67+
68+func (p *pane) clearOwnerForTest() {
69+ p.mu.Lock()
70+ p.clearOwnerLocked()
71+ p.mu.Unlock()
72+}
73+
74+func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) {
75+ pty := newOwnerRoutingPTY()
76+ d, source, _, _ := newManualSessionWithPTYs(t, pty)
77+ pane := source.activeTab().focusedPane()
78+ pane.onExit = func() {}
79+ source.snapEligible.Store(true)
80+
81+ destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
82+ destination := &session{
83+ id: domain.SessionID("destination"),
84+ name: "destination",
85+ tabs: []*tab{destinationTab},
86+ client: &attachedClient{},
87+ snapEligible: atomic.Bool{},
88+ }
89+ destination.snapEligible.Store(true)
90+ destination.client.initOverlays()
91+ destination.client.setSession(destination)
92+ d.sessions[destination.id] = destination
93+
94+ sourceInvalidations := make(chan renderInvalidation, 2)
95+ destinationInvalidations := make(chan renderInvalidation, 2)
96+ sourceCoordinator := d.attachCoordinator(source, nil, source.client, true)
97+ sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation }
98+ destinationCoordinator := d.attachCoordinator(destination, nil, destination.client, true)
99+ destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation }
100+
101+ d.startPaneGoroutines(source, source.activeTab(), pane)
102+ pty.steps <- channelPTYStep{data: []byte("source\a")}
103+ <-pty.processed
104+ require.Len(t, sourceInvalidations, 1)
105+ require.Empty(t, destinationInvalidations)
106+ require.Equal(t, uint64(1), source.snapshotGeneration)
107+ require.Zero(t, destination.snapshotGeneration)
108+ require.True(t, source.activeTab().attention)
109+
110+ installTiledPaneOwnerForTest(destination, destinationTab, pane)
111+ pty.steps <- channelPTYStep{data: []byte("destination\a")}
112+ <-pty.processed
113+
114+ require.Len(t, sourceInvalidations, 1, "output after owner publication must not invalidate the source")
115+ require.Len(t, destinationInvalidations, 1, "output after owner publication must invalidate the destination")
116+ require.Equal(t, uint64(1), source.snapshotGeneration, "source snapshot must not be dirtied after transfer")
117+ require.Equal(t, uint64(1), destination.snapshotGeneration)
118+ require.True(t, destinationTab.attention)
119+ require.Equal(t, int32(1), pty.maxReaders.Load(), "owner publication must not start another PTY reader")
120+
121+ pty.steps <- channelPTYStep{err: io.EOF}
122+ d.sessWg.Wait()
123+}
124+
125+func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
126+ pty := newOwnerRoutingPTY()
127+ d, source, _, _ := newManualSessionWithPTYs(t, pty)
128+ sourceTab := source.activeTab()
129+ moved := sourceTab.focusedPane()
130+
131+ destinationTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
132+ other := newPane(layout.PaneID("pane-2"), newQuietPTY(), domain.Size{Cols: 40, Rows: 23})
133+ destinationTab.mu.Lock()
134+ require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23}))
135+ destinationTab.panes[other.id] = other
136+ destinationTab.mu.Unlock()
137+ destination := &session{id: domain.SessionID("destination"), name: "destination", tabs: []*tab{destinationTab}}
138+ d.sessions[destination.id] = destination
139+
140+ // Model a completed owner commit while the one reader remains blocked in
141+ // Read. The source immediately reuses the old local ID, which makes stale
142+ // ID-only exit routing observably close the wrong pane.
143+ replacement := newPane(layout.PaneID("pane-1"), newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
144+ sourceTab.mu.Lock()
145+ sourceTab.panes[replacement.id] = replacement
146+ publishPaneOwner(replacement, source, sourceTab, 0)
147+ sourceTab.mu.Unlock()
148+
149+ destinationTab.mu.Lock()
150+ displaced := destinationTab.panes[layout.PaneID("pane-1")]
151+ displaced.clearOwnerForTest()
152+ destinationTab.panes[moved.id] = moved
153+ publishPaneOwner(moved, destination, destinationTab, 0)
154+ destinationTab.mu.Unlock()
155+
156+ d.sessWg.Add(1)
157+ go d.ptyReader(source, sourceTab, moved)
158+ pty.steps <- channelPTYStep{err: io.EOF}
159+ d.sessWg.Wait()
160+
161+ d.mu.Lock()
162+ require.Same(t, source, d.sessions[source.id], "stale exit must not close the source replacement")
163+ require.Same(t, destination, d.sessions[destination.id])
164+ d.mu.Unlock()
165+ sourceTab.mu.Lock()
166+ require.Same(t, replacement, sourceTab.panes[replacement.id])
167+ sourceTab.mu.Unlock()
168+ destinationTab.mu.Lock()
169+ require.NotContains(t, destinationTab.panes, moved.id, "the exited pane must be removed from its current owner")
170+ require.Same(t, other, destinationTab.panes[other.id])
171+ destinationTab.mu.Unlock()
172+ require.Nil(t, moved.ownerSnapshot())
173+ require.True(t, replacement.effectLease().Current())
174+ require.Equal(t, int32(1), pty.maxReaders.Load())
175+
176+ // A stale second reap is a no-op and cannot close either surviving owner.
177+ d.reapPaneOwner(moved)
178+ sourceTab.mu.Lock()
179+ require.Same(t, replacement, sourceTab.panes[replacement.id])
180+ sourceTab.mu.Unlock()
181+ destinationTab.mu.Lock()
182+ require.Same(t, other, destinationTab.panes[other.id])
183+ destinationTab.mu.Unlock()
184+ require.Equal(t, int32(1), pty.closes.Load(), "exit must release the process exactly once")
185+}
186+
187+var _ ports.PTY = (*ownerRoutingPTY)(nil)
A internal/usecase/daemon/pane_owner_test.go +92 -0
@@ -0,0 +1,92 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) {
12+ p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23})
13+ firstSession := &session{id: "first"}
14+ firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
15+ secondSession := &session{id: "second"}
16+ secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
17+
18+ p.mu.Lock()
19+ oldLease, _ := p.publishOwnerLocked(firstSession, firstTab, 0)
20+ firstLease := p.effectLeaseLocked()
21+ p.mu.Unlock()
22+ require.False(t, oldLease.Current())
23+ require.True(t, firstLease.Current())
24+ require.Equal(t, uint64(1), firstLease.owner.generation)
25+ require.Same(t, firstSession, firstLease.owner.session)
26+ require.Same(t, firstTab, firstLease.owner.tab)
27+
28+ p.mu.Lock()
29+ retiredLease, _ := p.publishOwnerLocked(secondSession, secondTab, 0)
30+ secondLease := p.effectLeaseLocked()
31+ p.mu.Unlock()
32+ require.Equal(t, firstLease, retiredLease)
33+ require.False(t, firstLease.Current())
34+ require.True(t, secondLease.Current())
35+ require.Equal(t, uint64(2), secondLease.owner.generation)
36+ require.Equal(t, "p_stable", p.stableID, "owner publication must preserve stable pane identity")
37+
38+ p.mu.Lock()
39+ clearedLease := p.clearOwnerLocked()
40+ p.mu.Unlock()
41+ require.Equal(t, secondLease, clearedLease)
42+ require.False(t, secondLease.Current())
43+ require.Nil(t, p.ownerSnapshot())
44+ require.Equal(t, "p_stable", p.stableID, "terminal owner clearing must preserve stable pane identity")
45+ // Leases retain immutable owner records even after later publication and
46+ // terminal clearing.
47+ require.Same(t, firstSession, firstLease.owner.session)
48+ require.Same(t, firstTab, firstLease.owner.tab)
49+ require.Equal(t, uint64(1), firstLease.owner.generation)
50+}
51+
52+func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) {
53+ p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8})
54+ sess := &session{id: "owner"}
55+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
56+
57+ tb.mu.Lock()
58+ generation := tb.beginFloatingWarmLocked(true)
59+ p.mu.Lock()
60+ p.publishOwnerLocked(sess, tb, generation)
61+ lease := p.effectLeaseLocked()
62+ p.mu.Unlock()
63+ require.True(t, tb.installFloatingLocked(p, generation))
64+ tb.mu.Unlock()
65+ require.True(t, lease.Current())
66+
67+ tb.mu.Lock()
68+ tb.floating.generation++
69+ tb.mu.Unlock()
70+ require.False(t, lease.Current(), "a reused floating slot must reject the old pane effect lease")
71+ require.Equal(t, "p_float", p.stableID)
72+}
73+
74+func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) {
75+ sess := &session{id: "owner"}
76+ tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23})
77+ second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23})
78+ tb.panes[second.id] = second
79+
80+ publishTiledPaneOwners(sess, tb)
81+
82+ for _, p := range []*pane{tb.panes["pane-1"], second} {
83+ owner := p.ownerSnapshot()
84+ require.NotNil(t, owner)
85+ require.Same(t, sess, owner.session)
86+ require.Same(t, tb, owner.tab)
87+ require.Equal(t, uint64(1), owner.generation)
88+ require.Zero(t, owner.floatingSlotGeneration)
89+ }
90+ require.Equal(t, "p_one", tb.panes["pane-1"].stableID)
91+ require.Equal(t, "p_two", second.stableID)
92+}
M internal/usecase/daemon/pane_rearrange_test.go +2 -3
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "io"6 "io"
7+ "maps"
7 "sync"8 "sync"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -158,9 +159,7 @@ func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
158 h.tab.mu.Lock()159 h.tab.mu.Lock()
159 h.tab.tree.Focus = "pane-3"160 h.tab.tree.Focus = "pane-3"
160 before := make(map[layout.PaneID]*pane, len(h.tab.panes))161 before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161- for id, p := range h.tab.panes {162+ maps.Copy(before, h.tab.panes)
162- before[id] = p
163- }
164 h.tab.mu.Unlock()163 h.tab.mu.Unlock()
165 164
166 err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{165 err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
A internal/usecase/daemon/pane_sync_owner.go +47 -0
@@ -0,0 +1,47 @@
1+package daemon
2+
3+// migratePaneSyncOwnerLocked transfers synchronized-output coordination after
4+// an ownership commit. The caller must hold p.mu and pass the exact leases
5+// returned by publishOwnerLocked. Holding the pane parsing fence keeps PTY
6+// writes from observing a coordinator between owners.
7+//
8+// The returned cleanup must be finished after releasing pane and coordinator
9+// locks. It stops detached timers but never joins their workers.
10+func (d *Daemon) migratePaneSyncOwnerLocked(p *pane, oldOwner, newOwner paneEffectLease) syncTimerCleanup {
11+ var cleanup syncTimerCleanup
12+ if p == nil || oldOwner.pane != p || newOwner.pane != p || newOwner.owner == nil || p.owner.Load() != newOwner.owner {
13+ return cleanup
14+ }
15+
16+ oldGeneration := p.syncGen
17+ if oldOwner.owner != nil && oldOwner.owner.session != nil {
18+ if coordinator := oldOwner.owner.session.renderCoordinator(); coordinator != nil {
19+ cleanup.append(coordinator.detachSyncBatchGeneration(p, oldGeneration))
20+ }
21+ }
22+ p.syncGen = 0
23+ if p.screen == nil || !p.screen.SyncUpdateActive() || newOwner.owner.session == nil || newOwner.owner.tab == nil {
24+ return cleanup
25+ }
26+
27+ generation := newOwner.owner.session.syncGen.Add(1)
28+ p.syncGen = generation
29+ coordinator := newOwner.owner.session.renderCoordinator()
30+ if coordinator == nil {
31+ return cleanup
32+ }
33+
34+ lease := newOwner
35+ renderable := func() bool {
36+ return lease.Current() && d.paneRenderable(lease.owner.session, lease.owner.tab, p)
37+ }
38+ forceEnd := func() {
39+ p.mu.Lock()
40+ if p.owner.Load() == lease.owner && p.syncGen == generation && p.screen.SyncUpdateActive() {
41+ p.screen.ForceSyncEnd()
42+ }
43+ p.mu.Unlock()
44+ }
45+ cleanup.append(coordinator.beginSyncBatchWithRenderability(p, generation, renderable, forceEnd))
46+ return cleanup
47+}
A internal/usecase/daemon/pane_sync_owner_test.go +175 -0
@@ -0,0 +1,175 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type paneSyncOwnerFixture struct {
15+ d *Daemon
16+ pane *pane
17+ source, destination *session
18+ sourceTab, targetTab *tab
19+ sourceCoordinator *renderCoordinator
20+ targetCoordinator *renderCoordinator
21+ sourceClock *coordinatorMockClock
22+ targetClock *coordinatorMockClock
23+}
24+
25+func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture {
26+ t.Helper()
27+ size := domain.Size{Cols: 80, Rows: 23}
28+ sourceTab := newTab(newQuietPTY(), size)
29+ targetTab := newTab(newQuietPTY(), size)
30+ source := &session{id: "sync-source", tabs: []*tab{sourceTab}, active: 0, client: &attachedClient{}}
31+ destination := &session{id: "sync-destination", tabs: []*tab{targetTab}, active: 0, client: &attachedClient{}}
32+ d := &Daemon{sessions: map[domain.SessionID]*session{source.id: source, destination.id: destination}}
33+ p := sourceTab.focusedPane()
34+ publishPaneOwner(p, source, sourceTab, 0)
35+
36+ // Model the membership commit that accompanies owner publication. Keeping
37+ // the source membership is harmless here; callbacks must route by owner.
38+ targetTab.mu.Lock()
39+ targetTab.panes[p.id] = p
40+ targetTab.tree.Root = layout.NewLeaf(p.id)
41+ targetTab.tree.Focus = p.id
42+ targetTab.mu.Unlock()
43+
44+ sourceClock := newCoordinatorMockClock(t, 8)
45+ targetClock := newCoordinatorMockClock(t, 8)
46+ sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: sourceClock.clock})
47+ targetCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: targetClock.clock})
48+ source.installRenderCoordinator(sourceCoordinator)
49+ destination.installRenderCoordinator(targetCoordinator)
50+ return paneSyncOwnerFixture{
51+ d: d, pane: p, source: source, destination: destination,
52+ sourceTab: sourceTab, targetTab: targetTab,
53+ sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
54+ sourceClock: sourceClock, targetClock: targetClock,
55+ }
56+}
57+
58+func (f paneSyncOwnerFixture) armSourceBatch(t *testing.T, active bool, force func()) (*coordinatorMockTimer, paneEffectLease) {
59+ t.Helper()
60+ f.pane.mu.Lock()
61+ if active {
62+ f.pane.screen.Write([]byte(renderer.SyncStartCSI))
63+ }
64+ generation := f.source.syncGen.Add(1)
65+ f.pane.syncGen = generation
66+ lease := f.pane.effectLeaseLocked()
67+ f.pane.mu.Unlock()
68+ f.sourceCoordinator.noteSyncBeginWithRenderability(f.pane, generation, lease.Current, force)
69+ return awaitCoordinatorScheduledTimer(t, f.sourceClock), lease
70+}
71+
72+func TestMigratePaneSynchronizedOutputOwner(t *testing.T) {
73+ t.Run("active batch moves to destination and only destination consumes sync end", func(t *testing.T) {
74+ fixture := newPaneSyncOwnerFixture(t)
75+ forceEntered := make(chan struct{})
76+ var enterOnce sync.Once
77+ var oldLease paneEffectLease
78+ oldTimer, oldLease := fixture.armSourceBatch(t, true, func() {
79+ enterOnce.Do(func() { close(forceEntered) })
80+ fixture.pane.mu.Lock()
81+ if fixture.pane.owner.Load() == oldLease.owner && fixture.pane.screen.SyncUpdateActive() {
82+ fixture.pane.screen.ForceSyncEnd()
83+ }
84+ fixture.pane.mu.Unlock()
85+ })
86+ fixture.sourceCoordinator.mu.Lock()
87+ oldWorkerDone := fixture.sourceCoordinator.syncBatches[fixture.pane].lane.token.done
88+ fixture.sourceCoordinator.mu.Unlock()
89+
90+ // Select the old watchdog while the pane parsing fence is held. The
91+ // migration must detach it without waiting for its blocked callback.
92+ fixture.pane.mu.Lock()
93+ oldTimer.ch <- time.Time{}
94+ <-forceEntered
95+ oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
96+ cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
97+ oldTimer.mock.AssertNotCalled(t, "Stop")
98+ fixture.sourceCoordinator.mu.Lock()
99+ require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
100+ fixture.sourceCoordinator.mu.Unlock()
101+ fixture.targetCoordinator.mu.Lock()
102+ targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
103+ fixture.targetCoordinator.mu.Unlock()
104+ require.NotNil(t, targetBatch)
105+ require.Equal(t, fixture.destination.syncGen.Load(), targetBatch.generation)
106+ fixture.pane.mu.Unlock()
107+ cleanup.finish()
108+ oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
109+ <-oldWorkerDone
110+
111+ fixture.pane.mu.Lock()
112+ require.True(t, fixture.pane.screen.SyncUpdateActive(), "selected old watchdog must become a stale no-op")
113+ fixture.pane.mu.Unlock()
114+
115+ fixture.d.processPanePTYData(fixture.pane, []byte(renderer.SyncEndCSI), false)
116+ fixture.sourceCoordinator.mu.Lock()
117+ require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
118+ fixture.sourceCoordinator.mu.Unlock()
119+ fixture.targetCoordinator.mu.Lock()
120+ require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
121+ fixture.targetCoordinator.mu.Unlock()
122+ fixture.pane.mu.Lock()
123+ require.False(t, fixture.pane.screen.SyncUpdateActive())
124+ fixture.pane.mu.Unlock()
125+ })
126+
127+ t.Run("destination callbacks reject a newer owner generation", func(t *testing.T) {
128+ fixture := newPaneSyncOwnerFixture(t)
129+ _, _ = fixture.armSourceBatch(t, true, nil)
130+ fixture.pane.mu.Lock()
131+ oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
132+ cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
133+ fixture.pane.mu.Unlock()
134+ cleanup.finish()
135+ _ = awaitCoordinatorScheduledTimer(t, fixture.targetClock)
136+
137+ fixture.targetCoordinator.mu.Lock()
138+ targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
139+ fixture.targetCoordinator.mu.Unlock()
140+ require.NotNil(t, targetBatch)
141+ require.True(t, targetBatch.renderable())
142+
143+ fixture.pane.mu.Lock()
144+ fixture.pane.publishOwnerLocked(fixture.source, fixture.sourceTab, 0)
145+ fixture.pane.mu.Unlock()
146+ require.False(t, targetBatch.renderable(), "renderability must validate the destination owner generation")
147+ targetBatch.force()
148+ fixture.pane.mu.Lock()
149+ require.True(t, fixture.pane.screen.SyncUpdateActive(), "force-end must validate the destination owner generation")
150+ fixture.pane.mu.Unlock()
151+ fixture.targetCoordinator.noteSyncPaneRemoved(fixture.pane)
152+ })
153+
154+ t.Run("inactive screen removes source batch without creating destination state", func(t *testing.T) {
155+ fixture := newPaneSyncOwnerFixture(t)
156+ oldTimer, _ := fixture.armSourceBatch(t, false, nil)
157+
158+ fixture.pane.mu.Lock()
159+ oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
160+ cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
161+ require.Zero(t, fixture.pane.syncGen)
162+ fixture.pane.mu.Unlock()
163+ cleanup.finish()
164+
165+ fixture.sourceCoordinator.mu.Lock()
166+ require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
167+ fixture.sourceCoordinator.mu.Unlock()
168+ fixture.targetCoordinator.mu.Lock()
169+ require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
170+ fixture.targetCoordinator.mu.Unlock()
171+ require.Zero(t, fixture.destination.syncGen.Load())
172+ require.Empty(t, fixture.targetClock.timers)
173+ oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
174+ })
175+}
M internal/usecase/daemon/picker.go +353 -151
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 "sort"7 "sort"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -15,18 +16,35 @@ import (
15 16
16 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}17 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
17 18
19+// enterPicker preserves the existing navigation entry point. Navigation always
20+// publishes its model, including an empty one; only move entry can fail for a
21+// missing destination.
18 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {22 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
19- views, curTab := d.pickerViews(sess)23+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
20- ac.overlays.pickerMu.Lock()24+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
21- ac.overlays.picker = picker.New(views, sess.id, curTab)25+}
22- ac.overlays.pickerPending = nil26+
23- ac.overlays.pickerPreview = nil27+func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
24- ac.overlays.pickerMu.Unlock()28+ rt := ac.overlays
29+ rt.pickerMu.Lock()
30+ previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
31+ rt.pickerPreviewGeneration++
32+ rt.pickerPreviewSession = nil
33+ rt.pickerPreview = nil
34+ rt.picker = model
35+ rt.pickerIntent = intent
36+ rt.pickerSource = source
37+ rt.pickerPending = nil
38+ rt.pickerESC.stop()
39+ rt.pickerMu.Unlock()
40+ d.teardownPreviewSubscription(ac, previous, previousGeneration)
25 d.registerPreviewForSelection(ac)41 d.registerPreviewForSelection(ac)
26 d.invalidateRender(sess, ac, true, "picker.go")42 d.invalidateRender(sess, ac, true, "picker.go")
27 }43 }
28 44
29-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {45+// pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
46+// knows nothing about picker intent; picker.New owns all destination policy.
47+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
30 d.mu.Lock()48 d.mu.Lock()
31 sessions := d.sessionsSnapshotLocked()49 sessions := d.sessionsSnapshotLocked()
32 stopped := make([]stoppedSession, 0, len(d.stopped))50 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -45,33 +63,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
45 63
46 includeTerminalTitle := d.currentTabsConfig().TerminalTitle64 includeTerminalTitle := d.currentTabsConfig().TerminalTitle
47 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))65 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
48- curTab := 066+ var current picker.SourceFilter
49 for _, s := range sessions {67 for _, s := range sessions {
50 s.mu.Lock()68 s.mu.Lock()
51- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}69+ view := picker.SessionView{
70+ ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active,
71+ Tabs: make([]picker.TabEntry, 0, len(s.tabs)),
72+ }
52 if !s.ephemeral {73 if !s.ephemeral {
53 createdAt := s.createdAt74 createdAt := s.createdAt
54- view.TargetName = s.name
55 view.ExpectedCreatedAt = &createdAt75 view.ExpectedCreatedAt = &createdAt
56 }76 }
57 sessionAttention := false77 sessionAttention := false
58 for i, tb := range s.tabs {78 for i, tb := range s.tabs {
59 name := tabDisplayName(tb, i)79 name := tabDisplayName(tb, i)
60- view.Tabs[i] = picker.TabEntry{80+ tabID := domain.TabStableID(tb.stableID)
81+ view.Tabs = append(view.Tabs, picker.TabEntry{
82+ TabID: tabID,
61 Name: name,83 Name: name,
62 Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),84 Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
63 Attention: tb.attention,85 Attention: tb.attention,
64- }86+ })
65 if tb.attention {87 if tb.attention {
66 sessionAttention = true88 sessionAttention = true
67 }89 }
90+ if s == cur && i == s.active {
91+ current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID}
92+ }
68 }93 }
69 if sessionAttention {94 if sessionAttention {
70 view.Name = attentionSuffix(view.Name)95 view.Name = attentionSuffix(view.Name)
71 }96 }
72- if s == cur {
73- curTab = s.active
74- }
75 s.mu.Unlock()97 s.mu.Unlock()
76 views = append(views, view)98 views = append(views, view)
77 }99 }
@@ -79,6 +101,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
79 createdAt := s.createdAt101 createdAt := s.createdAt
80 views = append(views, picker.SessionView{102 views = append(views, picker.SessionView{
81 ID: domain.SessionID("stopped:" + s.name),103 ID: domain.SessionID("stopped:" + s.name),
104+ Incarnation: s.incarnation,
82 Name: s.name,105 Name: s.name,
83 TargetName: s.name,106 TargetName: s.name,
84 Tabs: []picker.TabEntry{{}},107 Tabs: []picker.TabEntry{{}},
@@ -86,7 +109,21 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
86 ExpectedCreatedAt: &createdAt,109 ExpectedCreatedAt: &createdAt,
87 })110 })
88 }111 }
89- return views, curTab112+ return views, current
113+}
114+
115+func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
116+ views, attachedCurrent := d.pickerViews(cur)
117+ if intent == pickerNavigate {
118+ current = attachedCurrent
119+ }
120+ return picker.New(views, picker.SelectionConfig{
121+ Mode: pickerSelectionMode(intent),
122+ Current: current,
123+ Source: picker.SourceFilter{
124+ Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
125+ },
126+ })
90 }127 }
91 128
92 func attentionSuffix(label string) string {129 func attentionSuffix(label string) string {
@@ -106,6 +143,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
106 active: func() bool { return rt.picker != nil },143 active: func() bool { return rt.picker != nil },
107 closeLocked: func() {144 closeLocked: func() {
108 rt.picker = nil145 rt.picker = nil
146+ rt.pickerIntent = pickerNavigate
147+ rt.pickerSource = moveSourceLocator{}
109 generation = rt.pickerPreviewGeneration148 generation = rt.pickerPreviewGeneration
110 },149 },
111 afterClose: func() {150 afterClose: func() {
@@ -117,7 +156,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
117 }156 }
118 }157 }
119 158
120-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {159+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
121 sess := ac.currentSession()160 sess := ac.currentSession()
122 if sess == nil {161 if sess == nil {
123 return162 return
@@ -142,17 +181,38 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
142 })181 })
143 var target picker.Target182 var target picker.Target
144 var ok bool183 var ok bool
184+ var intent pickerIntent
185+ var source moveSourceLocator
145 if result.action == 'x' || result.action == '\r' || result.action == '\n' {186 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
146 target, ok = rt.picker.Selected()187 target, ok = rt.picker.Selected()
147 }188 }
189+ intent, source = rt.pickerIntent, rt.pickerSource
148 rt.pickerMu.Unlock()190 rt.pickerMu.Unlock()
149 191
150 if result.action == 'x' {192 if result.action == 'x' {
193+ var effect *roleEffectTicket
194+ if len(effects) != 0 {
195+ effect = effects[0]
196+ }
151 if ok {197 if ok {
152- if err := d.killPickerTarget(target); err != nil {198+ var err error
199+ if effect != nil {
200+ effect.bindActionEnd(d, "picker-delete")
201+ err = d.killPickerTargetForRole(target, effect.roleToken())
202+ } else {
203+ err = d.killPickerTarget(target)
204+ }
205+ if err != nil {
153 d.reportError(sess, err)206 d.reportError(sess, err)
154 }207 }
155 }208 }
209+ if effect != nil && effect.ended.Load() {
210+ fresh, admitted := ac.beginRoleEffect(effect.token)
211+ if !admitted {
212+ return
213+ }
214+ defer fresh.End()
215+ }
156 d.refreshPicker(ac)216 d.refreshPicker(ac)
157 d.invalidateRender(sess, ac, true, "picker.go")217 d.invalidateRender(sess, ac, true, "picker.go")
158 return218 return
@@ -160,12 +220,43 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
160 if result.changed {220 if result.changed {
161 d.registerPreviewForSelection(ac)221 d.registerPreviewForSelection(ac)
162 }222 }
163- if result.exit {223+ committing := (result.action == '\r' || result.action == '\n') && ok
224+ if result.exit && !committing {
164 d.closePicker(ac)225 d.closePicker(ac)
165 }226 }
166- if (result.action == '\r' || result.action == '\n') && ok {227+ if committing {
167- if err := d.switchToTarget(sess, ac, target); err != nil {228+ if intent == pickerMovePane || intent == pickerMoveTab {
229+ if err := d.movePickerSourceError(source); err != nil {
230+ d.reportError(sess, err)
231+ d.invalidateRender(sess, ac, true, "picker.go")
232+ return
233+ }
234+ if len(effects) != 0 && effects[0] != nil {
235+ effects[0].End()
236+ }
237+ d.closePicker(ac)
238+ err := d.commitMovePickerSelection(intent, source, target)
239+ if err != nil {
240+ d.reportError(sess, movePickerUserError(err))
241+ d.invalidateRender(sess, ac, true, "picker.go")
242+ return
243+ }
244+ d.invalidateRender(sess, ac, true, "picker.go")
245+ return
246+ }
247+ var err error
248+ if len(effects) != 0 && effects[0] != nil {
249+ err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
250+ } else {
251+ d.closePicker(ac)
252+ err = d.switchToTarget(sess, ac, target)
253+ }
254+ if errors.Is(err, errAttachmentTransition) {
255+ return
256+ }
257+ if err != nil {
168 d.reportError(sess, err)258 d.reportError(sess, err)
259+ return
169 }260 }
170 return261 return
171 }262 }
@@ -181,6 +272,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
181 ac.overlays.pickerMu.Lock()272 ac.overlays.pickerMu.Lock()
182 var target picker.Target273 var target picker.Target
183 var ok bool274 var ok bool
275+ intent := ac.overlays.pickerIntent
184 if ac.overlays.picker != nil {276 if ac.overlays.picker != nil {
185 target, ok = ac.overlays.picker.Selected()277 target, ok = ac.overlays.picker.Selected()
186 }278 }
@@ -194,15 +286,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
194 if !ok {286 if !ok {
195 return287 return
196 }288 }
197- next := d.tabByTarget(target)289+ targetSess, next := d.previewTarget(target, intent)
198- targetSess := d.sessionByID(target.Session)
199 if next == nil || targetSess == nil {290 if next == nil || targetSess == nil {
200 return291 return
201 }292 }
202 ac.overlays.pickerMu.Lock()293 ac.overlays.pickerMu.Lock()
203 // Selection may have changed while the target was resolved.294 // Selection may have changed while the target was resolved.
204 selected, stillSelected := ac.overlays.picker.Selected()295 selected, stillSelected := ac.overlays.picker.Selected()
205- valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target296+ valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
206 if valid {297 if valid {
207 ac.overlays.pickerPreview = next298 ac.overlays.pickerPreview = next
208 ac.overlays.pickerPreviewSession = targetSess299 ac.overlays.pickerPreviewSession = targetSess
@@ -314,6 +405,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
314 func (d *Daemon) closePicker(ac *attachedClient) {405 func (d *Daemon) closePicker(ac *attachedClient) {
315 ac.overlays.pickerMu.Lock()406 ac.overlays.pickerMu.Lock()
316 ac.overlays.picker = nil407 ac.overlays.picker = nil
408+ ac.overlays.pickerIntent = pickerNavigate
409+ ac.overlays.pickerSource = moveSourceLocator{}
317 ac.overlays.pickerPending = nil410 ac.overlays.pickerPending = nil
318 ac.overlays.pickerESC.stop()411 ac.overlays.pickerESC.stop()
319 generation := ac.overlays.pickerPreviewGeneration412 generation := ac.overlays.pickerPreviewGeneration
@@ -321,19 +414,15 @@ func (d *Daemon) closePicker(ac *attachedClient) {
321 d.clearPreviewGeneration(ac, generation)414 d.clearPreviewGeneration(ac, generation)
322 }415 }
323 416
324-func (d *Daemon) tabByTarget(target picker.Target) *tab {417+func pickerTargetsEqual(left, right picker.Target) bool {
325- d.mu.Lock()418+ if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
326- sess := d.sessions[target.Session]419+ left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
327- d.mu.Unlock()420+ return false
328- if sess == nil {
329- return nil
330 }421 }
331- sess.mu.Lock()422+ if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
332- defer sess.mu.Unlock()423+ return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
333- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
334- return nil
335 }424 }
336- return sess.tabs[target.TabIndex]425+ return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
337 }426 }
338 427
339 func (d *Daemon) sessionByID(id domain.SessionID) *session {428 func (d *Daemon) sessionByID(id domain.SessionID) *session {
@@ -347,6 +436,43 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
347 // command switching behavior.436 // command switching behavior.
348 type sessionHandoffGuard struct {437 type sessionHandoffGuard struct {
349 expectedSource *tab438 expectedSource *tab
439+ closePicker bool
440+}
441+
442+// switchActiveTargetForRole hands a frame-bound navigation request to the
443+// centralized transition. The transition releases admission at its freeze seam
444+// and revalidates the exact initiating token before changing target focus or
445+// attachment ownership.
446+func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
447+ if token.sess == nil || token.ac == nil || token.role != attachmentActive {
448+ return nil
449+ }
450+ d.mu.Lock()
451+ targetSess := d.sessions[target.Session]
452+ d.mu.Unlock()
453+ if targetSess == nil || targetSess == token.sess {
454+ return nil
455+ }
456+
457+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
458+ source: token.sess, target: targetSess, next: token.ac,
459+ expectedRole: attachmentActive, targetRole: attachmentActive,
460+ expectedTransport: token.transport, sourceToken: &token, action: "jump-attention",
461+ activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
462+ })
463+ if err != nil {
464+ // Losing the exact source role is a benign stale action, not a notice for
465+ // whichever attachment replaced the initiator.
466+ if !token.activeCurrent() {
467+ return nil
468+ }
469+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
470+ }
471+ d.touchMRU(targetSess)
472+ token.ac.recordPreviousSession(token.sess)
473+ d.deferAttachmentTransitionCleanups(transition)
474+ d.firstPaintForTransition(transition.published)
475+ return nil
350 }476 }
351 477
352 // switchToTarget resolves named lifecycle targets and commits their transition478 // switchToTarget resolves named lifecycle targets and commits their transition
@@ -356,15 +482,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
356 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})482 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
357 }483 }
358 484
359-// switchToTargetGuarded is the navigation-only variant whose guard is checked485+// switchToTargetForRole is the sole client-originated navigation entry. The
360-// as part of the source ownership transfer.486+// intent captures the exact initiating capability before admission is released;
487+// every active, stopped, and same-session target then uses transitionAttachment
488+// for frozen, atomic source-token preflight.
489+func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
490+ if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil {
491+ return nil
492+ }
493+ token.effect.bindActionEnd(d, action)
494+ token.effect.End()
495+ return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action)
496+}
497+
498+// switchToTargetGuarded is retained for daemon-internal and headless callers.
361 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {499 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
500+ return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")
501+}
502+
503+func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error {
362 if target.Name != "" {504 if target.Name != "" {
363 ctx := d.serveCtx505 ctx := d.serveCtx
364 if ctx == nil {506 if ctx == nil {
365 ctx = context.Background()507 ctx = context.Background()
366 }508 }
367 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {509 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
510+ if sourceToken != nil && !sourceToken.activeCurrent() {
511+ return errAttachmentTransition
512+ }
368 d.invalidateRender(from, ac, true, "picker.go")513 d.invalidateRender(from, ac, true, "picker.go")
369 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)514 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
370 }515 }
@@ -373,8 +518,7 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
373 d.mu.Lock()518 d.mu.Lock()
374 var (519 var (
375 targetSess *session520 targetSess *session
376- old *attachedClient521+ transition attachmentTransitionResult
377- cleanups []renderLifecycleCleanup
378 switched bool522 switched bool
379 cause error523 cause error
380 )524 )
@@ -382,39 +526,56 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
382 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)526 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
383 if ok {527 if ok {
384 if isStopped {528 if isStopped {
385- targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)529+ targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
386 } else {530 } else {
387 // The snapshot ID may describe the stopped representation. The531 // The snapshot ID may describe the stopped representation. The
388 // locked name/lifecycle resolver chose this active incarnation.532 // locked name/lifecycle resolver chose this active incarnation.
389 resolvedTarget := target533 resolvedTarget := target
390 resolvedTarget.Session = active.id534 resolvedTarget.Session = active.id
391- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard)535+ targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
392 }536 }
393 }537 }
394 } else if active := d.sessions[target.Session]; active != nil {538 } else if active := d.sessions[target.Session]; active != nil {
395- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard)539+ targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
396 }540 }
397 d.mu.Unlock()541 d.mu.Unlock()
398 542
399 if !switched {543 if !switched {
544+ if sourceToken != nil && !sourceToken.activeCurrent() {
545+ return errAttachmentTransition
546+ }
400 if guard.expectedSource != nil {547 if guard.expectedSource != nil {
401 return errNoNeighbor548 return errNoNeighbor
402 }549 }
403 d.invalidateRender(from, ac, true, "picker.go")550 d.invalidateRender(from, ac, true, "picker.go")
404 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)551 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
405 }552 }
406- finishRenderLifecycleCleanups(cleanups)553+ if guard.closePicker {
407- if old != nil && old != ac {554+ if sourceToken != nil {
408- d.unregisterPreview(old)555+ fresh, admitted := ac.beginRoleEffect(transition.published)
409- old.clearPreviousSession()556+ if admitted {
410- old.setSession(nil)557+ d.closePicker(ac)
411- d.notifyDetachedAsync(old, ports.ReasonReplaced)558+ fresh.End()
559+ }
560+ } else {
561+ d.closePicker(ac)
562+ }
412 }563 }
564+ d.deferAttachmentTransitionCleanups(transition)
413 if targetSess == from {565 if targetSess == from {
414- d.activateTab(from, from.activeTab())566+ if sourceToken != nil {
415- d.invalidateRender(from, ac, true, "picker.go")567+ fresh, admitted := ac.beginRoleEffect(transition.published)
568+ if admitted {
569+ d.activateTab(from, from.activeTab())
570+ d.invalidateRender(from, ac, true, "picker.go")
571+ fresh.End()
572+ }
573+ } else {
574+ d.activateTab(from, from.activeTab())
575+ d.invalidateRender(from, ac, true, "picker.go")
576+ }
416 } else {577 } else {
417- d.firstPaint(targetSess, ac, ac.size)578+ d.firstPaintForTransition(transition.published)
418 }579 }
419 return nil580 return nil
420 }581 }
@@ -439,121 +600,154 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
439 return nil, stopped, true, true600 return nil, stopped, true, true
440 }601 }
441 602
442-// switchToActiveTargetLocked commits an active target handoff. Caller holds603+// switchToActiveTargetLocked commits an active target handoff through the
443-// d.mu and releases it before completing render cleanup or first paint.604+// centralized attachment transition. Caller holds d.mu.
444-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) {605+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, bool) {
445- // The caller already holds d.mu. Keep handoff ownership atomic with global
446- // routing so it cannot select an attachment midway between sessions.
447- d.notices.routingMu.Lock()
448- defer d.notices.routingMu.Unlock()
449 if d.sessions[target.Session] != targetSess {606 if d.sessions[target.Session] != targetSess {
450- return nil, nil, nil, false607+ return nil, attachmentTransitionResult{}, false
451 }608 }
452 if targetSess == from {609 if targetSess == from {
453- targetSess.mu.Lock()610+ if sourceToken == nil {
454- defer targetSess.mu.Unlock()611+ targetSess.mu.Lock()
455- if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {612+ defer targetSess.mu.Unlock()
456- return nil, nil, nil, false613+ if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
614+ return nil, attachmentTransitionResult{}, false
615+ }
616+ targetSess.active = target.TabIndex
617+ return targetSess, attachmentTransitionResult{}, true
457 }618 }
458- targetSess.active = target.TabIndex619+ d.mu.Unlock()
459- return targetSess, nil, nil, true620+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
621+ source: from, target: targetSess, next: ac,
622+ expectedRole: attachmentActive, targetRole: attachmentActive,
623+ expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
624+ activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true,
625+ })
626+ d.mu.Lock()
627+ return targetSess, transition, err == nil
460 }628 }
461 629
462- targetSess.mu.Lock()630+ unlock := lockAttachmentSessions(from, targetSess)
463- matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)631+ if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
464- if !matches {632+ unlock()
465- targetSess.mu.Unlock()633+ return nil, attachmentTransitionResult{}, false
466- return nil, nil, nil, false
467- }
468- from.mu.Lock()
469- if from.client != ac {
470- from.mu.Unlock()
471- targetSess.mu.Unlock()
472- return nil, nil, nil, false
473 }634 }
474- var guardedSource *tab
475 if guard.expectedSource != nil {635 if guard.expectedSource != nil {
476 if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {636 if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
477- from.mu.Unlock()637+ unlock()
478- targetSess.mu.Unlock()638+ return nil, attachmentTransitionResult{}, false
479- return nil, nil, nil, false
480 }639 }
481- guardedSource = guard.expectedSource640+ guard.expectedSource.mu.Lock()
482- guardedSource.mu.Lock()641+ sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
483- if guardedSource.floating.state == floatingVisible {642+ guard.expectedSource.mu.Unlock()
484- guardedSource.mu.Unlock()643+ if sourceHasFloating {
485- from.mu.Unlock()644+ unlock()
486- targetSess.mu.Unlock()645+ return nil, attachmentTransitionResult{}, false
487- return nil, nil, nil, false
488 }646 }
489 }647 }
490- term := from.terminal648+ unlock()
491- env := copyEnvironment(from.env)
492- from.client = nil
493- ac.setSession(nil)
494- if guardedSource != nil {
495- guardedSource.mu.Unlock()
496- }
497- from.mu.Unlock()
498 649
499- old := targetSess.client650+ // Release d.mu before the gate freeze/drain. transitionAttachment reacquires
500- targetSess.terminal = term651+ // d.mu and revalidates both registered lifecycles for publication.
501- targetSess.env = copyEnvironment(env)652+ d.mu.Unlock()
502- if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {653+ expectedTransport := ac.transportSnapshot()
503- targetSess.active = target.TabIndex654+ if sourceToken != nil {
504- }655+ expectedTransport = sourceToken.transport
505- targetSess.mu.Unlock()656+ }
506-657+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
507- // Prepare source invalidation, output rebase, and destination coordinator658+ source: from,
508- // identity before publishing the destination attachment.659+ target: targetSess,
509- cleanups := d.handoffCoordinator(from, targetSess, old, ac)660+ next: ac,
510- ac.setSession(targetSess)661+ expectedRole: attachmentActive,
511- targetSess.mu.Lock()662+ targetRole: attachmentActive,
512- targetSess.client = ac663+ expectedTransport: expectedTransport,
513- targetSess.mu.Unlock()664+ sourceToken: sourceToken,
665+ action: action,
666+ expectedSourceTab: guard.expectedSource,
667+ activateTargetTab: target.TabIndex >= 0,
668+ targetTabIndex: target.TabIndex,
669+ copySourceEnvironment: true,
670+ ready: true,
671+ })
672+ d.mu.Lock()
673+ if err != nil {
674+ return nil, attachmentTransitionResult{}, false
675+ }
514 d.touchMRU(targetSess)676 d.touchMRU(targetSess)
515 ac.recordPreviousSession(from)677 ac.recordPreviousSession(from)
516- return targetSess, old, cleanups, true678+ return targetSess, transition, true
517 }679 }
518 680
519 // resumeStoppedAndSwitchLocked creates the stopped representation and commits681 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
520 // the handoff while d.mu is held. Creation failure leaves the source client682 // the handoff while d.mu is held. Creation failure leaves the source client
521 // and stopped record untouched.683 // and stopped record untouched.
522-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {684+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
523 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {685 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
524- return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}686+ return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
687+ }
688+
689+ if sourceToken != nil {
690+ var targetSess *session
691+ d.mu.Unlock()
692+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
693+ source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive,
694+ expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
695+ expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
696+ createTargetLocked: func() (*session, error) {
697+ current, ok := d.stopped[target.Name]
698+ if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
699+ return nil, errAttachmentTransition
700+ }
701+ from.mu.Lock()
702+ cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
703+ from.mu.Unlock()
704+ created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, current.tabNames)
705+ targetSess = created
706+ return created, createErr
707+ },
708+ })
709+ d.mu.Lock()
710+ if err != nil {
711+ if targetSess != nil {
712+ d.mu.Unlock()
713+ _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
714+ d.mu.Lock()
715+ }
716+ return targetSess, attachmentTransitionResult{}, false, err
717+ }
718+ d.touchMRU(targetSess)
719+ ac.recordPreviousSession(from)
720+ return targetSess, transition, true, nil
525 }721 }
526 722
527- // The caller already holds d.mu. Keep handoff ownership atomic with global
528- // routing so it cannot select an attachment midway between sessions.
529- d.notices.routingMu.Lock()
530- defer d.notices.routingMu.Unlock()
531 from.mu.Lock()723 from.mu.Lock()
532 if from.client != ac {724 if from.client != ac {
533 from.mu.Unlock()725 from.mu.Unlock()
534- return nil, nil, false, nil726+ return nil, attachmentTransitionResult{}, false, nil
535 }727 }
536 term := from.terminal728 term := from.terminal
537 env := copyEnvironment(from.env)729 env := copyEnvironment(from.env)
730+ from.mu.Unlock()
538 cwd := d.dirOrHome(stopped.cwd)731 cwd := d.dirOrHome(stopped.cwd)
539 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)732 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
540 if err != nil {733 if err != nil {
541- from.mu.Unlock()
542 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)734 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
543- return nil, nil, false, err735+ return nil, attachmentTransitionResult{}, false, err
544 }736 }
545- from.client = nil
546- ac.setSession(nil)
547- from.mu.Unlock()
548 737
549- cleanups := d.handoffCoordinator(from, targetSess, nil, ac)738+ d.mu.Unlock()
550- ac.setSession(targetSess)739+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
551- targetSess.mu.Lock()740+ source: from, target: targetSess, next: ac,
552- targetSess.client = ac741+ expectedRole: attachmentActive, targetRole: attachmentActive,
553- targetSess.mu.Unlock()742+ expectedTransport: ac.transportSnapshot(), ready: true,
743+ })
744+ d.mu.Lock()
745+ if err != nil {
746+ return targetSess, attachmentTransitionResult{}, false, err
747+ }
554 d.touchMRU(targetSess)748 d.touchMRU(targetSess)
555 ac.recordPreviousSession(from)749 ac.recordPreviousSession(from)
556- return targetSess, cleanups, true, nil750+ return targetSess, transition, true, nil
557 }751 }
558 752
559 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {753 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -564,13 +758,13 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
564 // targets must use switchToTarget so resolution and commit share d.mu.758 // targets must use switchToTarget so resolution and commit share d.mu.
565 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {759 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
566 d.mu.Lock()760 d.mu.Lock()
567- _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{})761+ _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
568 d.mu.Unlock()762 d.mu.Unlock()
569 if !switched {763 if !switched {
570 return nil764 return nil
571 }765 }
572- finishRenderLifecycleCleanups(cleanups)766+ d.deferAttachmentTransitionCleanups(transition)
573- return old767+ return transition.displaced.ac
574 }768 }
575 769
576 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves770 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -579,23 +773,45 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
579 d.mu.Lock()773 d.mu.Lock()
580 stopped, ok := d.stopped[target.Name]774 stopped, ok := d.stopped[target.Name]
581 var (775 var (
582- targetSess *session776+ transition attachmentTransitionResult
583- cleanups []renderLifecycleCleanup
584 switched bool777 switched bool
585 )778 )
586 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {779 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
587- targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)780+ _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
588 }781 }
589 d.mu.Unlock()782 d.mu.Unlock()
590 if !switched {783 if !switched {
591 d.invalidateRender(from, ac, true, "picker.go")784 d.invalidateRender(from, ac, true, "picker.go")
592 return false785 return false
593 }786 }
594- finishRenderLifecycleCleanups(cleanups)787+ d.deferAttachmentTransitionCleanups(transition)
595- d.firstPaint(targetSess, ac, ac.size)788+ d.firstPaintForTransition(transition.published)
596 return true789 return true
597 }790 }
598 791
792+func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error {
793+ if token.ac == nil {
794+ return d.killPickerTarget(target)
795+ }
796+ if target.Stopped {
797+ token.effect.bindActionEnd(d, "picker-delete")
798+ token.effect.End()
799+ frozen := freezeRoleEffectGates(token.ac)
800+ defer frozen.unfreeze()
801+ if !d.sourceRoleTokenCurrentFrozen(token) {
802+ return nil
803+ }
804+ return d.killPickerTarget(target)
805+ }
806+ d.mu.Lock()
807+ targetSess := d.sessions[target.Session]
808+ d.mu.Unlock()
809+ if targetSess == nil {
810+ return nil
811+ }
812+ return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
813+}
814+
599 func (d *Daemon) killPickerTarget(target picker.Target) error {815 func (d *Daemon) killPickerTarget(target picker.Target) error {
600 if target.Stopped {816 if target.Stopped {
601 if err := d.retryStoppedPurge(target.Name); err != nil {817 if err := d.retryStoppedPurge(target.Name); err != nil {
@@ -613,20 +829,6 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
613 return nil829 return nil
614 }830 }
615 831
616-func (d *Daemon) refreshPicker(ac *attachedClient) {
617- sess := ac.currentSession()
618- if sess == nil {
619- return
620- }
621- views, curTab := d.pickerViews(sess)
622- ac.overlays.pickerMu.Lock()
623- if ac.overlays.picker != nil {
624- ac.overlays.picker = picker.New(views, sess.id, curTab)
625- }
626- ac.overlays.pickerMu.Unlock()
627- d.registerPreviewForSelection(ac)
628-}
629-
630 func snapshotPickerPreview(tb *tab) picker.Preview {832 func snapshotPickerPreview(tb *tab) picker.Preview {
631 if tb == nil {833 if tb == nil {
632 return picker.Preview{}834 return picker.Preview{}
M internal/usecase/daemon/picker_test.go +455 -24
@@ -23,6 +23,11 @@ import (
23 "github.com/bnema/vev/pkg/vt"23 "github.com/bnema/vev/pkg/vt"
24 )24 )
25 25
26+var (
27+ _ domain.TabStableID = moveSourceLocator{}.TabID
28+ _ domain.PaneStableID = moveSourceLocator{}.PaneID
29+)
30+
26 // --- test doubles -----------------------------------------------------------31 // --- test doubles -----------------------------------------------------------
27 32
28 type refusingSnapshotDeleteRepository struct {33 type refusingSnapshotDeleteRepository struct {
@@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
74 d.sessions[current.id] = current79 d.sessions[current.id] = current
75 d.sessions[ringing.id] = ringing80 d.sessions[ringing.id] = ringing
76 81
77- views, curTab := d.pickerViews(current)82+ views, currentSelection := d.pickerViews(current)
78 83
79- require.Equal(t, 0, curTab)84+ require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
80 require.Len(t, views, 2)85 require.Len(t, views, 2)
81 require.Equal(t, "alpha", views[0].Name)86 require.Equal(t, "alpha", views[0].Name)
82 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)87 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
83 require.Equal(t, "beta ", views[1].Name)88 require.Equal(t, "beta ", views[1].Name)
84 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)89 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
85 90
86- model := picker.New(views, current.id, 0)91+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}})
87 model.Down()92 model.Down()
88 model.Down()93 model.Down()
89 target, ok := model.Selected()94 target, ok := model.Selected()
@@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
130 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)135 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
131 }136 }
132 137
138+func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
139+ d := newTestDaemon(t, nil, stubClock{})
140+ source := &session{
141+ id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0,
142+ tabs: []*tab{
143+ {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
144+ {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
145+ },
146+ }
147+ destination := &session{
148+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
149+ tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}},
150+ }
151+ d.sessions[source.id] = source
152+ d.sessions[destination.id] = destination
153+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
154+ sourceLocator := moveSourceLocator{
155+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
156+ TabID: "source-tab",
157+ PaneID: "source-pane",
158+ }
159+
160+ views, current := d.pickerViews(source)
161+ require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
162+ require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
163+ var sourceView picker.SessionView
164+ for _, view := range views {
165+ if view.ID == source.id {
166+ sourceView = view
167+ }
168+ }
169+ require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
170+ "daemon capture does not remove the move source")
171+
172+ paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{
173+ Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
174+ })
175+ paneTarget, ok := paneModel.Selected()
176+ require.True(t, ok)
177+ require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
178+
179+ tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{})
180+ tabTarget, ok := tabModel.Selected()
181+ require.True(t, ok)
182+ require.Equal(t, destination.id, tabTarget.Session)
183+ require.Equal(t, destination.incarnation, tabTarget.Incarnation)
184+ require.Equal(t, destination.name, tabTarget.Name)
185+ require.Equal(t, -1, tabTarget.TabIndex)
186+}
187+
188+func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) {
189+ d, sess, ac, _, releases := newManualTabSession(t, 1)
190+ defer releases[0]()
191+ sess.mu.Lock()
192+ sess.incarnation = domain.IncarnationID{1}
193+ tb := sess.tabs[0]
194+ sess.mu.Unlock()
195+ tb.mu.Lock()
196+ tabID := tb.stableID
197+ paneID := tb.focusedPane().stableID
198+ tb.mu.Unlock()
199+ source := moveSourceLocator{
200+ Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
201+ TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID),
202+ }
203+
204+ for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} {
205+ err := d.enterPickerForIntent(sess, ac, intent, source)
206+
207+ require.ErrorIs(t, err, errNoMoveDestination)
208+ require.False(t, ac.overlays.pickerActive())
209+ ac.overlays.pickerMu.Lock()
210+ require.Nil(t, ac.overlays.pickerPreview)
211+ require.Nil(t, ac.overlays.pickerPreviewSession)
212+ require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation")
213+ ac.overlays.pickerMu.Unlock()
214+ }
215+}
216+
217+func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
218+ p1, release1 := newBlockingPTY(t)
219+ p2, release2 := newBlockingPTY(t)
220+ defer release1()
221+ defer release2()
222+ d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
223+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1}
224+ delete(d.sessions, domain.SessionID("manual"))
225+ d.sessions[sourceSession.id] = sourceSession
226+ targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
227+ targetTab.stableID = "destination-tab"
228+ targetSession := &session{
229+ id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
230+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab},
231+ }
232+ d.sessions[targetSession.id] = targetSession
233+ sourceTab := sourceSession.tabs[0]
234+ sourceTab.mu.Lock()
235+ sourceTab.stableID = "source-tab"
236+ sourcePaneID := sourceTab.focusedPane().stableID
237+ sourceTab.mu.Unlock()
238+ source := moveSourceLocator{
239+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
240+ TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID),
241+ }
242+
243+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
244+ ac.overlays.pickerMu.Lock()
245+ storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource
246+ selected, ok := ac.overlays.picker.Selected()
247+ generation := ac.overlays.pickerPreviewGeneration
248+ require.Same(t, targetTab, ac.overlays.pickerPreview)
249+ require.Same(t, targetSession, ac.overlays.pickerPreviewSession)
250+ ac.overlays.pickerMu.Unlock()
251+ require.Equal(t, pickerMovePane, storedIntent)
252+ require.Equal(t, source, storedSource)
253+ require.True(t, ok)
254+ require.Equal(t, targetSession.id, selected.Session)
255+ require.Equal(t, targetSession.incarnation, selected.Incarnation)
256+ require.Equal(t, targetSession.name, selected.Name)
257+ require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID)
258+ require.Equal(t, 0, selected.TabIndex)
259+ require.Nil(t, selected.ExpectedCreatedAt)
260+ rc := targetSession.renderCoordinator()
261+ require.NotNil(t, rc)
262+ rc.mu.Lock()
263+ subscription, subscribed := rc.previewWakes[ac]
264+ rc.mu.Unlock()
265+ require.True(t, subscribed)
266+ require.Equal(t, generation, subscription.generation)
267+
268+ d.enterPicker(sourceSession, ac)
269+
270+ rc.mu.Lock()
271+ _, subscribed = rc.previewWakes[ac]
272+ rc.mu.Unlock()
273+ require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription")
274+ ac.overlays.pickerMu.Lock()
275+ require.NotNil(t, ac.overlays.picker)
276+ require.Equal(t, pickerNavigate, ac.overlays.pickerIntent)
277+ require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource)
278+ ac.overlays.pickerMu.Unlock()
279+
280+ d.closePicker(ac)
281+ ac.overlays.pickerMu.Lock()
282+ require.Nil(t, ac.overlays.picker)
283+ require.Nil(t, ac.overlays.pickerPreview)
284+ ac.overlays.pickerMu.Unlock()
285+}
286+
287+func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) {
288+ p1, release1 := newBlockingPTY(t)
289+ p2, release2 := newBlockingPTY(t)
290+ p3, release3 := newBlockingPTY(t)
291+ defer release1()
292+ defer release2()
293+ defer release3()
294+ d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
295+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
296+ delete(d.sessions, domain.SessionID("manual"))
297+ d.sessions[sourceSession.id] = sourceSession
298+ target := &session{
299+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1,
300+ ctx: sourceSession.ctx, cancel: func() {},
301+ tabs: []*tab{
302+ newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
303+ newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel),
304+ },
305+ }
306+ target.tabs[0].stableID, target.tabs[1].stableID = "first", "active"
307+ d.sessions[target.id] = target
308+ source := moveSourceLocator{
309+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
310+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
311+ }
312+
313+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
314+
315+ ac.overlays.pickerMu.Lock()
316+ selected, ok := ac.overlays.picker.Selected()
317+ require.Same(t, target.tabs[1], ac.overlays.pickerPreview)
318+ ac.overlays.pickerMu.Unlock()
319+ require.True(t, ok)
320+ require.Equal(t, target.id, selected.Session)
321+ require.Equal(t, target.incarnation, selected.Incarnation)
322+ require.Equal(t, target.name, selected.Name)
323+ require.Equal(t, -1, selected.TabIndex)
324+ require.Nil(t, selected.ExpectedCreatedAt)
325+ require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus")
326+ d.closePicker(ac)
327+}
328+
329+func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
330+ t.Run("pane destination stable tab", func(t *testing.T) {
331+ d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
332+ defer releaseAll(sourceReleases)
333+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
334+ sourceSession.tabs[0].stableID = "source-tab"
335+ delete(d.sessions, domain.SessionID("manual"))
336+ d.sessions[sourceSession.id] = sourceSession
337+
338+ firstPTY, releaseFirst := newBlockingPTY(t)
339+ defer releaseFirst()
340+ secondPTY, releaseSecond := newBlockingPTY(t)
341+ defer releaseSecond()
342+ firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
343+ secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
344+ firstTab.stableID, secondTab.stableID = "first", "selected"
345+ destination := &session{
346+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
347+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab},
348+ }
349+ d.sessions[destination.id] = destination
350+ source := moveSourceLocator{
351+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
352+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
353+ }
354+
355+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
356+ ac.overlays.pickerMu.Lock()
357+ ac.overlays.picker.Down()
358+ before, ok := ac.overlays.picker.Selected()
359+ ac.overlays.pickerMu.Unlock()
360+ require.True(t, ok)
361+ require.Equal(t, domain.TabStableID("selected"), before.TabID)
362+
363+ destination.mu.Lock()
364+ destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
365+ destination.mu.Unlock()
366+ d.refreshPicker(ac)
367+
368+ ac.overlays.pickerMu.Lock()
369+ after, ok := ac.overlays.picker.Selected()
370+ ac.overlays.pickerMu.Unlock()
371+ require.True(t, ok)
372+ require.Equal(t, before.Session, after.Session)
373+ require.Equal(t, before.Incarnation, after.Incarnation)
374+ require.Equal(t, before.TabID, after.TabID)
375+ require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab")
376+ d.closePicker(ac)
377+ })
378+
379+ t.Run("tab destination session", func(t *testing.T) {
380+ d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
381+ defer releaseAll(sourceReleases)
382+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
383+ sourceSession.tabs[0].stableID = "source-tab"
384+ delete(d.sessions, domain.SessionID("manual"))
385+ d.sessions[sourceSession.id] = sourceSession
386+
387+ releases := make([]func(), 0, 2)
388+ for i, destination := range []struct {
389+ id domain.SessionID
390+ name string
391+ }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} {
392+ pty, release := newBlockingPTY(t)
393+ releases = append(releases, release)
394+ tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
395+ tb.stableID = string(destination.id) + "-tab"
396+ d.sessions[destination.id] = &session{
397+ id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true,
398+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb},
399+ }
400+ }
401+ defer releaseAll(releases)
402+ source := moveSourceLocator{
403+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
404+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
405+ }
406+
407+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
408+ ac.overlays.pickerMu.Lock()
409+ ac.overlays.picker.Down()
410+ before, ok := ac.overlays.picker.Selected()
411+ ac.overlays.pickerMu.Unlock()
412+ require.True(t, ok)
413+ require.Equal(t, domain.SessionID("selected"), before.Session)
414+
415+ d.refreshPicker(ac)
416+
417+ ac.overlays.pickerMu.Lock()
418+ after, ok := ac.overlays.picker.Selected()
419+ ac.overlays.pickerMu.Unlock()
420+ require.True(t, ok)
421+ require.Equal(t, before, after)
422+ d.closePicker(ac)
423+ })
424+}
425+
133 func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {426 func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
134 p, release := newBlockingPTY(t)427 p, release := newBlockingPTY(t)
135 defer release()428 defer release()
@@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
147 break440 break
148 }441 }
149 }442 }
150- model := picker.New([]picker.SessionView{originalView}, original.id, 0)443+ model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}})
151 target, ok := model.Selected()444 target, ok := model.Selected()
152 require.True(t, ok)445 require.True(t, ok)
153 446
@@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
193 views, _ := d.pickerViews(sess)486 views, _ := d.pickerViews(sess)
194 487
195 require.Len(t, views, 1)488 require.Len(t, views, 1)
196- require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)489+ require.Equal(t, []picker.TabEntry{
490+ {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
491+ {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true},
492+ }, views[0].Tabs)
197 }493 }
198 494
199 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {495 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
217 views, _ := d.pickerViews(sess)513 views, _ := d.pickerViews(sess)
218 514
219 require.Len(t, views, 1)515 require.Len(t, views, 1)
220- require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)516+ require.Equal(t, []picker.TabEntry{
517+ {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
518+ {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"},
519+ }, views[0].Tabs)
221 }520 }
222 521
223 func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {522 func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
@@ -505,7 +804,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
505 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")804 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
506 }805 }
507 806
508-func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {807+func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
509 p1, releasePTY1 := newBlockingPTY(t)808 p1, releasePTY1 := newBlockingPTY(t)
510 p2, releasePTY2 := newBlockingPTY(t)809 p2, releasePTY2 := newBlockingPTY(t)
511 defer releasePTY1()810 defer releasePTY1()
@@ -540,11 +839,24 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
540 require.Same(t, ac1, sess2.client)839 require.Same(t, ac1, sess2.client)
541 require.Nil(t, sess1.client)840 require.Nil(t, sess1.client)
542 require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")841 require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
543- det := awaitFrame(t, sends2, ports.MsgDetached)842+
544- dm, err := ports.UnmarshalDetached(det.Payload)843+ d.attachmentCleanupWg.Wait()
844+ var displacedFrames []ports.Frame
845+ for len(sends2) > 0 {
846+ displacedFrames = append(displacedFrames, <-sends2)
847+ }
848+ require.Len(t, displacedFrames, 1, "healthy displaced client receives only its snatched panel")
849+ require.Equal(t, ports.MsgOutput, displacedFrames[0].Type)
850+ displacedOutput, err := ports.UnmarshalOutput(displacedFrames[0].Payload)
545 require.NoError(t, err)851 require.NoError(t, err)
546- require.Equal(t, ports.ReasonReplaced, dm.Reason)852+ require.Contains(t, string(displacedOutput.Data), "Session snatched")
547- awaitFrame(t, sends1, ports.MsgOutput)853+ require.Same(t, sess2, ac2.currentSession())
854+ require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2))
855+
856+ movingFrame := awaitFrame(t, sends1, ports.MsgOutput)
857+ movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload)
858+ require.NoError(t, err)
859+ require.Zero(t, movingOutput.BaseStateNum, "moving client must reset before painting the destination")
548 }860 }
549 861
550 func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {862 func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
@@ -623,7 +935,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
623 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")935 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
624 }936 }
625 937
626-func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {938+func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) {
627 p1, releasePTY1 := newBlockingPTY(t)939 p1, releasePTY1 := newBlockingPTY(t)
628 p2, releasePTY2 := newBlockingPTY(t)940 p2, releasePTY2 := newBlockingPTY(t)
629 defer releasePTY1()941 defer releasePTY1()
@@ -662,25 +974,41 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
662 }()974 }()
663 <-enteredSend975 <-enteredSend
664 976
665- switchDone := make(chan struct{})977+ switchDone := make(chan attachmentTransitionResult, 1)
978+ switchErr := make(chan error, 1)
666 go func() {979 go func() {
667- cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)980+ result, err := d.transitionAttachment(attachmentTransitionRequest{
668- finishRenderLifecycleCleanups(cleanups)981+ source: sess1,
669- close(switchDone)982+ target: sess2,
983+ next: ac,
984+ expectedRole: attachmentActive,
985+ targetRole: attachmentActive,
986+ expectedTransport: ac.transportSnapshot(),
987+ ready: true,
988+ })
989+ switchDone <- result
990+ switchErr <- err
670 }()991 }()
671- awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
672 992
673- // The handoff is immediately about to acquire sendMu, which paint still owns993+ var result attachmentTransitionResult
674- // through its blocked transport send.994+ select {
995+ case result = <-switchDone:
996+ require.NoError(t, <-switchErr)
997+ case <-time.After(2 * time.Second):
998+ t.Fatal("session switch waited for in-flight transport send")
999+ }
1000+ require.True(t, result.published.activeCurrent())
675 select {1001 select {
676- case <-switchDone:1002+ case <-handoffAtSendMu:
677- t.Fatal("session switch completed while paint owned sendMu")1003+ t.Fatal("output rebase ran during architecture publication")
678 default:1004 default:
679 }1005 }
680 1006
681 close(releaseSend)1007 close(releaseSend)
682 awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")1008 awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
683- awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished")1009+ for _, cleanup := range result.cleanups {
1010+ cleanup.finish()
1011+ }
684 }1012 }
685 1013
686 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {1014 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
@@ -850,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
850 pickerModel: picker.New([]picker.SessionView{1178 pickerModel: picker.New([]picker.SessionView{
851 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},1179 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
852 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},1180 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
853- }, "selected", 0),1181+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}),
854 }1182 }
855 1183
856 captureOverlayLayers(&state, snap, domain.PaletteConfig{})1184 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
@@ -879,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
879 }1207 }
880 1208
881 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {1209 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
882- model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)1210+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
883 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}1211 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
884 cases := []struct {1212 cases := []struct {
885 name string1213 name string
@@ -1131,6 +1459,92 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
1131 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")1459 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
1132 }1460 }
1133 1461
1462+func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {
1463+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1464+ defer releaseAll(currentReleases)
1465+ current.id, current.name, current.active = "current", "z-current", 1
1466+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1467+ delete(d.sessions, domain.SessionID("manual"))
1468+ d.sessions[current.id] = current
1469+
1470+ otherPTY, releaseOther := newBlockingPTY(t)
1471+ defer releaseOther()
1472+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1473+ otherTab.stableID = "other-tab"
1474+ other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1475+ d.sessions[other.id] = other
1476+
1477+ targetPTY, releaseTarget := newBlockingPTY(t)
1478+ defer releaseTarget()
1479+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1480+ targetTab.stableID = "target-tab"
1481+ target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1482+ d.sessions[target.id] = target
1483+
1484+ d.enterPicker(current, ac)
1485+ d.handlePickerInput(ac, []byte("k"))
1486+ d.handlePickerInput(ac, []byte("k"))
1487+ ac.overlays.pickerMu.Lock()
1488+ selected, ok := ac.overlays.picker.Selected()
1489+ ac.overlays.pickerMu.Unlock()
1490+ require.True(t, ok)
1491+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1492+
1493+ d.handlePickerInput(ac, []byte("x"))
1494+
1495+ d.mu.Lock()
1496+ _, targetStillLive := d.sessions[target.id]
1497+ d.mu.Unlock()
1498+ require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1499+
1500+ ac.overlays.pickerMu.Lock()
1501+ selected, ok = ac.overlays.picker.Selected()
1502+ ac.overlays.pickerMu.Unlock()
1503+ require.True(t, ok)
1504+ require.Equal(t, current.id, selected.Session)
1505+ require.Equal(t, domain.TabStableID("current-active"), selected.TabID)
1506+ require.Equal(t, 1, selected.TabIndex)
1507+}
1508+
1509+func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1510+ d, current, ac, _, currentReleases := newManualTabSession(t, 1)
1511+ defer releaseAll(currentReleases)
1512+ current.id, current.name = "current", "z-current"
1513+ delete(d.sessions, domain.SessionID("manual"))
1514+ d.sessions[current.id] = current
1515+
1516+ targetPTY, releaseTarget := newBlockingPTY(t)
1517+ defer releaseTarget()
1518+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1519+ target := &session{id: "target", name: "a-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1520+ d.sessions[target.id] = target
1521+ current.mu.Lock()
1522+ current.client = ac
1523+ current.mu.Unlock()
1524+ require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
1525+
1526+ d.enterPicker(current, ac)
1527+ d.handlePickerInput(ac, []byte("k"))
1528+ ac.overlays.pickerMu.Lock()
1529+ selected, ok := ac.overlays.picker.Selected()
1530+ ac.overlays.pickerMu.Unlock()
1531+ require.True(t, ok)
1532+ require.Equal(t, target.id, selected.Session)
1533+
1534+ token := current.attachmentToken(ac, ac.transport())
1535+ token.lease = current.renderCoordinator().attachmentLease(ac)
1536+ ac.publishRoleCapability(token)
1537+ effect, admitted := ac.beginRoleEffect(token)
1538+ require.True(t, admitted)
1539+ d.handlePickerInput(ac, []byte("x"), effect)
1540+
1541+ d.mu.Lock()
1542+ _, targetStillLive := d.sessions[target.id]
1543+ d.mu.Unlock()
1544+ require.False(t, targetStillLive, "effect-backed picker deletion must remove the selected session")
1545+ require.True(t, ac.overlays.pickerActive(), "successful deletion refreshes rather than closes the picker")
1546+}
1547+
1134 func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {1548 func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
1135 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)1549 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
1136 defer releaseAll(releases)1550 defer releaseAll(releases)
@@ -1177,6 +1591,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1177 p, release := newBlockingPTY(t)1591 p, release := newBlockingPTY(t)
1178 defer release()1592 defer release()
1179 d, from, ac, sends := newManualSessionWithPTYs(t, p)1593 d, from, ac, sends := newManualSessionWithPTYs(t, p)
1594+ otherPTY, releaseOther := newBlockingPTY(t)
1595+ defer releaseOther()
1596+ otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
1597+ otherTab.stableID = "other-tab"
1598+ other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1599+ d.sessions[other.id] = other
1180 cause := errors.New("delete failed")1600 cause := errors.New("delete failed")
1181 store, state := newMockStore(t)1601 store, state := newMockStore(t)
1182 WithStore(t, store)(d)1602 WithStore(t, store)(d)
@@ -1207,4 +1627,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1207 d.mu.Unlock()1627 d.mu.Unlock()
1208 require.True(t, retained, "failed deletion must retain the reserved name")1628 require.True(t, retained, "failed deletion must retain the reserved name")
1209 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")1629 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
1630+
1631+ from.mu.Lock()
1632+ activeTab := from.tabs[from.active]
1633+ activeTabID := activeTab.stableID
1634+ from.mu.Unlock()
1635+ ac.overlays.pickerMu.Lock()
1636+ selected, selectedOK := ac.overlays.picker.Selected()
1637+ ac.overlays.pickerMu.Unlock()
1638+ require.True(t, selectedOK)
1639+ require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session")
1640+ require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab")
1210 }1641 }
M internal/usecase/daemon/prompt.go +29 -3
@@ -23,10 +23,21 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26+func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) {
27+ d.closePrompt(ac)
28+ ac.overlays.promptMu.Lock()
29+ ac.overlays.prompt = promptui.New(title, initial)
30+ ac.overlays.promptTransitionSubmit = submit
31+ ac.overlays.promptPending = nil
32+ ac.overlays.promptMu.Unlock()
33+ d.invalidateRender(sess, ac, true, "prompt.go")
34+}
35+
26 func (d *Daemon) closePrompt(ac *attachedClient) {36 func (d *Daemon) closePrompt(ac *attachedClient) {
27 ac.overlays.promptMu.Lock()37 ac.overlays.promptMu.Lock()
28 ac.overlays.prompt = nil38 ac.overlays.prompt = nil
29 ac.overlays.promptSubmit = nil39 ac.overlays.promptSubmit = nil
40+ ac.overlays.promptTransitionSubmit = nil
30 ac.overlays.promptPending = nil41 ac.overlays.promptPending = nil
31 ac.overlays.promptMu.Unlock()42 ac.overlays.promptMu.Unlock()
32 }43 }
@@ -39,7 +50,7 @@ func promptValidationError(err error) bool {
39 errors.Is(err, errSessionNameInUse)50 errors.Is(err, errSessionNameInUse)
40 }51 }
41 52
42-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {53+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
43 sess := ac.currentSession()54 sess := ac.currentSession()
44 if sess == nil {55 if sess == nil {
45 return56 return
@@ -55,6 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
55 changed := false66 changed := false
56 exit := false67 exit := false
57 var submit func(string) error68 var submit func(string) error
69+ var transitionSubmit func(string, attachmentRoleToken) error
58 var submittedPrompt *promptui.Model70 var submittedPrompt *promptui.Model
59 var value string71 var value string
60 72
@@ -69,6 +81,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
69 },81 },
70 enter: func() {82 enter: func() {
71 submit = ac.overlays.promptSubmit83 submit = ac.overlays.promptSubmit
84+ transitionSubmit = ac.overlays.promptTransitionSubmit
72 submittedPrompt = ac.overlays.prompt85 submittedPrompt = ac.overlays.prompt
73 value = strings.TrimSpace(ac.overlays.prompt.Value())86 value = strings.TrimSpace(ac.overlays.prompt.Value())
74 },87 },
@@ -78,8 +91,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
78 })91 })
79 ac.overlays.promptMu.Unlock()92 ac.overlays.promptMu.Unlock()
80 93
81- if submit != nil {94+ if submit != nil || transitionSubmit != nil {
82- if err := submit(value); err != nil {95+ var err error
96+ if transitionSubmit != nil {
97+ var token attachmentRoleToken
98+ if len(effects) != 0 && effects[0] != nil {
99+ token = effects[0].roleToken()
100+ }
101+ err = transitionSubmit(value, token)
102+ } else {
103+ err = submit(value)
104+ }
105+ if errors.Is(err, errAttachmentTransition) {
106+ return
107+ }
108+ if err != nil {
83 ac.overlays.promptMu.Lock()109 ac.overlays.promptMu.Lock()
84 if ac.overlays.prompt == submittedPrompt {110 if ac.overlays.prompt == submittedPrompt {
85 ac.overlays.prompt.SetError(err.Error())111 ac.overlays.prompt.SetError(err.Error())
M internal/usecase/daemon/render.go +123 -60
@@ -82,7 +82,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
82 return false82 return false
83 }83 }
84 84
85+// ptyReader is retained as a compatibility entry point for focused tests. The
86+// production goroutine calls readPanePTY, whose closure owns only the pane.
85 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {87 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
88+ if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
89+ publishPaneOwner(p, sess, tb, 0)
90+ }
91+ d.readPanePTY(p)
92+}
93+
94+func (d *Daemon) readPanePTY(p *pane) {
86 defer d.sessWg.Done()95 defer d.sessWg.Done()
87 if p == nil {96 if p == nil {
88 return97 return
@@ -98,103 +107,146 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
98 for {107 for {
99 n, err := p.pty.Read(buf)108 n, err := p.pty.Read(buf)
100 if n > 0 {109 if n > 0 {
101- d.processPTYData(sess, tb, p, buf[:n], true)110+ d.processPanePTYData(p, buf[:n], true)
102 }111 }
103 if err != nil {112 if err != nil {
104- sess.mu.Lock()113+ lease := p.effectLease()
105- sessionName := sess.name114+ if sessionName, current := paneEffectSessionName(lease); current {
106- sess.mu.Unlock()115+ d.log.Info("pane pty closed", "err", err, "session", sessionName)
107- d.log.Info("pane pty closed", "err", err, "session", sessionName)116+ } else {
117+ d.log.Info("pane pty closed", "err", err)
118+ }
108 if p.onExit != nil {119 if p.onExit != nil {
109 p.onExit()120 p.onExit()
110 } else {121 } else {
111- d.reapPane(sess, tb, p)122+ d.reapPaneOwner(p)
112 }123 }
113 return124 return
114 }125 }
115 }126 }
116 }127 }
117 128
118-// processPTYData is the sole VT parsing path. While a resize apply owns the129+type panePTYEffects struct {
130+ lease paneEffectLease
131+ renderCoordinator *renderCoordinator
132+ wasSyncing bool
133+ isSyncing bool
134+ completeSyncRead bool
135+ syncEnded bool
136+ attention bool
137+ responses []byte
138+ clipboards []string
139+ syncCleanup syncTimerCleanup
140+}
141+
142+// processPTYData retains the old call shape for resize replay and focused tests,
143+// but routing is always derived from the pane owner captured under pane.mu.
144+func (d *Daemon) processPTYData(_ *session, _ *tab, p *pane, data []byte, bufferDuringApply bool) {
145+ d.processPanePTYData(p, data, bufferDuringApply)
146+}
147+
148+// processPanePTYData is the sole VT parsing path. While a resize apply owns the
119 // pane, reader calls append exact bytes and returns immediately; it never waits149 // pane, reader calls append exact bytes and returns immediately; it never waits
120 // for PTY.Resize or loses a short read.150 // for PTY.Resize or loses a short read.
121-func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {151+func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool) {
122- rc := sess.renderCoordinator()152+ if p == nil {
153+ return
154+ }
123 p.mu.Lock()155 p.mu.Lock()
124 if bufferDuringApply && p.resizeApplying {156 if bufferDuringApply && p.resizeApplying {
125 p.resizePending = append(p.resizePending, data...)157 p.resizePending = append(p.resizePending, data...)
126 p.mu.Unlock()158 p.mu.Unlock()
127 return159 return
128 }160 }
129- wasSyncing := p.screen.SyncUpdateActive()161+ effects := panePTYEffects{lease: p.effectLeaseLocked()}
162+ owner := effects.lease.owner
163+ if owner != nil && owner.session != nil {
164+ effects.renderCoordinator = owner.session.renderCoordinator()
165+ }
166+ effects.wasSyncing = p.screen.SyncUpdateActive()
130 p.screen.Write(data)167 p.screen.Write(data)
131 p.refreshTerminalTitleLocked()168 p.refreshTerminalTitleLocked()
132- isSyncing := p.screen.SyncUpdateActive()169+ effects.isSyncing = p.screen.SyncUpdateActive()
133- completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)170+ effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
134- var syncGen uint64171+ if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
135- syncEnded := false172+ if effects.isSyncing {
136- if wasSyncing != isSyncing {173+ syncGen := owner.session.syncGen.Add(1)
137- if isSyncing {
138- syncGen = sess.syncGen.Add(1)
139 p.syncGen = syncGen174 p.syncGen = syncGen
140- if rc != nil {175+ if effects.renderCoordinator != nil {
141- rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {176+ lease := effects.lease
177+ effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
178+ return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
179+ }, func() {
142 p.mu.Lock()180 p.mu.Lock()
143- if p.syncGen == syncGen && p.screen.SyncUpdateActive() {181+ if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() {
144 p.screen.ForceSyncEnd()182 p.screen.ForceSyncEnd()
145 }183 }
146 p.mu.Unlock()184 p.mu.Unlock()
147 })185 })
148 }186 }
149 } else {187 } else {
150- syncGen = p.syncGen188+ effects.syncEnded = effects.renderCoordinator != nil && effects.renderCoordinator.removeSyncEnd(p, p.syncGen, true)
151- syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
152- }
153- }
154- p.mu.Unlock()
155- renderable := d.paneRenderable(sess, tb, p)
156- markSnapshotDirty(sess)
157- d.flushPTYEffects(sess, tb, p)
158- if rc != nil {
159- if wasSyncing != isSyncing {
160- if isSyncing {
161- if renderable {
162- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
163- }
164- } else if syncEnded && renderable {
165- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
166- }
167- }
168- if completeSyncRead && renderable {
169- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
170- } else if renderable && !isSyncing && wasSyncing == isSyncing {
171- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
172 }189 }
173 }190 }
174-}191+ effects.attention = p.ptyAttention
175-
176-// flushPTYEffects preserves the normal response/title/attention/clipboard
177-// behavior for both direct reads and resize-buffer replay.
178-func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
179- p.mu.Lock()
180- attention := p.ptyAttention
181 p.ptyAttention = false192 p.ptyAttention = false
182- responses := append([]byte(nil), p.ptyResponses...)193+ effects.responses = append([]byte(nil), p.ptyResponses...)
183 p.ptyResponses = p.ptyResponses[:0]194 p.ptyResponses = p.ptyResponses[:0]
184- clipboards := append([]string(nil), p.ptyClipboards...)195+ effects.clipboards = append([]string(nil), p.ptyClipboards...)
185 p.ptyClipboards = p.ptyClipboards[:0]196 p.ptyClipboards = p.ptyClipboards[:0]
186 p.mu.Unlock()197 p.mu.Unlock()
187- if attention {198+ effects.syncCleanup.finish()
188- d.noteAttention(sess, tb)199+ d.applyPanePTYEffects(p, effects)
200+}
201+
202+// applyPanePTYEffects emits only effects still bound to the owner generation
203+// that parsed them. Each owner-sensitive boundary revalidates independently so
204+// ownership publication can retire the remaining effects without rerouting
205+// bytes that belonged to the old owner.
206+func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
207+ owner := effects.lease.owner
208+ if owner == nil || owner.session == nil || owner.tab == nil {
209+ return
210+ }
211+ if effects.lease.Current() {
212+ markSnapshotDirty(owner.session)
213+ }
214+ if effects.attention && effects.lease.Current() {
215+ d.noteAttention(owner.session, owner.tab)
216+ }
217+ for _, b64 := range effects.clipboards {
218+ if effects.lease.Current() {
219+ d.forwardClipboardAsync(effects.lease, b64)
220+ }
221+ }
222+ if len(effects.responses) > 0 && effects.lease.Current() {
223+ if _, err := p.pty.Write(effects.responses); err != nil {
224+ if sessionName, current := paneEffectSessionName(effects.lease); current {
225+ d.log.Warn("pty response write failed", "err", err, "session", sessionName)
226+ }
227+ }
189 }228 }
190- for _, b64 := range clipboards {229+ if effects.renderCoordinator == nil || !effects.lease.Current() {
191- d.forwardClipboardAsync(sess, b64)230+ return
192 }231 }
193- if len(responses) > 0 {232+ renderable := d.paneRenderable(owner.session, owner.tab, p)
194- if _, err := p.pty.Write(responses); err != nil {233+ if !effects.lease.Current() {
195- d.log.Warn("pty response write failed", "err", err, "session", sess.name)234+ return
235+ }
236+ if effects.wasSyncing != effects.isSyncing {
237+ if effects.isSyncing {
238+ if renderable {
239+ effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
240+ }
241+ } else if effects.syncEnded && renderable {
242+ effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
196 }243 }
197 }244 }
245+ if effects.completeSyncRead && renderable {
246+ effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
247+ } else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
248+ effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
249+ }
198 }250 }
199 251
200 // paint draws the composed client frame (active tab plus status bar) and252 // paint draws the composed client frame (active tab plus status bar) and
@@ -210,8 +262,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
210 // paint owns attachment sendMu while it captures, composes, and emits; JSONL262 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
211 // observer I/O must therefore happen only after that ownership is released.263 // observer I/O must therefore happen only after that ownership is released.
212 type runtimeMarkBatch struct {264 type runtimeMarkBatch struct {
213- observer ports.RuntimeObserver265+ observer ports.RuntimeObserver
214- marks []ports.RuntimeMark266+ marks []ports.RuntimeMark
267+ roleEffect *roleEffectTicket
215 }268 }
216 269
217 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {270 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -243,6 +296,16 @@ func (b *runtimeMarkBatch) flush() {
243 296
244 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {297 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
245 marks := d.newRuntimeMarkBatch()298 marks := d.newRuntimeMarkBatch()
299+ if lease != nil {
300+ token := sess.attachmentToken(ac, ac.transport())
301+ token.lease = lease
302+ ticket, admitted := ac.beginRoleEffect(token)
303+ if !admitted {
304+ return
305+ }
306+ marks.roleEffect = ticket
307+ defer ticket.End()
308+ }
246 tb := sess.activeTab()309 tb := sess.activeTab()
247 if tb == nil {310 if tb == nil {
248 return311 return
M internal/usecase/daemon/render_coordinator.go +180 -112
@@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
132 return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)132 return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)
133 }133 }
134 134
135-// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it135+// renderInvalidationReservation is the in-memory half of an invalidation.
136-// rejects a same-object replacement whose source pointer still matches but136+// finish performs observer, timer, and callback work after ownership fences are
137-// whose lifecycle lease has been revoked.137+// released.
138-func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {138+type renderInvalidationReservation struct {
139+ coordinator *renderCoordinator
140+ invalidation renderInvalidation
141+ onInvalidate func(renderInvalidation)
142+ queueStart bool
143+ queueCorrelation ports.RuntimeCorrelation
144+ observer ports.RuntimeObserver
145+ old *timerToken
146+ arm bool
147+ generation uint64
148+ delay time.Duration
149+ clock ports.Clock
150+}
151+
152+// reserveInvalidationForLeaseAtResizeEpoch publishes only coordinator memory.
153+// It is safe inside a short pane-owner fence and performs no timer or observer
154+// calls.
155+func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
139 c.mu.Lock()156 c.mu.Lock()
140- // An unbound coordinator is the manual/headless harness state; there is no157+ defer c.mu.Unlock()
141- // published production attachment to protect there. A parked target may
142- // still publish its own PTY/session mutations to picker observers, but no
143- // attachment-owned callback may revive that target's render path.
144 detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)158 detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
145 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||159 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
146 (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||160 (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
147 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {161 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
148- c.mu.Unlock()162+ return nil, false
149- return false
150 }163 }
151- onInvalidate := c.opts.onInvalidate164+ reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
152 c.metrics.invalidations.Add(1)165 c.metrics.invalidations.Add(1)
153 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview166 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
154- queueStart := false
155- var queueCorrelation ports.RuntimeCorrelation
156 if !wasPending {167 if !wasPending {
157 c.ackDeferred = false168 c.ackDeferred = false
158 c.deadlineDue = false169 c.deadlineDue = false
159 if c.opts.observer != nil {170 if c.opts.observer != nil {
160 c.queueMarked = true171 c.queueMarked = true
161 c.queueCorrelation = ports.NewRuntimeCorrelation()172 c.queueCorrelation = ports.NewRuntimeCorrelation()
162- queueCorrelation = c.queueCorrelation173+ reservation.queueCorrelation = c.queueCorrelation
163- queueStart = true174+ reservation.queueStart = true
164 }175 }
165 }176 }
166 c.pending = true177 c.pending = true
@@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
168 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent179 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
169 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0180 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
170 c.coalesced++181 c.coalesced++
171- // A deadline may have expired while synchronized output still gated the182+ reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
172- // pending work. Completion republishes urgently and must reserve a fresh183+ if reservation.arm {
173- // deadline rather than leaving that already-fired timer as the only arm.184+ _, reservation.old = c.normalLane.replaceLocked()
174- arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
175- var old *timerToken
176- if arm {
177- _, old = c.normalLane.replaceLocked()
178 }185 }
179- gen := c.normalLane.generation186+ reservation.generation = c.normalLane.generation
180- delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond187+ reservation.delay = minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
181 if c.pendingUrgent {188 if c.pendingUrgent {
182- delay = urgentRenderDeadline189+ reservation.delay = urgentRenderDeadline
183 }190 }
184- clock := c.opts.clock191+ reservation.clock = c.opts.clock
185- c.armed = c.armed || arm192+ reservation.observer = c.opts.observer
186- observer := c.opts.observer193+ c.armed = c.armed || reservation.arm
187- c.mu.Unlock()194+ return reservation, true
188- if queueStart && observer != nil {195+}
189- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))196+
197+func (r *renderInvalidationReservation) finish() {
198+ if r == nil || r.coordinator == nil {
199+ return
200+ }
201+ c := r.coordinator
202+ if r.queueStart && r.observer != nil {
203+ r.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", r.queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
190 }204 }
191- stopDetachedTimer(old)205+ stopDetachedTimer(r.old)
192- if !arm || clock == nil {206+ if !r.arm || r.clock == nil {
193- if onInvalidate != nil {207+ if r.onInvalidate != nil {
194- onInvalidate(inv)208+ r.onInvalidate(r.invalidation)
195 }209 }
196- return true210+ return
197 }211 }
198-212+ timer := r.clock.NewTimer(r.delay)
199- // NewTimer and C may re-enter the coordinator. Publish only if this
200- // generation is still the reserved deadline after those external calls.
201- timer := clock.NewTimer(delay)
202 timerC := timer.C()213 timerC := timer.C()
203 c.mu.Lock()214 c.mu.Lock()
204- valid := !c.torndown && c.pending && c.normalLane.generation == gen215+ valid := !c.torndown && c.pending && c.normalLane.generation == r.generation
205 var token *timerToken216 var token *timerToken
206 if valid && timerC != nil {217 if valid && timerC != nil {
207- token = c.normalLane.publishLocked(gen, timer)218+ token = c.normalLane.publishLocked(r.generation, timer)
208 valid = token != nil219 valid = token != nil
209 if valid {220 if valid {
210 c.supervisor.startLocked(token, timerC, func() {221 c.supervisor.startLocked(token, timerC, func() {
@@ -216,34 +227,44 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
216 c.mu.Unlock()227 c.mu.Unlock()
217 if !valid {228 if !valid {
218 timer.Stop()229 timer.Stop()
219- if onInvalidate != nil {230+ } else if timerC == nil {
220- onInvalidate(inv)
221- }
222- return true
223- }
224- if timerC == nil {
225 timer.Stop()231 timer.Stop()
226- c.fire(gen, false, true)232+ c.fire(r.generation, false, true)
227- if onInvalidate != nil {
228- onInvalidate(inv)
229- }
230- return true
231 }233 }
232- // Test-visible observation follows deadline publication, so a producer234+ if r.onInvalidate != nil {
233- // callback cannot observe a successful invalidation before its timer owns235+ r.onInvalidate(r.invalidation)
234- // a worker (and race tests cannot finish while a mock expectation remains).
235- if onInvalidate != nil {
236- onInvalidate(inv)
237 }236 }
238- runtime.Gosched()237+ if valid && timerC != nil {
238+ runtime.Gosched()
239+ }
240+}
241+
242+// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
243+// rejects a same-object replacement whose source pointer still matches but
244+// whose lifecycle lease has been revoked.
245+func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246+ reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247+ if !ok {
248+ return false
249+ }
250+ reservation.finish()
239 return true251 return true
240 }252 }
241 253
242 // notifyAck reports that the client acknowledged an output state, releasing254 // notifyAck reports that the client acknowledged an output state, releasing
243 // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired255 // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
244 // normal or urgent deadline.256 // normal or urgent deadline.
245-func (c *renderCoordinator) notifyAck() {257+func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
258+
259+// notifyAckForLease prevents an acknowledgement captured by an obsolete active
260+// frame from flushing the replacement attachment's render queue. A nil lease is
261+// reserved for coordinator-owned/direct test callers.
262+func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
246 c.mu.Lock()263 c.mu.Lock()
264+ if lease != nil && !c.leaseCurrentLocked(lease, true) {
265+ c.mu.Unlock()
266+ return
267+ }
247 if (!c.ackDeferred && !c.deadlineDue) || !c.pending {268 if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
248 c.mu.Unlock()269 c.mu.Unlock()
249 return270 return
@@ -278,20 +299,47 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func())
278 c.noteSyncBeginWithRenderability(p, gen, renderable, force...)299 c.noteSyncBeginWithRenderability(p, gen, renderable, force...)
279 }300 }
280 301
302+// syncTimerCleanup carries detached timer work across an enclosing pane or
303+// coordinator critical section. finish never waits for a worker.
304+type syncTimerCleanup struct{ tokens []*timerToken }
305+
306+func (cleanup *syncTimerCleanup) add(token *timerToken) {
307+ if token != nil {
308+ cleanup.tokens = append(cleanup.tokens, token)
309+ }
310+}
311+
312+func (cleanup *syncTimerCleanup) append(other syncTimerCleanup) {
313+ cleanup.tokens = append(cleanup.tokens, other.tokens...)
314+}
315+
316+func (cleanup syncTimerCleanup) finish() {
317+ for _, token := range cleanup.tokens {
318+ stopDetachedTimer(token)
319+ }
320+}
321+
281 // noteSyncBeginWithRenderability records lifecycle unconditionally while the322 // noteSyncBeginWithRenderability records lifecycle unconditionally while the
282 // supplied predicate decides dynamically whether this batch gates composition.323 // supplied predicate decides dynamically whether this batch gates composition.
283 func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) {324 func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) {
325+ c.beginSyncBatchWithRenderability(p, gen, renderable, force...).finish()
326+}
327+
328+// beginSyncBatchWithRenderability publishes a batch and returns every timer
329+// detached while doing so. Callers that already own pane.mu defer finish until
330+// after releasing the parsing fence.
331+func (c *renderCoordinator) beginSyncBatchWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) syncTimerCleanup {
332+ var cleanup syncTimerCleanup
284 c.mu.Lock()333 c.mu.Lock()
285 if c.torndown {334 if c.torndown {
286 c.mu.Unlock()335 c.mu.Unlock()
287- return336+ return cleanup
288 }337 }
289 if c.syncBatches == nil {338 if c.syncBatches == nil {
290 c.syncBatches = make(map[*pane]*syncBatch)339 c.syncBatches = make(map[*pane]*syncBatch)
291 }340 }
292- var old *timerToken
293 if previous := c.syncBatches[p]; previous != nil {341 if previous := c.syncBatches[p]; previous != nil {
294- old = detachSyncBatchLocked(previous)342+ cleanup.add(detachSyncBatchLocked(previous))
295 }343 }
296 batch := &syncBatch{generation: gen, renderable: renderable}344 batch := &syncBatch{generation: gen, renderable: renderable}
297 if len(force) != 0 {345 if len(force) != 0 {
@@ -301,9 +349,8 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
301 c.syncRegistryVersion++349 c.syncRegistryVersion++
302 clock := c.opts.clock350 clock := c.opts.clock
303 c.mu.Unlock()351 c.mu.Unlock()
304- stopDetachedTimer(old)
305 if clock == nil {352 if clock == nil {
306- return353+ return cleanup
307 }354 }
308 355
309 // Both timer operations are external. A reentrant callback may replace or356 // Both timer operations are external. A reentrant callback may replace or
@@ -323,9 +370,25 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
323 }370 }
324 c.mu.Unlock()371 c.mu.Unlock()
325 if !valid || timerC == nil {372 if !valid || timerC == nil {
326- stopDetachedTimer(&timerToken{timer: timer})373+ cleanup.add(&timerToken{timer: timer})
327- return
328 }374 }
375+ return cleanup
376+}
377+
378+// detachSyncBatchGeneration removes only the exact pane generation. It returns
379+// timer cleanup to the caller so no Timer method runs under an enclosing pane
380+// or coordinator lock.
381+func (c *renderCoordinator) detachSyncBatchGeneration(p *pane, gen uint64) syncTimerCleanup {
382+ var cleanup syncTimerCleanup
383+ c.mu.Lock()
384+ batch := c.syncBatches[p]
385+ if batch != nil && batch.generation == gen {
386+ delete(c.syncBatches, p)
387+ c.syncRegistryVersion++
388+ cleanup.add(detachSyncBatchLocked(batch))
389+ }
390+ c.mu.Unlock()
391+ return cleanup
329 }392 }
330 393
331 func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) {394 func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) {
@@ -558,29 +621,41 @@ func (cleanup renderLifecycleCleanup) finish() {
558 // beginDetach invalidates pending work. It never waits for detached workers.621 // beginDetach invalidates pending work. It never waits for detached workers.
559 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {622 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
560 c.mu.Lock()623 c.mu.Lock()
624+ cleanup := c.beginDetachLocked(ac)
625+ c.mu.Unlock()
626+ return cleanup
627+}
628+
629+// beginDetachLocked is the prelocked lifecycle seam used when registry,
630+// current-session, and lease publication must share one commit boundary.
631+func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {
561 var cleanup renderLifecycleCleanup632 var cleanup renderLifecycleCleanup
562 if c.lease != nil && c.lease.active && c.lease.attachment == ac {633 if c.lease != nil && c.lease.active && c.lease.attachment == ac {
563 c.lease.active = false634 c.lease.active = false
564- c.pending = false635+ cleanup = c.resetAttachmentLifecycleLocked()
565- c.pendingReset = false
566- c.pendingUrgent = false
567- c.ackDeferred = false
568- c.pendingPreview = false
569- c.coalesced = 0
570- _, timer := c.normalLane.replaceLocked()
571- c.armed = false
572- _, resizeTimer := c.resizeLane.replaceLocked()
573- _, retryTimer := c.retryLane.replaceLocked()
574- cleanup = renderLifecycleCleanup{
575- tokens: []*timerToken{timer, resizeTimer, retryTimer},
576- observer: c.opts.observer,
577- queueCorrelation: c.queueCorrelation,
578- queueMarked: c.queueMarked,
579- ackBlocked: c.ackBlocked,
580- }
581- c.queueMarked, c.ackBlocked = false, nil
582 }636 }
583- c.mu.Unlock()637+ return cleanup
638+}
639+
640+func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {
641+ c.pending = false
642+ c.pendingReset = false
643+ c.pendingUrgent = false
644+ c.ackDeferred = false
645+ c.pendingPreview = false
646+ c.coalesced = 0
647+ _, timer := c.normalLane.replaceLocked()
648+ c.armed = false
649+ _, resizeTimer := c.resizeLane.replaceLocked()
650+ _, retryTimer := c.retryLane.replaceLocked()
651+ cleanup := renderLifecycleCleanup{
652+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
653+ observer: c.opts.observer,
654+ queueCorrelation: c.queueCorrelation,
655+ queueMarked: c.queueMarked,
656+ ackBlocked: c.ackBlocked,
657+ }
658+ c.queueMarked, c.ackBlocked = false, nil
584 return cleanup659 return cleanup
585 }660 }
586 661
@@ -593,36 +668,29 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
593 // for detached workers.668 // for detached workers.
594 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {669 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
595 c.mu.Lock()670 c.mu.Lock()
596- var cleanup renderLifecycleCleanup671+ cleanup, _ := c.beginReplaceLocked(old, replacement, ready)
597- // A coordinator may be installed while replacing a legacy attachment
598- // which predated coordinator ownership. In that case nil has no pending
599- // identity to invalidate, and the replacement becomes the first bound
600- // attachment atomically with this lifecycle transition.
601- if c.lease == nil || c.lease.attachment == old {
602- c.installLeaseLocked(replacement, ready)
603- c.pending = false
604- c.pendingReset = false
605- c.pendingUrgent = false
606- c.ackDeferred = false
607- c.pendingPreview = false
608- c.coalesced = 0
609- _, timer := c.normalLane.replaceLocked()
610- c.armed = false
611- _, resizeTimer := c.resizeLane.replaceLocked()
612- _, retryTimer := c.retryLane.replaceLocked()
613- cleanup = renderLifecycleCleanup{
614- tokens: []*timerToken{timer, resizeTimer, retryTimer},
615- observer: c.opts.observer,
616- queueCorrelation: c.queueCorrelation,
617- queueMarked: c.queueMarked,
618- ackBlocked: c.ackBlocked,
619- }
620- c.queueMarked, c.ackBlocked = false, nil
621- }
622 c.mu.Unlock()672 c.mu.Unlock()
623 return cleanup673 return cleanup
624 }674 }
625 675
676+// beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before
677+// their ownership commit, making lease installation non-failing afterward.
678+func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) {
679+ if !c.canReplaceLocked(old, replacement) {
680+ return renderLifecycleCleanup{}, nil
681+ }
682+ lease := c.installLeaseLocked(replacement, ready)
683+ return c.resetAttachmentLifecycleLocked(), lease
684+}
685+
686+func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool {
687+ // A coordinator may be installed while replacing a legacy attachment which
688+ // predated coordinator ownership. Any inactive lease is detached historical
689+ // state; a resume may also replace the same client object because registry and
690+ // transport validation proved a new role incarnation.
691+ return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement)
692+}
693+
626 // noteReplace is for callers that hold no outer lifecycle locks.694 // noteReplace is for callers that hold no outer lifecycle locks.
627 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {695 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
628 ready := true696 ready := true
M internal/usecase/daemon/render_coordinator_test.go +2 -2
@@ -1334,7 +1334,7 @@ func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1334 rc.mu.Unlock()1334 rc.mu.Unlock()
1335 }1335 }
1336 1336
1337-func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) {1337+func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) {
1338 rc := newRenderCoordinator(renderCoordinatorOptions{})1338 rc := newRenderCoordinator(renderCoordinatorOptions{})
1339 owner := &attachedClient{}1339 owner := &attachedClient{}
1340 rc.attach(owner)1340 rc.attach(owner)
@@ -1345,7 +1345,7 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing
1345 1345
1346 called := false1346 called := false
1347 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })1347 rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1348- require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")1348+ require.True(t, called, "a nil clock must run the retry without a timer")
1349 rc.mu.Lock()1349 rc.mu.Lock()
1350 retryToken := rc.retryLane.token1350 retryToken := rc.retryLane.token
1351 rc.mu.Unlock()1351 rc.mu.Unlock()
M internal/usecase/daemon/render_coordinator_types.go +7 -3
@@ -80,9 +80,10 @@ type resizeRequestMetadata struct {
80 source *attachedClient80 source *attachedClient
81 // lease binds every delayed resize/retry callback to the exact attachment81 // lease binds every delayed resize/retry callback to the exact attachment
82 // incarnation that published this request.82 // incarnation that published this request.
83- lease *attachmentLease83+ lease *attachmentLease
84- epoch uint64 // latest requested epoch84+ epoch uint64 // latest requested epoch
85- committed uint64 // latest published epoch85+ committed uint64 // latest published epoch
86+ retryAttempts uint8
86 }87 }
87 88
88 // renderCoordinatorOptions wires one coordinator instance.89 // renderCoordinatorOptions wires one coordinator instance.
@@ -105,6 +106,9 @@ type renderCoordinatorOptions struct {
105 // afterSyncGateEvaluated is a package-private deterministic test seam. It106 // afterSyncGateEvaluated is a package-private deterministic test seam. It
106 // runs unlocked after visibility predicates and before registry validation.107 // runs unlocked after visibility predicates and before registry validation.
107 afterSyncGateEvaluated func()108 afterSyncGateEvaluated func()
109+ // onActivateTabAfterResize observes whether activation used a lease-backed
110+ // path; it is nil in production.
111+ onActivateTabAfterResize func(bool)
108 }112 }
109 113
110 // renderCoordinator fans in producer invalidations for one attached session.114 // renderCoordinator fans in producer invalidations for one attached session.
M internal/usecase/daemon/render_pipeline.go +26 -12
@@ -437,17 +437,10 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
437 return false437 return false
438 }438 }
439 }439 }
440- ac.sess.mu.Lock()
441- if ac.sess.v != sess {
442- ac.sess.mu.Unlock()
443- ac.sendMu.Unlock()
444- return false
445- }
446 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)440 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
447 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)441 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
448 endDiff(0, err == nil)442 endDiff(0, err == nil)
449 if err != nil {443 if err != nil {
450- ac.sess.mu.Unlock()
451 ac.sendMu.Unlock()444 ac.sendMu.Unlock()
452 d.log.Error("render draw failed", "err", err, "session", sess.name)445 d.log.Error("render draw failed", "err", err, "session", sess.name)
453 // Without a coordinator reportError repaints synchronously. Suppress only446 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -470,7 +463,8 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
470 var sendTr ports.Transport463 var sendTr ports.Transport
471 var sendErr error464 var sendErr error
472 if len(data) > 0 {465 if len(data) > 0 {
473- sendTr = ac.transport()466+ sendTransport := ac.transportSnapshot()
467+ sendTr = sendTransport.transport
474 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))468 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
475 if sendTr == nil {469 if sendTr == nil {
476 sendErr = errors.New("client transport is nil")470 sendErr = errors.New("client transport is nil")
@@ -479,18 +473,25 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
479 if async, ok := sendTr.(ports.AsyncTransport); ok {473 if async, ok := sendTr.(ports.AsyncTransport); ok {
480 send = async.SendAsync474 send = async.SendAsync
481 }475 }
476+ interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
482 sendErr = prepared.send(data, ac.echoAck.Load(), send)477 sendErr = prepared.send(data, ac.echoAck.Load(), send)
478+ if interruptible {
479+ if sendErr != nil {
480+ marks.roleEffect.reportTransportFailure(sendTransport)
481+ }
482+ marks.roleEffect.endTransportSend()
483+ }
483 }484 }
484 endEmit(uint64(len(data)), sendErr == nil)485 endEmit(uint64(len(data)), sendErr == nil)
485 }486 }
486 if sendErr == nil {487 if sendErr == nil {
487 // Publish only after output preparation and transport emission both488 // Publish only after output preparation and transport emission both
488- // succeed. Receipt acknowledgement follows after releasing the attachment489+ // succeed. A cross-session transition may publish concurrently, but its
489- // session guard, while sendMu still owns this transaction.490+ // mandatory first-paint rebase waits for sendMu and therefore follows this
491+ // completed output transaction.
490 ac.pipelineScratch = ac.pipelineCache492 ac.pipelineScratch = ac.pipelineCache
491 ac.pipelineCache = composed.cache493 ac.pipelineCache = composed.cache
492 }494 }
493- ac.sess.mu.Unlock()
494 if sendErr == nil {495 if sendErr == nil {
495 // A successful no-byte emission also commits: its renderer shadow still496 // A successful no-byte emission also commits: its renderer shadow still
496 // represents the captured frame. Lock panes only under sendMu and with no497 // represents the captured frame. Lock panes only under sendMu and with no
@@ -502,7 +503,20 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
502 }503 }
503 ac.sendMu.Unlock()504 ac.sendMu.Unlock()
504 if sendErr != nil {505 if sendErr != nil {
505- d.detachOnSendError(sess, ac, sendTr)506+ // A transport failure may invalidate the role gate. Release this render's
507+ // admission first. Detachment freezes the gate and therefore cannot mutate
508+ // ownership until any enclosing admitted operation has also ended.
509+ if marks.roleEffect == nil {
510+ d.detachOnSendError(sess, ac, sendTr)
511+ } else {
512+ // Capture the exact admitted capability, including its coordinator
513+ // lease, before End permits a replacement publication. Reserve cleanup
514+ // accounting before End so terminal Wait cannot race a later Add.
515+ token := marks.roleEffect.roleToken()
516+ launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr)
517+ marks.roleEffect.End()
518+ launchCleanup()
519+ }
506 }520 }
507 return true521 return true
508 }522 }
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -511,7 +511,7 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
511 snap := &overlayRenderSnapshot{511 snap := &overlayRenderSnapshot{
512 copySearchModel: visualsearch.New(snapshot),512 copySearchModel: visualsearch.New(snapshot),
513 pickerActive: true,513 pickerActive: true,
514- pickerModel: picker.New(nil, "", 0),514+ pickerModel: picker.New(nil, picker.SelectionConfig{}),
515 noticesOverlayActive: true,515 noticesOverlayActive: true,
516 noticesOverlayModel: notices.New(nil, time.Time{}),516 noticesOverlayModel: notices.New(nil, time.Time{}),
517 paletteActive: true,517 paletteActive: true,
M internal/usecase/daemon/render_resize_deadline.go +55 -14
@@ -1,6 +1,11 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+const maxResizeRetryAttempts = 3
4 9
5 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and10 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
6 // token validation at every callback effect boundary.11 // token validation at every callback effect boundary.
@@ -17,6 +22,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
17 _, retry := c.retryLane.replaceLocked()22 _, retry := c.retryLane.replaceLocked()
18 c.resize.epoch++23 c.resize.epoch++
19 c.resize.size, c.resize.source, c.resize.lease = size, source, lease24 c.resize.size, c.resize.source, c.resize.lease = size, source, lease
25+ c.resize.retryAttempts = 0
20 epoch := c.resize.epoch26 epoch := c.resize.epoch
21 c.mu.Unlock()27 c.mu.Unlock()
22 stopDetachedTimer(retry)28 stopDetachedTimer(retry)
@@ -90,40 +96,61 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached
90 return true96 return true
91 }97 }
92 98
93-func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {99+type resizeRetryReservation struct {
100+ coordinator *renderCoordinator
101+ epoch uint64
102+ source *attachedClient
103+ lease *attachmentLease
104+ run func()
105+ generation uint64
106+ old *timerToken
107+ clock ports.Clock
108+}
109+
110+// reserveResizeRetryForLease publishes the retry-lane generation only. Timer
111+// calls are deferred to finish so owner fences protect no external operation.
112+func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) {
94 c.mu.Lock()113 c.mu.Lock()
95- if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {114+ defer c.mu.Unlock()
96- c.mu.Unlock()115+ if c.torndown || !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch || c.resize.retryAttempts >= maxResizeRetryAttempts {
97- return116+ return nil, false
98 }117 }
118+ c.resize.retryAttempts++
99 gen, old := c.retryLane.replaceLocked()119 gen, old := c.retryLane.replaceLocked()
100- clock := c.opts.clock120+ return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true
101- c.mu.Unlock()121+}
102- stopDetachedTimer(old)122+
103- if clock == nil {123+func (r *resizeRetryReservation) finish() {
124+ if r == nil || r.coordinator == nil {
104 return125 return
105 }126 }
106- timer := clock.NewTimer(minOutputRenderDeadline)127+ c := r.coordinator
128+ stopDetachedTimer(r.old)
129+ if r.clock == nil {
130+ r.run()
131+ return
132+ }
133+ timer := r.clock.NewTimer(minOutputRenderDeadline)
107 timerC := timer.C()134 timerC := timer.C()
108 if timerC == nil {135 if timerC == nil {
109 timer.Stop()136 timer.Stop()
110 return137 return
111 }138 }
112 c.mu.Lock()139 c.mu.Lock()
113- valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source140+ valid := !c.torndown && c.retryLane.generation == r.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source
114 if valid {141 if valid {
115- token := c.retryLane.publishLocked(gen, timer)142+ token := c.retryLane.publishLocked(r.generation, timer)
116 valid = token != nil143 valid = token != nil
117 if valid {144 if valid {
118 c.supervisor.startLocked(token, timerC, func() {145 c.supervisor.startLocked(token, timerC, func() {
119 c.mu.Lock()146 c.mu.Lock()
120- valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source147+ valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source
121 if valid {148 if valid {
122 c.retryLane.clearLocked(token)149 c.retryLane.clearLocked(token)
123 }150 }
124 c.mu.Unlock()151 c.mu.Unlock()
125 if valid {152 if valid {
126- run()153+ r.run()
127 }154 }
128 })155 })
129 }156 }
@@ -134,6 +161,20 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
134 }161 }
135 }162 }
136 163
164+func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {
165+ reservation, ok := c.reserveResizeRetryForLease(epoch, source, lease, run)
166+ if !ok {
167+ return
168+ }
169+ reservation.finish()
170+}
171+
172+func (c *renderCoordinator) resizeRetryAvailableForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
173+ c.mu.Lock()
174+ defer c.mu.Unlock()
175+ return !c.torndown && c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.retryAttempts < maxResizeRetryAttempts
176+}
177+
137 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {178 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
138 c.mu.Lock()179 c.mu.Lock()
139 defer c.mu.Unlock()180 defer c.mu.Unlock()
A internal/usecase/daemon/resize_publication.go +108 -0
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+type resizeCommitPublication struct {
9+ members []resizeMember
10+ session *session
11+ attachment *attachedClient
12+ lease *attachmentLease
13+ epoch uint64
14+ role attachmentRoleToken
15+ coordinator *renderCoordinator
16+ observer ports.RuntimeObserver
17+ mark ports.RuntimeMark
18+}
19+
20+func (p resizeCommitPublication) current() bool {
21+ if !resizeMembersOwnerCurrent(p.members) {
22+ return false
23+ }
24+ if p.attachment == nil {
25+ return true
26+ }
27+ // Attached resize publication is admitted only through a live role ticket.
28+ if p.role.effect == nil {
29+ return false
30+ }
31+ if p.role.effect.ended.Load() || p.role.sess != p.session ||
32+ p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
33+ p.role.generation != p.attachment.roleGeneration.Load() ||
34+ !p.attachment.transportSnapshotCurrent(p.role.transport) ||
35+ p.attachment.currentSession() != p.session || p.coordinator == nil {
36+ return false
37+ }
38+ p.session.mu.Lock()
39+ active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
40+ p.session.mu.Unlock()
41+ return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
42+}
43+
44+// emit performs observer work only after every resize-owner fence and
45+// attachment sendMu are released. The generation-bound publication is checked
46+// once more so a move that wins after geometry publication but before observer
47+// admission suppresses stale source telemetry.
48+func (p resizeCommitPublication) emit() bool {
49+ if !p.current() {
50+ return false
51+ }
52+ if p.observer != nil {
53+ p.observer.ObserveRuntime(p.mark)
54+ }
55+ return true
56+}
57+
58+// publishResizeCommit linearizes attachment geometry and an immutable telemetry
59+// reservation with pane ownership. sendMu is acquired before resize fences, so
60+// the canonical rule never acquires an attachment lock while a pane fence is
61+// held. Observer callbacks run only after both lock families are released.
62+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
63+ if ac != nil && ticket == nil {
64+ return false
65+ }
66+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
67+ if ac != nil {
68+ ac.sendMu.Lock()
69+ if d.afterResizeCommitSendLocked != nil {
70+ d.afterResizeCommitSendLocked()
71+ }
72+ }
73+ fences := acquireResizeOwnerPostEffectFences(members)
74+ if fences == nil {
75+ if ac != nil {
76+ ac.sendMu.Unlock()
77+ }
78+ return false
79+ }
80+ publication := resizeCommitPublication{
81+ members: append([]resizeMember(nil), members...),
82+ session: sess,
83+ attachment: ac,
84+ lease: lease,
85+ epoch: epoch,
86+ observer: d.runtimeObserver,
87+ mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
88+ }
89+ if ticket != nil {
90+ publication.role = ticket.roleToken()
91+ publication.coordinator = sess.renderCoordinator()
92+ }
93+ if !publication.current() {
94+ fences.Release()
95+ if ac != nil {
96+ ac.sendMu.Unlock()
97+ }
98+ return false
99+ }
100+ if ac != nil {
101+ ac.size = size
102+ }
103+ fences.Release()
104+ if ac != nil {
105+ ac.sendMu.Unlock()
106+ }
107+ return publication.emit()
108+}
A internal/usecase/daemon/resize_retry_token.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
4+// owner generations which accepted the failure. A moved pane cannot lend its
5+// old source worker authority over either the PTY or source publications.
6+type acceptedTabLayoutRetryToken struct {
7+ session *session
8+ tab *tab
9+ members []resizeMember
10+}
11+
12+func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
13+ return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
14+}
15+
16+func (t acceptedTabLayoutRetryToken) current() bool {
17+ if t.session == nil || t.tab == nil || len(t.members) == 0 {
18+ return false
19+ }
20+ for i := range t.members {
21+ member := &t.members[i]
22+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) {
23+ return false
24+ }
25+ }
26+ return true
27+}
M internal/usecase/daemon/resume.go +121 -17
@@ -3,11 +3,14 @@ package daemon
3 import (3 import (
4 "crypto/rand"4 "crypto/rand"
5 "encoding/binary"5 "encoding/binary"
6+ "errors"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 )10 )
10 11
12+var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
13+
11 func newResumeToken() uint64 {14 func newResumeToken() uint64 {
12 var b [8]byte15 var b [8]byte
13 if _, err := rand.Read(b[:]); err != nil {16 if _, err := rand.Read(b[:]); err != nil {
@@ -45,6 +48,10 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
45 }48 }
46 49
47 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {50 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
51+ return d.parkAttachmentAs(sess, ac, attachmentActive)
52+}
53+
54+func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attachmentRole) bool {
48 if !d.prepareParkAttachment(sess, ac) {55 if !d.prepareParkAttachment(sess, ac) {
49 return false56 return false
50 }57 }
@@ -54,21 +61,45 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
54 // remain outside d.mu: sendMu is ordered before the daemon lock.61 // remain outside d.mu: sendMu is ordered before the daemon lock.
55 ac.clearCaptureFrames()62 ac.clearCaptureFrames()
56 d.mu.Lock()63 d.mu.Lock()
57- if d.closing {64+ if d.closing || d.sessions[sess.id] != sess {
58 d.mu.Unlock()65 d.mu.Unlock()
59 return false66 return false
60 }67 }
68+ if role == attachmentActive {
69+ sess.mu.Lock()
70+ if sess.client != nil {
71+ role = attachmentSnatched
72+ }
73+ sess.mu.Unlock()
74+ }
61 token := ac.resumeToken75 token := ac.resumeToken
76+ var (
77+ oldRetirement *parkedAttachmentRetirement
78+ oldSame *parkedAttachment
79+ )
62 if old := d.parked[token]; old != nil {80 if old := d.parked[token]; old != nil {
63- old.timer.Stop()81+ if old.ac == ac {
64- old.closeDone()82+ oldSame = old
83+ } else {
84+ retirement := d.retireParkedAttachmentLocked(token, old)
85+ oldRetirement = &retirement
86+ }
65 }87 }
66 grace := d.resumeParkGrace88 grace := d.resumeParkGrace
67 timer := d.clock.NewTimer(grace)89 timer := d.clock.NewTimer(grace)
68- parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})}90+ parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})}
69 ac.parked = true91 ac.parked = true
70 d.parked[token] = parked92 d.parked[token] = parked
71 d.mu.Unlock()93 d.mu.Unlock()
94+ if oldRetirement != nil {
95+ finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
96+ }
97+ if oldSame != nil {
98+ if oldSame.timer != nil {
99+ oldSame.timer.Stop()
100+ }
101+ oldSame.closeDone()
102+ }
72 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)103 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
73 104
74 go func(token uint64, parked *parkedAttachment) {105 go func(token uint64, parked *parkedAttachment) {
@@ -112,15 +143,66 @@ func (p *parkedAttachment) closeDone() {
112 p.doneOnce.Do(func() { close(p.done) })143 p.doneOnce.Do(func() { close(p.done) })
113 }144 }
114 145
115-// purgeParkedForSessionLocked invalidates every parked token for sess. Caller146+// demoteParkedActiveForSessionLocked preserves resumable predecessors when a
116-// holds d.mu. Use before killing or normally attaching to a session so stale147+// new active owner is published. Caller holds d.mu.
117-// resume tokens cannot resurrect or replace an active attachment.148+func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
118-func (d *Daemon) purgeParkedForSessionLocked(sess *session) {149+ for _, parked := range d.parked {
150+ if parked.sess == sess && parked.role == attachmentActive {
151+ parked.role = attachmentSnatched
152+ }
153+ }
154+}
155+
156+type parkedAttachmentRetirement struct {
157+ parked *parkedAttachment
158+ transport transportSnapshot
159+}
160+
161+func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
162+ delete(d.parked, token)
163+ parked.ac.clearPreviousSession()
164+ parked.ac.resumeToken = 0
165+ parked.ac.parked = false
166+ parked.ac.roleGeneration.Add(1)
167+ parked.ac.setSession(nil)
168+ return parkedAttachmentRetirement{
169+ parked: parked,
170+ transport: parked.ac.transportSnapshot(),
171+ }
172+}
173+
174+// purgeParkedForSessionLocked invalidates every parked token for sess and
175+// returns the external resources that must be retired after releasing d.mu.
176+// It is reserved for terminal session kill and daemon shutdown.
177+func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
178+ var retirements []parkedAttachmentRetirement
119 for token, parked := range d.parked {179 for token, parked := range d.parked {
120 if parked.sess == sess {180 if parked.sess == sess {
121- d.removeParkedLocked(token, parked)181+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
122 }182 }
123 }183 }
184+ return retirements
185+}
186+
187+func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
188+ retirements := make([]parkedAttachmentRetirement, 0, len(d.parked))
189+ for token, parked := range d.parked {
190+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
191+ }
192+ return retirements
193+}
194+
195+// finishParkedAttachmentRetirements stops timers and closes transports without
196+// holding daemon, session, coordinator, or pane locks.
197+func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
198+ for _, retirement := range retirements {
199+ if retirement.parked.timer != nil {
200+ retirement.parked.timer.Stop()
201+ }
202+ retirement.parked.closeDone()
203+ ac := retirement.parked.ac
204+ _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
205+ }
124 }206 }
125 207
126 // resumeParked acquires the parked client's output lock before the daemon208 // resumeParked acquires the parked client's output lock before the daemon
@@ -144,7 +226,10 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
144 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}226 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
145 }227 }
146 if d.parked[h.ResumeToken] != parked {228 if d.parked[h.ResumeToken] != parked {
147- return nil, nil, false, nil229+ if d.sessions[parked.sess.id] == parked.sess {
230+ return nil, nil, false, errResumeTokenLifecycleRace
231+ }
232+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
148 }233 }
149 return d.resumeParkedLocked(h, tr, sz)234 return d.resumeParkedLocked(h, tr, sz)
150 }235 }
@@ -158,13 +243,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
158 }243 }
159 sess := parked.sess244 sess := parked.sess
160 registered := d.sessions[sess.id] == sess245 registered := d.sessions[sess.id] == sess
246+ if !registered {
247+ d.removeParkedLocked(h.ResumeToken, parked)
248+ d.log.Warn("resume rejected", "session", sess.name, "registered", false)
249+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
250+ }
161 sess.mu.Lock()251 sess.mu.Lock()
162 active := sess.client != nil252 active := sess.client != nil
163 sess.mu.Unlock()253 sess.mu.Unlock()
164- if !registered || active {254+ if active && parked.role == attachmentActive {
165- d.removeParkedLocked(h.ResumeToken, parked)255+ parked.role = attachmentSnatched
166- d.log.Warn("resume rejected", "session", sess.name, "registered", registered, "active", active)
167- return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
168 }256 }
169 ac := parked.ac257 ac := parked.ac
170 if ac.clientID != h.ClientID {258 if ac.clientID != h.ClientID {
@@ -183,15 +271,31 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
183 ac.size = sz271 ac.size = sz
184 ac.resumeToken = d.nextResumeTokenLocked()272 ac.resumeToken = d.nextResumeTokenLocked()
185 ac.parked = false273 ac.parked = false
186- ac.setSession(sess)
187 // The resumed session's snapshot is the sole source for future PTY children.274 // The resumed session's snapshot is the sole source for future PTY children.
188 // Existing PTYs retain the environment they were started with.275 // Existing PTYs retain the environment they were started with.
189 sess.mu.Lock()276 sess.mu.Lock()
190 sess.env = copyEnvironment(h.Env)277 sess.env = copyEnvironment(h.Env)
191 sess.terminal = terminalEnv{TrueColor: h.TrueColor}278 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
192- sess.client = ac
193 sess.mu.Unlock()279 sess.mu.Unlock()
194- d.attachCoordinator(sess, nil, ac, false)280+ // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
281+ // neither held. Reacquire them before returning to preserve this helper's
282+ // locked-caller contract.
283+ d.mu.Unlock()
284+ ac.sendMu.Unlock()
285+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
286+ target: sess,
287+ next: ac,
288+ expectedRole: attachmentDetached,
289+ targetRole: parked.role,
290+ expectedTransport: ac.transportSnapshot(),
291+ ready: false,
292+ })
293+ ac.sendMu.Lock()
294+ d.mu.Lock()
295+ if err != nil {
296+ return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
297+ }
298+ d.deferAttachmentTransitionCleanups(transition)
195 d.touchMRU(sess)299 d.touchMRU(sess)
196 d.log.Info("client resumed", "session", sess.name)300 d.log.Info("client resumed", "session", sess.name)
197 return sess, ac, true, nil301 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +68 -10
@@ -51,6 +51,40 @@ func (t *closeTrackingTransport) Sends() []ports.Frame {
51 return append([]ports.Frame(nil), t.sends...)51 return append([]ports.Frame(nil), t.sends...)
52 }52 }
53 53
54+type closeCountingBlockedTransport struct {
55+ closeOnce sync.Once
56+ closed chan struct{}
57+ count int
58+ mu sync.Mutex
59+}
60+
61+func newCloseCountingBlockedTransport() *closeCountingBlockedTransport {
62+ return &closeCountingBlockedTransport{closed: make(chan struct{})}
63+}
64+
65+func (t *closeCountingBlockedTransport) Send(ports.Frame) error {
66+ <-t.closed
67+ return errors.New("closed")
68+}
69+
70+func (t *closeCountingBlockedTransport) Recv() (ports.Frame, error) {
71+ return ports.Frame{}, errors.New("closed")
72+}
73+
74+func (t *closeCountingBlockedTransport) Close() error {
75+ t.mu.Lock()
76+ t.count++
77+ t.mu.Unlock()
78+ t.closeOnce.Do(func() { close(t.closed) })
79+ return nil
80+}
81+
82+func (t *closeCountingBlockedTransport) CloseCount() int {
83+ t.mu.Lock()
84+ defer t.mu.Unlock()
85+ return t.count
86+}
87+
54 func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {88 func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
55 return ports.Hello{89 return ports.Hello{
56 Version: ports.ProtocolVersion,90 Version: ports.ProtocolVersion,
@@ -139,6 +173,24 @@ func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) {
139 require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport")173 require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport")
140 }174 }
141 175
176+func TestDetachedNotificationTimeoutClosesCapturedTransportOnce(t *testing.T) {
177+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
178+ d := newTestDaemon(t, nil, clock)
179+ tr := newCloseCountingBlockedTransport()
180+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
181+
182+ d.notifyDetachedSnapshotAsync(detachedAttachmentSnapshot{
183+ ac: ac,
184+ transport: ac.transportSnapshot(),
185+ }, ports.ReasonDetach)
186+ timer := <-clock.timers
187+ timer.ch <- time.Time{}
188+ d.waitNotifies()
189+
190+ require.Equal(t, 1, tr.CloseCount(), "timeout revocation must close the captured transport at most once")
191+ require.Nil(t, ac.transport(), "timeout cleanup must revoke the captured transport")
192+}
193+
142 func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {194 func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
143 pty, releasePTY := newBlockingPTY(t)195 pty, releasePTY := newBlockingPTY(t)
144 defer releasePTY()196 defer releasePTY()
@@ -308,6 +360,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
308 token := ac.resumeToken360 token := ac.resumeToken
309 require.True(t, sess.detachIfCurrent(ac))361 require.True(t, sess.detachIfCurrent(ac))
310 require.True(t, d.parkAttachment(sess, ac))362 require.True(t, d.parkAttachment(sess, ac))
363+ generation := ac.roleGeneration.Load()
311 364
312 newTr := &closeTrackingTransport{}365 newTr := &closeTrackingTransport{}
313 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})366 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
@@ -315,6 +368,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
315 require.True(t, ok)368 require.True(t, ok)
316 require.Same(t, sess, resumedSess)369 require.Same(t, sess, resumedSess)
317 require.Same(t, ac, resumedAC)370 require.Same(t, ac, resumedAC)
371+ require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
318 372
319 _ = ac.closeCapturedTransport(oldTr)373 _ = ac.closeCapturedTransport(oldTr)
320 require.True(t, oldTr.Closed(), "old transport is closed")374 require.True(t, oldTr.Closed(), "old transport is closed")
@@ -518,18 +572,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
518 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {572 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
519 for _, tc := range []struct {573 for _, tc := range []struct {
520 name string574 name string
521- discard func(*Daemon, uint64, *parkedAttachment)575+ discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
522 }{576 }{
523 {577 {
524 name: "expiry",578 name: "expiry",
525- discard: func(d *Daemon, token uint64, parked *parkedAttachment) {579+ discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
526 d.removeParkedLocked(token, parked)580 d.removeParkedLocked(token, parked)
581+ return nil
527 },582 },
528 },583 },
529 {584 {
530 name: "session purge",585 name: "session purge",
531- discard: func(d *Daemon, _ uint64, parked *parkedAttachment) {586+ discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
532- d.purgeParkedForSessionLocked(parked.sess)587+ return d.purgeParkedForSessionLocked(parked.sess)
533 },588 },
534 },589 },
535 } {590 } {
@@ -547,8 +602,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
547 d.mu.Lock()602 d.mu.Lock()
548 parked := d.parked[ac.resumeToken]603 parked := d.parked[ac.resumeToken]
549 require.NotNil(t, parked)604 require.NotNil(t, parked)
550- tc.discard(d, ac.resumeToken, parked)605+ retirements := tc.discard(d, ac.resumeToken, parked)
551 d.mu.Unlock()606 d.mu.Unlock()
607+ finishParkedAttachmentRetirements(retirements)
552 608
553 require.Nil(t, ac.previousSession.Get())609 require.Nil(t, ac.previousSession.Get())
554 })610 })
@@ -604,7 +660,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
604 require.False(t, ok, "killed session cannot be resumed")660 require.False(t, ok, "killed session cannot be resumed")
605 }661 }
606 662
607-func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {663+func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {
608 pty, release := newBlockingPTY(t)664 pty, release := newBlockingPTY(t)
609 defer release()665 defer release()
610 d := newTestDaemon(t, newFactory(t, pty), stubClock{})666 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -620,13 +676,15 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
620 require.NotSame(t, oldAC, activeAC)676 require.NotSame(t, oldAC, activeAC)
621 677
622 d.mu.Lock()678 d.mu.Lock()
623- _, parked := d.parked[token]679+ parked := d.parked[token]
624 d.mu.Unlock()680 d.mu.Unlock()
681+ require.NotNil(t, parked, "normal attach preserves the parked predecessor")
682+ require.Equal(t, attachmentSnatched, parked.role)
625 _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})683 _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
626- require.False(t, parked, "normal attach invalidates stale parked token")
627 require.NoError(t, err)684 require.NoError(t, err)
628- require.False(t, ok)685+ require.True(t, ok)
629- require.Nil(t, resumedAC)686+ require.Same(t, oldAC, resumedAC)
687+ require.Equal(t, attachmentSnatched, sess.attachmentRole(resumedAC))
630 require.Same(t, activeAC, sess.client)688 require.Same(t, activeAC, sess.client)
631 }689 }
632 690
A internal/usecase/daemon/role_aware_parking_test.go +482 -0
@@ -0,0 +1,482 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/pkg/renderer"
14+)
15+
16+func TestActiveLinkLossParksActiveAndDetachesCoordinator(t *testing.T) {
17+ pty, release := newBlockingPTY(t)
18+ defer release()
19+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
20+
21+ transport := &closeTrackingTransport{}
22+ sess, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
23+ require.NoError(t, err)
24+ token := active.resumeToken
25+ rc := sess.renderCoordinator()
26+ require.NotNil(t, rc.attachmentLease(active))
27+
28+ d.clientGone(sess, active, transport, false)
29+
30+ d.mu.Lock()
31+ parked := d.parked[token]
32+ d.mu.Unlock()
33+ require.NotNil(t, parked)
34+ require.Equal(t, attachmentActive, parked.role)
35+ require.Nil(t, sess.client)
36+ require.Nil(t, rc.attachmentLease(active), "active loss should detach coordinator ownership")
37+}
38+
39+func TestSnatchedLinkLossParksWithoutDisturbingActiveOwner(t *testing.T) {
40+ pty, release := newBlockingPTY(t)
41+ defer release()
42+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
43+
44+ oldTransport := &closeTrackingTransport{}
45+ sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
46+ require.NoError(t, err)
47+ oldToken := old.resumeToken
48+
49+ activeTransport := &closeTrackingTransport{}
50+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
51+ require.NoError(t, err)
52+ d.attachmentCleanupWg.Wait()
53+ lease := sess.renderCoordinator().attachmentLease(active)
54+ require.NotNil(t, lease)
55+
56+ d.runConnLoop(old)
57+
58+ d.mu.Lock()
59+ parked := d.parked[oldToken]
60+ d.mu.Unlock()
61+ require.NotNil(t, parked, "resumable snatched attachment should survive link loss")
62+ require.Equal(t, attachmentSnatched, parked.role)
63+ require.Same(t, active, sess.client)
64+ require.Same(t, lease, sess.renderCoordinator().attachmentLease(active))
65+ require.False(t, activeTransport.Closed())
66+}
67+
68+type sendErrorTransport struct {
69+ closeTrackingTransport
70+}
71+
72+func (*sendErrorTransport) Send(ports.Frame) error { return errors.New("send failed") }
73+
74+func TestSnatchedPanelSendFailureParksResumableAttachment(t *testing.T) {
75+ pty, release := newBlockingPTY(t)
76+ defer release()
77+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
78+
79+ failed := &sendErrorTransport{}
80+ sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), failed)
81+ require.NoError(t, err)
82+ token := waiting.resumeToken
83+
84+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
85+ require.NoError(t, err)
86+ d.attachmentCleanupWg.Wait()
87+
88+ d.mu.Lock()
89+ parked := d.parked[token]
90+ d.mu.Unlock()
91+ require.NotNil(t, parked, "failed snatched panel should park a resumable attachment")
92+ require.Equal(t, attachmentSnatched, parked.role)
93+ require.Nil(t, waiting.transport(), "parking should revoke the exact failed transport")
94+ require.True(t, failed.Closed())
95+ require.Same(t, active, sess.client)
96+}
97+
98+func TestStaleSnatchedTransportCannotParkFreshIncarnation(t *testing.T) {
99+ pty, release := newBlockingPTY(t)
100+ defer release()
101+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
102+
103+ oldTransport := &closeTrackingTransport{}
104+ sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
105+ require.NoError(t, err)
106+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
107+ require.NoError(t, err)
108+ d.attachmentCleanupWg.Wait()
109+ stale := sess.attachmentToken(waiting, oldTransport)
110+ fresh := &closeTrackingTransport{}
111+ waiting.replaceTransport(fresh)
112+
113+ require.False(t, d.parkOrDropSnatchedAttachment(stale))
114+ d.mu.Lock()
115+ _, parked := d.parked[waiting.resumeToken]
116+ d.mu.Unlock()
117+ require.False(t, parked)
118+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
119+ require.Same(t, fresh, waiting.transport())
120+ require.False(t, fresh.Closed())
121+ require.Same(t, active, sess.client)
122+}
123+
124+func TestSnatchedParkExpiryDoesNotAffectActiveOwner(t *testing.T) {
125+ pty, release := newBlockingPTY(t)
126+ defer release()
127+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
128+
129+ waitingTransport := &closeTrackingTransport{}
130+ sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), waitingTransport)
131+ require.NoError(t, err)
132+ token := waiting.resumeToken
133+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
134+ require.NoError(t, err)
135+ d.attachmentCleanupWg.Wait()
136+ lease := sess.renderCoordinator().attachmentLease(active)
137+
138+ d.runConnLoop(waiting)
139+ d.mu.Lock()
140+ parked := d.parked[token]
141+ d.mu.Unlock()
142+ require.NotNil(t, parked)
143+
144+ d.expireParked(token, parked)
145+
146+ d.mu.Lock()
147+ _, retained := d.parked[token]
148+ d.mu.Unlock()
149+ require.False(t, retained)
150+ require.Same(t, active, sess.client)
151+ require.Same(t, lease, sess.renderCoordinator().attachmentLease(active))
152+}
153+
154+func TestNonzeroResumeTokenOverridesOrdinaryRoutingFallbacks(t *testing.T) {
155+ pty, release := newBlockingPTY(t)
156+ defer release()
157+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
158+
159+ work, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), &closeTrackingTransport{})
160+ require.NoError(t, err)
161+ const unknownToken = uint64(0xdecafbad)
162+ for _, tc := range []struct {
163+ name string
164+ intent uint8
165+ target string
166+ }{
167+ {name: "attach existing", intent: ports.IntentAttach, target: "work"},
168+ {name: "create unique", intent: ports.IntentNew, target: "fallback"},
169+ {name: "create ephemeral", intent: ports.IntentEphemeral},
170+ } {
171+ t.Run(tc.name, func(t *testing.T) {
172+ h := helloResumeCapable(tc.intent, tc.target, unknownToken)
173+ routedSession, routedClient, routeErr := d.route(h, &closeTrackingTransport{})
174+ require.Error(t, routeErr)
175+ require.Nil(t, routedSession)
176+ require.Nil(t, routedClient)
177+ var protocolErr *protoErr
178+ require.ErrorAs(t, routeErr, &protocolErr)
179+ require.Equal(t, ports.ErrNoSuchSession, protocolErr.code)
180+ require.Same(t, active, work.client)
181+ d.mu.Lock()
182+ require.Len(t, d.sessions, 1)
183+ require.Nil(t, d.findByNameLocked("fallback"))
184+ d.mu.Unlock()
185+ })
186+ }
187+
188+ _, replacement, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
189+ require.NoError(t, err, "a zero token keeps ordinary attach semantics")
190+ require.NotSame(t, active, replacement)
191+ require.Same(t, replacement, work.client)
192+ require.Equal(t, attachmentSnatched, work.attachmentRole(active))
193+}
194+
195+func TestHandleHelloExpiredSnatchedResumeFailsClosedAfterSessionEnds(t *testing.T) {
196+ workPTY, releaseWork := newBlockingPTY(t)
197+ keeperPTY, releaseKeeper := newBlockingPTY(t)
198+ fallbackPTY, releaseFallback := newBlockingPTY(t)
199+ defer releaseWork()
200+ defer releaseKeeper()
201+ defer releaseFallback()
202+ d := newTestDaemon(t, newFactorySeq(t, workPTY, keeperPTY, fallbackPTY), stubClock{})
203+
204+ oldTransport := &closeTrackingTransport{}
205+ work, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
206+ require.NoError(t, err)
207+ oldToken := old.resumeToken
208+
209+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
210+ require.NoError(t, err)
211+ d.attachmentCleanupWg.Wait()
212+ require.Equal(t, attachmentSnatched, work.attachmentRole(old))
213+ require.Same(t, active, work.client)
214+
215+ d.runConnLoop(old)
216+ d.mu.Lock()
217+ parked := d.parked[oldToken]
218+ d.mu.Unlock()
219+ require.NotNil(t, parked)
220+ require.Equal(t, attachmentSnatched, parked.role)
221+
222+ keeper, _, err := d.route(helloResumeCapable(ports.IntentNew, "keeper", 0), &closeTrackingTransport{})
223+ require.NoError(t, err)
224+ require.NoError(t, d.killSession(work, ports.ReasonSessionKilled, false))
225+
226+ d.mu.Lock()
227+ _, tokenRetained := d.parked[oldToken]
228+ stoppedWork, lifecycleRetained := d.stopped["work"]
229+ d.mu.Unlock()
230+ require.False(t, tokenRetained, "terminal lifecycle teardown must purge the parked token")
231+ require.True(t, lifecycleRetained, "the stopped lifecycle makes attach fallback observable")
232+
233+ reconnect := &closeTrackingTransport{}
234+ d.handleHello(reconnect, ports.Frame{
235+ Type: ports.MsgHello,
236+ Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken)),
237+ })
238+
239+ frames := reconnect.Sends()
240+ require.Len(t, frames, 1)
241+ require.Equal(t, ports.MsgError, frames[0].Type)
242+ wireErr, err := ports.UnmarshalErrorMsg(frames[0].Payload)
243+ require.NoError(t, err)
244+ require.Equal(t, ports.ErrNoSuchSession, wireErr.Code)
245+ require.True(t, reconnect.Closed())
246+ d.mu.Lock()
247+ require.Same(t, keeper, d.findByNameLocked("keeper"))
248+ require.Nil(t, d.findByNameLocked("work"), "expired resume must not recreate the stopped session")
249+ require.Equal(t, stoppedWork, d.stopped["work"])
250+ require.Len(t, d.sessions, 1)
251+ d.mu.Unlock()
252+ require.Nil(t, work.client, "expired resume must not reclaim the ended lifecycle")
253+ require.Nil(t, active.currentSession())
254+}
255+
256+func TestSnatchedResumeHandshakeSendsWelcomeAndPanelWithoutReplacingOwner(t *testing.T) {
257+ pty, release := newBlockingPTY(t)
258+ defer release()
259+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
260+
261+ oldTransport := &closeTrackingTransport{}
262+ sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
263+ require.NoError(t, err)
264+ token := old.resumeToken
265+ d.clientGone(sess, old, oldTransport, false)
266+
267+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
268+ require.NoError(t, err)
269+
270+ resumedTransport := &closeTrackingTransport{}
271+ hello := helloResumeCapable(ports.IntentResume, "work", token)
272+ d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
273+
274+ frames := resumedTransport.Sends()
275+ require.GreaterOrEqual(t, len(frames), 2)
276+ require.Equal(t, ports.MsgWelcome, frames[0].Type)
277+ welcome, err := ports.UnmarshalWelcome(frames[0].Payload)
278+ require.NoError(t, err)
279+ newToken := welcome.ResumeToken
280+ require.NotZero(t, newToken)
281+ require.NotEqual(t, token, newToken, "successful snatched resume rotates the credential")
282+ require.Equal(t, newToken, old.resumeToken, "the Welcome publishes the one rotated credential")
283+ require.Equal(t, ports.MsgOutput, frames[1].Type)
284+ output, err := ports.UnmarshalOutput(frames[1].Payload)
285+ require.NoError(t, err)
286+ require.Contains(t, string(output.Data), "Session snatched")
287+ d.mu.Lock()
288+ _, oldTokenParked := d.parked[token]
289+ parked := d.parked[newToken]
290+ d.mu.Unlock()
291+ require.False(t, oldTokenParked, "the consumed credential cannot be reused")
292+ require.NotNil(t, parked)
293+ require.Equal(t, attachmentSnatched, parked.role, "the rotated credential remains bound to the snatched role")
294+ require.Same(t, active, sess.client)
295+
296+ replay := &closeTrackingTransport{}
297+ d.handleHello(replay, ports.Frame{
298+ Type: ports.MsgHello,
299+ Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token)),
300+ })
301+ replayFrames := replay.Sends()
302+ require.Len(t, replayFrames, 1)
303+ require.Equal(t, ports.MsgError, replayFrames[0].Type)
304+ replayErr, err := ports.UnmarshalErrorMsg(replayFrames[0].Payload)
305+ require.NoError(t, err)
306+ require.Equal(t, ports.ErrNoSuchSession, replayErr.Code)
307+ require.True(t, replay.Closed())
308+ require.Same(t, active, sess.client, "replaying the old credential must not replace active ownership")
309+ d.mu.Lock()
310+ require.Same(t, parked, d.parked[newToken])
311+ d.mu.Unlock()
312+}
313+
314+func TestResumeWelcomeRechecksRoleAfterReplacement(t *testing.T) {
315+ pty, release := newBlockingPTY(t)
316+ defer release()
317+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
318+
319+ oldTransport := &closeTrackingTransport{}
320+ sess, resumed, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
321+ require.NoError(t, err)
322+ token := resumed.resumeToken
323+ d.clientGone(sess, resumed, oldTransport, false)
324+
325+ blocked := newWelcomeBlockingTransport(t)
326+ handshakeDone := make(chan struct{})
327+ go func() {
328+ hello := helloResumeCapable(ports.IntentResume, "work", token)
329+ d.handleHello(blocked.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
330+ close(handshakeDone)
331+ }()
332+ awaitTestCompletion(t, blocked.welcomeEntered, "resume did not start Welcome")
333+ welcome := awaitFrame(t, blocked.sends, ports.MsgWelcome)
334+ require.Equal(t, ports.MsgWelcome, welcome.Type)
335+ requireNoCoordinatorOutputFrame(t, blocked.sends)
336+ resumedLease := sess.renderCoordinator().attachmentLease(resumed)
337+ require.NotNil(t, resumedLease)
338+ require.False(t, resumedLease.ready, "blocked Welcome must keep the resumed lease unready")
339+
340+ resumeGateFrozen := make(chan struct{})
341+ var frozenOnce sync.Once
342+ d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) {
343+ if ac == resumed {
344+ frozenOnce.Do(func() { close(resumeGateFrozen) })
345+ }
346+ }
347+ type attachResult struct {
348+ ac *attachedClient
349+ err error
350+ }
351+ replacementResult := make(chan attachResult, 1)
352+ go func() {
353+ _, replacement, routeErr := d.route(
354+ helloResumeCapable(ports.IntentAttach, "work", 0),
355+ &closeTrackingTransport{},
356+ )
357+ replacementResult <- attachResult{ac: replacement, err: routeErr}
358+ }()
359+ awaitTestCompletion(t, resumeGateFrozen, "replacement did not freeze the resumed Welcome role")
360+
361+ blocked.release()
362+ replacement := awaitTestValue(t, replacementResult, "replacement did not finish after Welcome")
363+ require.NoError(t, replacement.err)
364+ d.attachmentCleanupWg.Wait()
365+ panelFrame := awaitFrame(t, blocked.sends, ports.MsgOutput)
366+ panel, err := ports.UnmarshalOutput(panelFrame.Payload)
367+ require.NoError(t, err)
368+ require.Zero(t, panel.BaseStateNum, "post-Welcome snatched panel must reset output state")
369+ require.Contains(t, string(panel.Data), "Session snatched")
370+ requireNoCoordinatorOutputFrame(t, blocked.sends)
371+
372+ require.Same(t, replacement.ac, sess.client)
373+ require.Equal(t, attachmentSnatched, sess.attachmentRole(resumed))
374+ rc := sess.renderCoordinator()
375+ rc.mu.Lock()
376+ require.False(t, resumedLease.ready, "replacement during Welcome must not ready the stale active lease")
377+ rc.mu.Unlock()
378+ require.Nil(t, rc.attachmentLease(resumed))
379+ require.NotNil(t, rc.attachmentLease(replacement.ac))
380+
381+ blocked.finish()
382+ awaitTestCompletion(t, handshakeDone, "resumed snatched connection did not continue after replacement")
383+}
384+
385+func TestSnatchedResumePreservesOwnerCapabilityAndCanReclaim(t *testing.T) {
386+ pty, release := newBlockingPTY(t)
387+ defer release()
388+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
389+
390+ oldTransport := &closeTrackingTransport{}
391+ sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
392+ require.NoError(t, err)
393+ token := waiting.resumeToken
394+ waiting.setClientTheme(themeui.Theme{
395+ Foreground: renderer.RGB{R: 90, G: 10, B: 10},
396+ Background: renderer.RGB{R: 20, G: 1, B: 1},
397+ HasFG: true, HasBG: true, Known: true,
398+ })
399+ d.clientGone(sess, waiting, oldTransport, false)
400+
401+ activeTransport := &closeTrackingTransport{}
402+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
403+ require.NoError(t, err)
404+ rc := sess.renderCoordinator()
405+ activeGeneration := active.roleGeneration.Load()
406+ activeLease := rc.attachmentLease(active)
407+ require.NotNil(t, activeLease)
408+ activeTheme := themeui.Theme{
409+ Foreground: renderer.RGB{R: 10, G: 80, B: 20},
410+ Background: renderer.RGB{R: 1, G: 15, B: 3},
411+ HasFG: true, HasBG: true, Known: true,
412+ }
413+ active.setClientTheme(activeTheme)
414+ require.True(t, d.applyHostTheme(sess, active, activeTheme, false))
415+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
416+
417+ resumedTransport := &closeTrackingTransport{}
418+ resumedSess, resumed, ok, err := d.resumeParked(
419+ helloResumeCapable(ports.IntentResume, "work", token),
420+ resumedTransport,
421+ domain.Size{Cols: 80, Rows: 24},
422+ )
423+ require.NoError(t, err)
424+ require.True(t, ok)
425+ require.Same(t, sess, resumedSess)
426+ require.Same(t, waiting, resumed)
427+ require.Same(t, active, sess.client)
428+ require.Equal(t, activeGeneration, active.roleGeneration.Load())
429+ require.Same(t, activeLease, rc.attachmentLease(active))
430+ require.Nil(t, rc.attachmentLease(waiting), "snatched resume must not acquire a render lease")
431+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
432+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
433+
434+ resumedGeneration := waiting.roleGeneration.Load()
435+ require.False(t, d.handleSnatchedClientFrame(sess.attachmentToken(waiting, resumedTransport), ports.Frame{
436+ Type: ports.MsgInput,
437+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
438+ }))
439+ d.attachmentCleanupWg.Wait()
440+
441+ require.Same(t, waiting, sess.client)
442+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
443+ require.Greater(t, waiting.roleGeneration.Load(), resumedGeneration)
444+ require.NotNil(t, rc.attachmentLease(waiting))
445+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
446+}
447+
448+func TestOrdinaryAttachDemotesParkedActiveAndAllowsSnatchedResume(t *testing.T) {
449+ pty, release := newBlockingPTY(t)
450+ defer release()
451+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
452+
453+ oldTransport := &closeTrackingTransport{}
454+ sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
455+ require.NoError(t, err)
456+ token := old.resumeToken
457+ d.clientGone(sess, old, oldTransport, false)
458+
459+ activeTransport := &closeTrackingTransport{}
460+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
461+ require.NoError(t, err)
462+
463+ d.mu.Lock()
464+ parked := d.parked[token]
465+ d.mu.Unlock()
466+ require.NotNil(t, parked, "ordinary attach must preserve the predecessor's resume token")
467+ require.Equal(t, attachmentSnatched, parked.role)
468+ require.Equal(t, token, old.resumeToken)
469+
470+ resumedTransport := &closeTrackingTransport{}
471+ resumedSess, resumed, ok, err := d.resumeParked(
472+ helloResumeCapable(ports.IntentResume, "work", token),
473+ resumedTransport,
474+ domain.Size{Cols: 80, Rows: 24},
475+ )
476+ require.NoError(t, err)
477+ require.True(t, ok)
478+ require.Same(t, sess, resumedSess)
479+ require.Same(t, old, resumed)
480+ require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
481+ require.Same(t, active, sess.client)
482+}
A internal/usecase/daemon/role_effect_freeze.go +273 -0
@@ -0,0 +1,273 @@
1+package daemon
2+
3+import (
4+ "sort"
5+ "sync"
6+ "sync/atomic"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type roleEffectDrainDeadline struct {
12+ clock ports.Clock
13+ done chan struct{}
14+ stopCh chan struct{}
15+ finished chan struct{}
16+ startOnce sync.Once
17+ stopOnce sync.Once
18+ started atomic.Bool
19+}
20+
21+func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
22+ return &roleEffectDrainDeadline{
23+ clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
24+ }
25+}
26+
27+func (d *roleEffectDrainDeadline) stop() {
28+ if d == nil {
29+ return
30+ }
31+ d.stopOnce.Do(func() {
32+ // Claim an unstarted deadline without allocating a timer. Done is never
33+ // called after the owning freeze/drain operation returns.
34+ d.startOnce.Do(func() {})
35+ if !d.started.Load() {
36+ return
37+ }
38+ close(d.stopCh)
39+ <-d.finished
40+ })
41+}
42+
43+func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
44+ if d == nil {
45+ return nil
46+ }
47+ d.startOnce.Do(func() {
48+ d.started.Store(true)
49+ timer := d.clock.NewTimer(detachNotifyTimeout)
50+ go func() {
51+ defer close(d.finished)
52+ select {
53+ case <-timer.C():
54+ close(d.done)
55+ case <-d.stopCh:
56+ timer.Stop()
57+ }
58+ }()
59+ })
60+ return d.done
61+}
62+
63+type frozenRoleEffectGates struct {
64+ clients []*attachedClient
65+ interruptions map[*attachedClient]transportSnapshot
66+ acquired bool
67+ drained bool
68+}
69+
70+func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71+ interrupted, ok := f.interruptions[ac]
72+ return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73+}
74+
75+func (f frozenRoleEffectGates) contains(target *attachedClient) bool {
76+ for _, ac := range f.clients {
77+ if ac == target {
78+ return true
79+ }
80+ }
81+ return false
82+}
83+
84+type roleTransportInterrupt struct {
85+ ac *attachedClient
86+ transport transportSnapshot
87+}
88+
89+// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze
90+// must be called without daemon, routing, session, coordinator, send, overlay,
91+// router, or pane locks held.
92+type roleEffectFreezeOptions struct {
93+ interrupts []roleTransportInterrupt
94+ done func() <-chan struct{}
95+ afterFrozen func(*attachedClient)
96+ nonblocking bool
97+}
98+
99+// freezeRoleEffectGates establishes transition priority in immutable attachment
100+// order, then drains earlier admissions.
101+func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
102+ return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)
103+}
104+
105+// freezeRoleEffectGatesWith uses done as one overall bound for ordered
106+// acquisition and drain. Acquisition is all-or-nothing: if the bound expires
107+// behind another owner, only gates acquired by this call are rolled back, in
108+// reverse order. nonblocking is used by move reservations to avoid inverting
109+// the move/teardown protocols.
110+func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {
111+ ordered := orderedRoleEffectClients(clients)
112+ frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
113+ if !acquired {
114+ return frozenRoleEffectGates{}
115+ }
116+
117+ interruptByClient := roleTransportInterruptsByClient(options.interrupts)
118+ frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
119+ interruptFrozenRoleTransports(ordered, interruptByClient)
120+ frozen.acquired = true
121+ frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)
122+ return frozen
123+}
124+
125+// orderedRoleEffectClients snapshots unique participants in immutable lock
126+// order. Assigning a previously unused order does not freeze or wait on a gate.
127+func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
128+ unique := make(map[*attachedClient]struct{}, len(clients))
129+ ordered := make([]*attachedClient, 0, len(clients))
130+ for _, ac := range clients {
131+ if ac == nil {
132+ continue
133+ }
134+ if _, exists := unique[ac]; exists {
135+ continue
136+ }
137+ unique[ac] = struct{}{}
138+ ac.roleEffects.immutableOrder()
139+ ordered = append(ordered, ac)
140+ }
141+ sort.Slice(ordered, func(i, j int) bool {
142+ return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
143+ })
144+ return ordered
145+}
146+
147+// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A
148+// true result means the overall deadline won and the caller must stop waiting.
149+func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool {
150+ if done == nil {
151+ g.cond.Wait()
152+ return false
153+ }
154+ changed := g.changed
155+ doneCh := done()
156+ g.mu.Unlock()
157+ select {
158+ case <-changed:
159+ case <-doneCh:
160+ }
161+ g.mu.Lock()
162+ select {
163+ case <-doneCh:
164+ return true
165+ default:
166+ return false
167+ }
168+}
169+
170+// acquireRoleEffectGates establishes exclusive transition ownership in order.
171+// It must run without architecture locks. Deadline rollback releases only this
172+// call's partial acquisition and does so in reverse order.
173+func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) {
174+ frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
175+ for _, ac := range ordered {
176+ g := &ac.roleEffects
177+ g.mu.Lock()
178+ g.initLocked()
179+ for g.phase == roleEffectsFrozen {
180+ if nonblocking {
181+ g.mu.Unlock()
182+ frozen.unfreeze()
183+ return frozenRoleEffectGates{}, false
184+ }
185+ if waitForRoleEffectChangeLocked(g, done) {
186+ g.mu.Unlock()
187+ frozen.unfreeze()
188+ return frozenRoleEffectGates{}, false
189+ }
190+ }
191+ g.phase = roleEffectsFrozen
192+ g.mu.Unlock()
193+ frozen.clients = append(frozen.clients, ac)
194+ if afterFrozen != nil {
195+ afterFrozen(ac)
196+ }
197+ }
198+ return frozen, true
199+}
200+
201+func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {
202+ byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
203+ for _, interrupt := range interrupts {
204+ if interrupt.ac != nil && interrupt.transport.transport != nil {
205+ byClient[interrupt.ac] = interrupt.transport
206+ }
207+ }
208+ return byClient
209+}
210+
211+// interruptFrozenRoleTransports snapshots every exact in-flight link while all
212+// gates are frozen, then closes the complete set before any drain wait. One
213+// uncooperative Send cannot prevent another participant from being interrupted.
214+func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
215+ toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
216+ for _, ac := range ordered {
217+ expected, mayInterrupt := interruptByClient[ac]
218+ if !mayInterrupt {
219+ continue
220+ }
221+ g := &ac.roleEffects
222+ g.mu.Lock()
223+ if gateHasTransportEffectLocked(g, expected) {
224+ toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
225+ }
226+ g.mu.Unlock()
227+ }
228+ for _, interrupt := range toClose {
229+ _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport)
230+ }
231+}
232+
233+// drainFrozenRoleEffects waits for admissions made before the freeze. A closed
234+// deadline permits terminal teardown to continue with the capability frozen;
235+// late tickets can then retire without pinning the owner indefinitely.
236+func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
237+ for _, ac := range ordered {
238+ g := &ac.roleEffects
239+ g.mu.Lock()
240+ for g.inFlight != 0 {
241+ if waitForRoleEffectChangeLocked(g, done) {
242+ g.mu.Unlock()
243+ return false
244+ }
245+ }
246+ if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt &&
247+ g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation {
248+ interruptions[ac] = expected
249+ }
250+ g.mu.Unlock()
251+ }
252+ return true
253+}
254+
255+func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
256+ for _, active := range g.transportEffects {
257+ if active.transport == expected.transport && active.incarnation == expected.incarnation {
258+ return true
259+ }
260+ }
261+ return false
262+}
263+
264+func (f frozenRoleEffectGates) unfreeze() {
265+ for i := len(f.clients) - 1; i >= 0; i-- {
266+ g := &f.clients[i].roleEffects
267+ g.mu.Lock()
268+ g.initLocked()
269+ g.phase = roleEffectsStable
270+ g.signalLocked()
271+ g.mu.Unlock()
272+ }
273+}
A internal/usecase/daemon/role_effect_gate.go +307 -0
@@ -0,0 +1,307 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+type roleEffectPhase uint8
11+
12+const (
13+ roleEffectsStable roleEffectPhase = iota
14+ roleEffectsFrozen
15+)
16+
17+// roleCapability is the single attachment-local publication consumed by role
18+// effect admission. It deliberately contains no independently mutable state:
19+// a ticket is admitted only when every field matches the frame's token.
20+type roleCapability struct {
21+ sess *session
22+ role attachmentRole
23+ generation uint64
24+ transport transportSnapshot
25+ lease *attachmentLease
26+}
27+
28+func capabilityFromToken(token attachmentRoleToken) roleCapability {
29+ return roleCapability{
30+ sess: token.sess,
31+ role: token.role,
32+ generation: token.generation,
33+ transport: token.transport,
34+ lease: token.lease,
35+ }
36+}
37+
38+func (c roleCapability) matches(token attachmentRoleToken) bool {
39+ return token.ac != nil && c.sess == token.sess && c.role == token.role &&
40+ c.generation == token.generation && c.transport.transport == token.transport.transport &&
41+ c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
42+}
43+
44+type roleEffectGate struct {
45+ mu sync.Mutex
46+ cond *sync.Cond
47+ changed chan struct{}
48+ phase roleEffectPhase
49+ capability roleCapability
50+ inFlight uint64
51+ transportEffects map[*roleEffectTicket]transportSnapshot
52+ failedTransport transportSnapshot
53+ order atomic.Uint64
54+}
55+
56+var nextRoleEffectGateOrder atomic.Uint64
57+
58+func (g *roleEffectGate) initLocked() {
59+ if g.cond == nil {
60+ g.cond = sync.NewCond(&g.mu)
61+ }
62+ if g.changed == nil {
63+ g.changed = make(chan struct{})
64+ }
65+}
66+
67+func (g *roleEffectGate) signalLocked() {
68+ g.initLocked()
69+ close(g.changed)
70+ g.changed = make(chan struct{})
71+ g.cond.Broadcast()
72+}
73+
74+func (g *roleEffectGate) immutableOrder() uint64 {
75+ if order := g.order.Load(); order != 0 {
76+ return order
77+ }
78+ order := nextRoleEffectGateOrder.Add(1)
79+ if g.order.CompareAndSwap(0, order) {
80+ return order
81+ }
82+ return g.order.Load()
83+}
84+
85+func (g *roleEffectGate) inFlightCount() uint64 {
86+ if g == nil {
87+ return 0
88+ }
89+ g.mu.Lock()
90+ defer g.mu.Unlock()
91+ return g.inFlight
92+}
93+
94+// roleEffectTicket reserves the exact published capability until End. End is
95+// idempotent so error paths can safely defer it before doing any observable I/O.
96+type roleEffectTicket struct {
97+ gate *roleEffectGate
98+ token attachmentRoleToken
99+ actionDaemon *Daemon
100+ action string
101+ ended atomic.Bool
102+}
103+
104+func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {
105+ if t == nil || t.ended.Load() {
106+ return
107+ }
108+ t.actionDaemon = d
109+ t.action = action
110+}
111+
112+func (t *roleEffectTicket) roleToken() attachmentRoleToken {
113+ if t == nil {
114+ return attachmentRoleToken{}
115+ }
116+ token := t.token
117+ token.effect = t
118+ return token
119+}
120+
121+func (t *roleEffectTicket) End() {
122+ if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
123+ return
124+ }
125+ g := t.gate
126+ g.mu.Lock()
127+ g.initLocked()
128+ if g.inFlight == 0 {
129+ g.mu.Unlock()
130+ panic("daemon: role effect ticket ended without admission")
131+ }
132+ delete(g.transportEffects, t)
133+ g.inFlight--
134+ g.signalLocked()
135+ g.mu.Unlock()
136+ if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil {
137+ t.actionDaemon.afterActionRoleEffectEnded(t.action)
138+ }
139+}
140+
141+func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {
142+ if effect != nil {
143+ effect.End()
144+ }
145+}
146+
147+// beginTransportSend marks only the interval spent in an external transport
148+// send as interruptible. A replacement may close this exact published link to
149+// release the send, but cannot retire an idle healthy snatched connection.
150+func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
151+ if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
152+ return false
153+ }
154+ g := t.gate
155+ g.mu.Lock()
156+ g.initLocked()
157+ if t.ended.Load() || g.capability.transport.transport != expected.transport ||
158+ g.capability.transport.incarnation != expected.incarnation {
159+ g.mu.Unlock()
160+ return false
161+ }
162+ if g.transportEffects == nil {
163+ g.transportEffects = make(map[*roleEffectTicket]transportSnapshot)
164+ }
165+ g.transportEffects[t] = expected
166+ g.signalLocked()
167+ g.mu.Unlock()
168+ return true
169+}
170+
171+func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {
172+ if t == nil || t.gate == nil || expected.transport == nil {
173+ return
174+ }
175+ g := t.gate
176+ g.mu.Lock()
177+ if !t.ended.Load() && g.capability.transport.transport == expected.transport &&
178+ g.capability.transport.incarnation == expected.incarnation {
179+ g.failedTransport = expected
180+ g.signalLocked()
181+ }
182+ g.mu.Unlock()
183+}
184+
185+func (t *roleEffectTicket) endTransportSend() {
186+ if t == nil || t.gate == nil {
187+ return
188+ }
189+ g := t.gate
190+ g.mu.Lock()
191+ if _, exists := g.transportEffects[t]; exists {
192+ delete(g.transportEffects, t)
193+ g.signalLocked()
194+ }
195+ g.mu.Unlock()
196+}
197+
198+// beginRoleEffect is the sole role-bound effect admission point. The gate is
199+// held only long enough to reserve the capability; the effect itself must run
200+// without the gate mutex.
201+func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
202+ if ac == nil || token.ac != ac {
203+ return nil, false
204+ }
205+ g := &ac.roleEffects
206+ g.mu.Lock()
207+ g.initLocked()
208+ if g.phase != roleEffectsStable || !g.capability.matches(token) {
209+ g.mu.Unlock()
210+ return nil, false
211+ }
212+ g.inFlight++
213+ g.mu.Unlock()
214+ captured := token
215+ captured.effect = nil
216+ return &roleEffectTicket{gate: g, token: captured}, true
217+}
218+
219+// beginCurrentRoleEffect waits out a transition that already froze ac, then
220+// admits the role capability derived from the session registries. Handshakes
221+// use it after Welcome has completed so a replacement blocked behind that send
222+// can publish before readiness, first paint, or the snatched reset panel.
223+func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transport) (attachmentRoleToken, *roleEffectTicket, bool) {
224+ if ac == nil || sess == nil || tr == nil {
225+ return attachmentRoleToken{}, nil, false
226+ }
227+ for {
228+ token := sess.attachmentToken(ac, tr)
229+ if token.role == attachmentDetached {
230+ return token, nil, false
231+ }
232+ if ticket, admitted := ac.beginRoleEffect(token); admitted {
233+ return token, ticket, true
234+ }
235+
236+ g := &ac.roleEffects
237+ g.mu.Lock()
238+ g.initLocked()
239+ if g.phase == roleEffectsFrozen {
240+ changed := g.changed
241+ g.mu.Unlock()
242+ <-changed
243+ continue
244+ }
245+ g.mu.Unlock()
246+ // Publication may have completed between token capture and admission.
247+ // Re-read the registry and exact generation instead of using stale role
248+ // or lease authority.
249+ }
250+}
251+
252+// publishRoleCapability is used by direct/headless setup and by transition
253+// publication while the gate is frozen. Production role changes publish only
254+// through publishFrozenRoleCapability.
255+func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {
256+ if ac == nil {
257+ return
258+ }
259+ g := &ac.roleEffects
260+ g.mu.Lock()
261+ g.initLocked()
262+ g.capability = capabilityFromToken(token)
263+ g.failedTransport = transportSnapshot{}
264+ g.mu.Unlock()
265+}
266+
267+// bootstrapRoleCapability supports direct/headless session construction. It
268+// never changes an existing or frozen production publication.
269+func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) {
270+ if ac == nil || token.sess == nil || token.role == attachmentDetached {
271+ return
272+ }
273+ g := &ac.roleEffects
274+ g.mu.Lock()
275+ g.initLocked()
276+ if g.phase == roleEffectsStable && g.capability.sess == nil {
277+ g.capability = capabilityFromToken(token)
278+ g.failedTransport = transportSnapshot{}
279+ }
280+ g.mu.Unlock()
281+}
282+
283+func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) {
284+ g := &ac.roleEffects
285+ g.mu.Lock()
286+ g.initLocked()
287+ if g.phase != roleEffectsFrozen {
288+ g.mu.Unlock()
289+ panic("daemon: publishing role capability without transition freeze")
290+ }
291+ g.capability = capabilityFromToken(token)
292+ g.failedTransport = transportSnapshot{}
293+ g.mu.Unlock()
294+}
295+
296+func (ac *attachedClient) invalidateFrozenRoleCapability() {
297+ g := &ac.roleEffects
298+ g.mu.Lock()
299+ g.initLocked()
300+ if g.phase != roleEffectsFrozen {
301+ g.mu.Unlock()
302+ panic("daemon: invalidating role capability without transition freeze")
303+ }
304+ g.capability = roleCapability{}
305+ g.failedTransport = transportSnapshot{}
306+ g.mu.Unlock()
307+}
A internal/usecase/daemon/role_effect_gate_test.go +1475 -0
@@ -0,0 +1,1475 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys"
15+ "github.com/bnema/vev/internal/usecase/picker"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+func releaseTestGate(t *testing.T, ch chan struct{}) func() {
20+ t.Helper()
21+ var once sync.Once
22+ release := func() { once.Do(func() { close(ch) }) }
23+ t.Cleanup(release)
24+ return release
25+}
26+
27+type blockedRenderReplacementTransport struct {
28+ sendEntered chan struct{}
29+ closed chan struct{}
30+ sendOnce sync.Once
31+ closeOnce sync.Once
32+ locksFree chan bool
33+ d *Daemon
34+ sess *session
35+}
36+
37+func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport {
38+ return &blockedRenderReplacementTransport{
39+ sendEntered: make(chan struct{}),
40+ closed: make(chan struct{}),
41+ locksFree: make(chan bool, 1),
42+ d: d,
43+ sess: sess,
44+ }
45+}
46+
47+func (t *blockedRenderReplacementTransport) Send(ports.Frame) error {
48+ t.sendOnce.Do(func() { close(t.sendEntered) })
49+ <-t.closed
50+ return errors.New("transport closed")
51+}
52+
53+func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) {
54+ return ports.Frame{}, io.EOF
55+}
56+
57+func (t *blockedRenderReplacementTransport) Close() error {
58+ locksFree := t.d.mu.TryLock()
59+ if locksFree {
60+ t.d.mu.Unlock()
61+ }
62+ if locksFree {
63+ locksFree = t.d.notices.routingMu.TryLock()
64+ if locksFree {
65+ t.d.notices.routingMu.Unlock()
66+ }
67+ }
68+ if locksFree {
69+ locksFree = t.sess.mu.TryLock()
70+ if locksFree {
71+ t.sess.mu.Unlock()
72+ }
73+ }
74+ if locksFree {
75+ if rc := t.sess.renderCoordinator(); rc != nil {
76+ locksFree = rc.mu.TryLock()
77+ if locksFree {
78+ rc.mu.Unlock()
79+ }
80+ }
81+ }
82+ t.closeOnce.Do(func() {
83+ t.locksFree <- locksFree
84+ close(t.closed)
85+ })
86+ return nil
87+}
88+
89+type teardownBlockingTransport struct {
90+ sendEntered chan struct{}
91+ closed chan struct{}
92+ sendOnce sync.Once
93+ closeOnce sync.Once
94+}
95+
96+func newTeardownBlockingTransport() *teardownBlockingTransport {
97+ return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})}
98+}
99+
100+func (t *teardownBlockingTransport) Send(ports.Frame) error {
101+ t.sendOnce.Do(func() { close(t.sendEntered) })
102+ <-t.closed
103+ return errors.New("transport closed")
104+}
105+
106+func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
107+
108+func (t *teardownBlockingTransport) Close() error {
109+ t.closeOnce.Do(func() { close(t.closed) })
110+ return nil
111+}
112+
113+func (t *teardownBlockingTransport) Closed() bool {
114+ select {
115+ case <-t.closed:
116+ return true
117+ default:
118+ return false
119+ }
120+}
121+
122+func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} {
123+ t.Helper()
124+ ticket, admitted := token.ac.beginRoleEffect(token)
125+ require.True(t, admitted)
126+ done := make(chan struct{})
127+ go func() {
128+ defer close(done)
129+ defer ticket.End()
130+ _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket)
131+ }()
132+ return done
133+}
134+
135+func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
136+ t.Helper()
137+ ac.roleEffects.mu.Lock()
138+ defer ac.roleEffects.mu.Unlock()
139+ require.Equal(t, roleEffectsStable, ac.roleEffects.phase)
140+ require.Zero(t, ac.roleEffects.inFlight)
141+ require.Empty(t, ac.roleEffects.transportEffects)
142+}
143+
144+func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) {
145+ ac.roleEffects.mu.Lock()
146+ defer ac.roleEffects.mu.Unlock()
147+ return ac.roleEffects.phase, ac.roleEffects.capability
148+}
149+
150+func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
151+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
152+ firstTransport := &closeTrackingTransport{}
153+ first.replaceTransport(firstTransport)
154+
155+ blockedTransport := &closeTrackingTransport{}
156+ blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size}
157+ blocked.initOverlays()
158+ blocked.setSession(sess)
159+ laterTransport := &closeTrackingTransport{}
160+ later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size}
161+ later.initOverlays()
162+ later.setSession(sess)
163+ sess.mu.Lock()
164+ sess.addSnatchedLocked(blocked)
165+ sess.addSnatchedLocked(later)
166+ sess.mu.Unlock()
167+
168+ rc := d.attachCoordinator(sess, nil, first, true)
169+ firstToken := sess.attachmentToken(first, firstTransport)
170+ firstToken.lease = rc.attachmentLease(first)
171+ first.publishRoleCapability(firstToken)
172+ blockedToken := sess.attachmentToken(blocked, blockedTransport)
173+ blocked.publishRoleCapability(blockedToken)
174+ laterToken := sess.attachmentToken(later, laterTransport)
175+ later.publishRoleCapability(laterToken)
176+
177+ // Fix the canonical order so teardown acquires first, waits behind the gate
178+ // owned by this test, and never reaches the still-stable later participant.
179+ first.roleEffects.immutableOrder()
180+ blocked.roleEffects.immutableOrder()
181+ later.roleEffects.immutableOrder()
182+ require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load())
183+ require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load())
184+
185+ blockedOwner := freezeRoleEffectGates(blocked)
186+ blockedOwnerHeld := true
187+ t.Cleanup(func() {
188+ if blockedOwnerHeld {
189+ blockedOwner.unfreeze()
190+ }
191+ })
192+
193+ firstPhase, firstCapability := roleGatePublicationSnapshot(first)
194+ blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)
195+ laterPhase, laterCapability := roleGatePublicationSnapshot(later)
196+ require.Equal(t, roleEffectsStable, firstPhase)
197+ require.Equal(t, roleEffectsFrozen, blockedPhase)
198+ require.Equal(t, roleEffectsStable, laterPhase)
199+ firstGeneration := first.roleGeneration.Load()
200+ blockedGeneration := blocked.roleGeneration.Load()
201+ laterGeneration := later.roleGeneration.Load()
202+ firstSnapshot := first.transportSnapshot()
203+ blockedSnapshot := blocked.transportSnapshot()
204+ laterSnapshot := later.transportSnapshot()
205+
206+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
207+ d.clock = clock
208+ frozenByKill := make(chan *attachedClient, 3)
209+ d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
210+ killDone := make(chan error, 1)
211+ go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
212+
213+ require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate"))
214+ deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")
215+ require.Equal(t, detachNotifyTimeout, deadline.duration)
216+ deadline.ch <- time.Now()
217+ select {
218+ case err := <-killDone:
219+ require.NoError(t, err)
220+ case <-time.After(time.Second):
221+ t.Fatal("teardown did not abort after partial gate acquisition timed out")
222+ }
223+ require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired")
224+
225+ d.mu.Lock()
226+ require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner")
227+ d.mu.Unlock()
228+ sess.mu.Lock()
229+ require.Same(t, first, sess.client)
230+ require.Contains(t, sess.snatched, blocked)
231+ require.Contains(t, sess.snatched, later)
232+ sess.mu.Unlock()
233+ for _, ac := range []*attachedClient{first, blocked, later} {
234+ require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
235+ }
236+ require.Equal(t, firstGeneration, first.roleGeneration.Load())
237+ require.Equal(t, blockedGeneration, blocked.roleGeneration.Load())
238+ require.Equal(t, laterGeneration, later.roleGeneration.Load())
239+ require.True(t, first.transportSnapshotCurrent(firstSnapshot))
240+ require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
241+ require.True(t, later.transportSnapshotCurrent(laterSnapshot))
242+ require.False(t, firstTransport.Closed())
243+ require.False(t, blockedTransport.Closed())
244+ require.False(t, laterTransport.Closed())
245+
246+ firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)
247+ blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)
248+ laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)
249+ require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back")
250+ require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
251+ require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
252+ require.Equal(t, firstCapability, gotFirstCapability)
253+ require.Equal(t, blockedCapability, gotBlockedCapability)
254+ require.Equal(t, laterCapability, gotLaterCapability)
255+
256+ blockedOwner.unfreeze()
257+ blockedOwnerHeld = false
258+ complete := freezeRoleEffectGates(first, blocked, later)
259+ require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
260+ require.True(t, complete.drained)
261+ complete.unfreeze()
262+ for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} {
263+ ticket, admitted := token.ac.beginRoleEffect(token)
264+ require.True(t, admitted, "pre-publication abort changed a role capability")
265+ ticket.End()
266+ requireRoleGateRetired(t, token.ac)
267+ }
268+}
269+
270+func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
271+ d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
272+ snatchedTransport := newTeardownBlockingTransport()
273+ snatched.replaceTransport(snatchedTransport)
274+ d.attachCoordinator(sess, nil, snatched, true)
275+ snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport))
276+
277+ activeTransport := newTeardownBlockingTransport()
278+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
279+ active.initOverlays()
280+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
281+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
282+ expectedTransport: active.transportSnapshot(), ready: true,
283+ })
284+ require.NoError(t, err)
285+ defer d.deferAttachmentTransitionCleanups(transition)
286+
287+ unrelatedTransport := &closeTrackingTransport{}
288+ unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
289+ unrelatedClient.initOverlays()
290+ unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient}
291+ unrelatedClient.setSession(unrelated)
292+ d.mu.Lock()
293+ d.sessions[unrelated.id] = unrelated
294+ d.mu.Unlock()
295+ d.attachCoordinator(unrelated, nil, unrelatedClient, true)
296+
297+ snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
298+ activeToken := sess.attachmentToken(active, activeTransport)
299+ snatchedDone := startBlockedRoleSend(t, snatchedToken)
300+ activeDone := startBlockedRoleSend(t, activeToken)
301+ awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
302+ awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")
303+
304+ killDone := make(chan error, 1)
305+ go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
306+ select {
307+ case err := <-killDone:
308+ require.NoError(t, err)
309+ case <-time.After(500 * time.Millisecond):
310+ _ = snatchedTransport.Close()
311+ _ = activeTransport.Close()
312+ t.Fatal("session kill remained pinned behind admitted participant sends")
313+ }
314+ awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire")
315+ awaitTestCompletion(t, activeDone, "active role ticket did not retire")
316+ d.waitNotifies()
317+
318+ require.True(t, snatchedTransport.Closed())
319+ require.True(t, activeTransport.Closed())
320+ require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
321+ require.Same(t, unrelatedClient, unrelated.client)
322+ require.Same(t, unrelated, unrelatedClient.currentSession())
323+ requireRoleGateRetired(t, snatched)
324+ requireRoleGateRetired(t, active)
325+}
326+
327+func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {
328+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
329+ stale := newTeardownBlockingTransport()
330+ ac.replaceTransport(stale)
331+ d.attachCoordinator(sess, nil, ac, true)
332+ ac.publishRoleCapability(sess.attachmentToken(ac, stale))
333+
334+ token := sess.attachmentToken(ac, stale)
335+ sendDone := startBlockedRoleSend(t, token)
336+ awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
337+ fresh := &closeTrackingTransport{}
338+ d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) {
339+ ac.replaceTransport(fresh)
340+ }
341+
342+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
343+ awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire")
344+ require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")
345+ require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")
346+ require.Same(t, fresh, ac.transport())
347+ d.mu.Lock()
348+ require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication")
349+ d.mu.Unlock()
350+ sess.mu.Lock()
351+ require.Same(t, ac, sess.client)
352+ sess.mu.Unlock()
353+ requireRoleGateRetired(t, ac)
354+}
355+
356+func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
357+ d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
358+ blocked := newTeardownBlockingTransport()
359+ active.replaceTransport(blocked)
360+ d.attachCoordinator(sess, nil, active, true)
361+ active.publishRoleCapability(sess.attachmentToken(active, blocked))
362+
363+ token := sess.attachmentToken(active, blocked)
364+ sendDone := startBlockedRoleSend(t, token)
365+ awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
366+
367+ shutdownDone := make(chan bool, 1)
368+ go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }()
369+ select {
370+ case incomplete := <-shutdownDone:
371+ require.False(t, incomplete)
372+ case <-time.After(500 * time.Millisecond):
373+ _ = blocked.Close()
374+ t.Fatal("shutdown remained pinned behind admitted participant send")
375+ }
376+ awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire")
377+ d.waitNotifies()
378+ require.True(t, blocked.Closed())
379+ requireRoleGateRetired(t, active)
380+}
381+
382+type renderFailureTransport struct {
383+ mu sync.Mutex
384+ closed bool
385+}
386+
387+func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") }
388+func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
389+func (t *renderFailureTransport) Close() error {
390+ t.mu.Lock()
391+ t.closed = true
392+ t.mu.Unlock()
393+ return nil
394+}
395+func (t *renderFailureTransport) Closed() bool {
396+ t.mu.Lock()
397+ defer t.mu.Unlock()
398+ return t.closed
399+}
400+
401+func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing.T) {
402+ d, sess, original, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
403+ failedTransport := &renderFailureTransport{}
404+ original.replaceTransport(failedTransport)
405+ rc := d.attachCoordinator(sess, nil, original, true)
406+ originalToken := sess.attachmentToken(original, failedTransport)
407+ originalToken.lease = rc.attachmentLease(original)
408+ original.publishRoleCapability(originalToken)
409+
410+ cleanupStarted := make(chan struct{})
411+ releaseCleanup := make(chan struct{})
412+ cleanupDone := make(chan struct{})
413+ d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) {
414+ require.Equal(t, originalToken.generation, token.generation)
415+ require.Equal(t, originalToken.transport, token.transport)
416+ require.Same(t, originalToken.lease, token.lease)
417+ close(cleanupStarted)
418+ <-releaseCleanup
419+ }
420+ d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
421+
422+ d.paint(sess, original, true, originalToken.lease)
423+ awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token")
424+ cleanupTracked := waitGroupDone(&d.attachmentCleanupWg)
425+ select {
426+ case <-cleanupTracked:
427+ t.Fatal("paused render failure cleanup was not tracked by attachmentCleanupWg")
428+ default:
429+ }
430+
431+ replacementTransport := &closeTrackingTransport{}
432+ replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size}
433+ replacement.initOverlays()
434+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
435+ target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive,
436+ expectedTransport: replacement.transportSnapshot(), ready: true,
437+ })
438+ require.NoError(t, err)
439+
440+ resumed, err := d.transitionAttachment(attachmentTransitionRequest{
441+ target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive,
442+ expectedTransport: original.transportSnapshot(), ready: true,
443+ })
444+ require.NoError(t, err)
445+ resumedGeneration := original.roleGeneration.Load()
446+ resumedLease := resumed.published.lease
447+ require.NotSame(t, originalToken.lease, resumedLease)
448+
449+ close(releaseCleanup)
450+ awaitTestCompletion(t, cleanupDone, "render failure cleanup did not finish")
451+ awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire")
452+
453+ sess.mu.Lock()
454+ require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation")
455+ sess.mu.Unlock()
456+ require.Equal(t, resumedGeneration, original.roleGeneration.Load())
457+ require.True(t, resumed.published.activeCurrent())
458+ require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport")
459+
460+ d.deferAttachmentTransitionCleanups(displaced)
461+ d.deferAttachmentTransitionCleanups(resumed)
462+ d.attachmentCleanupWg.Wait()
463+}
464+
465+func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {
466+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
467+ transport := &closeTrackingTransport{}
468+ ac.replaceTransport(transport)
469+ rc := d.attachCoordinator(sess, nil, ac, true)
470+ token := sess.attachmentToken(ac, transport)
471+ token.lease = rc.attachmentLease(ac)
472+ ac.publishRoleCapability(token)
473+
474+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
475+ d.clock = clock
476+ frozen := freezeRoleEffectGates(ac)
477+ defer frozen.unfreeze()
478+ cleanupDone := make(chan struct{})
479+ d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
480+
481+ launchCleanup := d.reserveRoleSendErrorCleanup(token, transport)
482+ launchCleanup()
483+ deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")
484+ require.Equal(t, detachNotifyTimeout, deadline.duration)
485+ deadline.ch <- time.Now()
486+ awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup")
487+ awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine")
488+
489+ sess.mu.Lock()
490+ require.Same(t, ac, sess.client)
491+ sess.mu.Unlock()
492+ require.False(t, transport.Closed())
493+}
494+
495+func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
496+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
497+ oldTransport := newBlockedRenderReplacementTransport(d, sess)
498+ old.replaceTransport(oldTransport)
499+ rc := d.attachCoordinator(sess, nil, old, true)
500+ lease := rc.attachmentLease(old)
501+ token := sess.attachmentToken(old, oldTransport)
502+ token.lease = lease
503+ old.publishRoleCapability(token)
504+
505+ paintDone := make(chan struct{})
506+ go func() {
507+ d.paint(sess, old, true, lease)
508+ close(paintDone)
509+ }()
510+ <-oldTransport.sendEntered
511+
512+ newTransport := &closeTrackingTransport{}
513+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
514+ next.initOverlays()
515+ transitionDone := make(chan attachmentTransitionResult, 1)
516+ transitionErr := make(chan error, 1)
517+ go func() {
518+ result, err := d.transitionAttachment(attachmentTransitionRequest{
519+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
520+ expectedTransport: next.transportSnapshot(), ready: true,
521+ })
522+ transitionDone <- result
523+ transitionErr <- err
524+ }()
525+
526+ var result attachmentTransitionResult
527+ select {
528+ case result = <-transitionDone:
529+ case <-time.After(time.Second):
530+ t.Fatal("replacement publication remained blocked behind the old render send")
531+ }
532+ require.NoError(t, <-transitionErr)
533+ require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held")
534+ select {
535+ case <-paintDone:
536+ case <-time.After(time.Second):
537+ t.Fatal("closing the exact old transport did not unblock its render")
538+ }
539+ require.Same(t, next, sess.client)
540+ require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption")
541+
542+ d.deferAttachmentTransitionCleanups(result)
543+ d.attachmentCleanupWg.Wait()
544+ require.Nil(t, old.transport(), "the unhealthy old link was not retired")
545+ require.Same(t, next, sess.client)
546+ require.False(t, newTransport.Closed())
547+}
548+
549+func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) {
550+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
551+ oldTransport := newBlockedRenderReplacementTransport(d, sess)
552+ old.replaceTransport(oldTransport)
553+ rc := d.attachCoordinator(sess, nil, old, true)
554+ lease := rc.attachmentLease(old)
555+ token := sess.attachmentToken(old, oldTransport)
556+ token.lease = lease
557+ old.publishRoleCapability(token)
558+ old.sendMu.Lock()
559+ old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
560+ old.sendMu.Unlock()
561+
562+ staleDetachFrozen := make(chan struct{})
563+ releaseStaleDetach := make(chan struct{})
564+ var releaseOnce sync.Once
565+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) })
566+ d.afterDetachRoleEffectsFrozen = func() {
567+ close(staleDetachFrozen)
568+ <-releaseStaleDetach
569+ }
570+ cleanupStarted := make(chan struct{})
571+ d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) }
572+
573+ paintDone := make(chan struct{})
574+ go func() {
575+ d.paint(sess, old, true, lease)
576+ close(paintDone)
577+ }()
578+ awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send")
579+
580+ newTransport := &closeTrackingTransport{}
581+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
582+ next.initOverlays()
583+ transitionDone := make(chan attachmentTransitionResult, 1)
584+ transitionErr := make(chan error, 1)
585+ go func() {
586+ result, err := d.transitionAttachment(attachmentTransitionRequest{
587+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
588+ expectedTransport: next.transportSnapshot(), ready: true,
589+ })
590+ transitionDone <- result
591+ transitionErr <- err
592+ }()
593+
594+ var result attachmentTransitionResult
595+ select {
596+ case result = <-transitionDone:
597+ require.NoError(t, <-transitionErr)
598+ case <-time.After(time.Second):
599+ t.Fatal("replacement did not interrupt the blocked render")
600+ }
601+ awaitTestCompletion(t, paintDone, "interrupted render did not retire")
602+ awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate")
603+
604+ d.deferAttachmentTransitionCleanups(result)
605+ awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze")
606+ releaseOnce.Do(func() { close(releaseStaleDetach) })
607+ select {
608+ case <-waitGroupDone(&d.attachmentCleanupWg):
609+ case <-time.After(time.Second):
610+ t.Fatal("displaced cleanup deadlocked behind stale detach")
611+ }
612+
613+ sess.mu.Lock()
614+ require.Same(t, next, sess.client)
615+ require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered")
616+ sess.mu.Unlock()
617+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
618+ require.Nil(t, old.transport(), "closed displaced transport remained attached")
619+ require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership")
620+ old.sendMu.Lock()
621+ require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures")
622+ old.sendMu.Unlock()
623+ require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner")
624+}
625+
626+func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) {
627+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
628+ staleTransport := &closeTrackingTransport{}
629+ old.replaceTransport(staleTransport)
630+ d.attachCoordinator(sess, nil, old, true)
631+
632+ nextTransport := &closeTrackingTransport{}
633+ next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size}
634+ next.initOverlays()
635+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
636+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
637+ expectedTransport: next.transportSnapshot(), ready: true,
638+ })
639+ require.NoError(t, err)
640+ stale := displaced.displaced
641+
642+ _, err = d.transitionAttachment(attachmentTransitionRequest{
643+ target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
644+ expectedTransport: old.transportSnapshot(), ready: true,
645+ })
646+ require.NoError(t, err)
647+ freshTransport := &closeTrackingTransport{}
648+ old.replaceTransport(freshTransport)
649+ _, err = d.transitionAttachment(attachmentTransitionRequest{
650+ target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive,
651+ expectedTransport: old.transportSnapshot(), ready: true,
652+ })
653+ require.NoError(t, err)
654+ require.NoError(t, staleTransport.Close())
655+
656+ require.False(t, d.cleanupInterruptedSnatchedAttachment(stale))
657+ sess.mu.Lock()
658+ require.Same(t, old, sess.client)
659+ require.Contains(t, sess.snatched, next)
660+ sess.mu.Unlock()
661+ require.Equal(t, attachmentActive, sess.attachmentRole(old))
662+ require.Same(t, freshTransport, old.transport())
663+ require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport")
664+ require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner")
665+}
666+
667+func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
668+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
669+ oldTransport := newDatagramTestTransport()
670+ old.replaceTransport(oldTransport)
671+ rc := d.attachCoordinator(sess, nil, old, true)
672+ token := sess.attachmentToken(old, oldTransport)
673+ token.lease = rc.attachmentLease(old)
674+ old.publishRoleCapability(token)
675+
676+ ticket, ok := old.beginRoleEffect(token)
677+ require.True(t, ok)
678+
679+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
680+ next.initOverlays()
681+ done := make(chan error, 1)
682+ go func() {
683+ _, err := d.transitionAttachment(attachmentTransitionRequest{
684+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
685+ expectedTransport: next.transportSnapshot(), ready: true,
686+ })
687+ done <- err
688+ }()
689+
690+ select {
691+ case err := <-done:
692+ t.Fatalf("transition committed before admitted effect ended: %v", err)
693+ case <-time.After(20 * time.Millisecond):
694+ }
695+ ticket.End()
696+ require.NoError(t, <-done)
697+}
698+
699+func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
700+ tests := []struct {
701+ name string
702+ setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)
703+ frame ports.Frame
704+ assert func(*testing.T, *attachedClient, *transactionalResizePTY)
705+ }{
706+ {
707+ name: "PTY input",
708+ frame: frameInput([]byte("reserved")),
709+ assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
710+ require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes())
711+ },
712+ },
713+ {
714+ name: "mouse input",
715+ setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) {
716+ p := sess.activeTab().focusedPane()
717+ p.mu.Lock()
718+ p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
719+ p.mu.Unlock()
720+ },
721+ frame: frameInput([]byte("\x1b[<0;1;2M")),
722+ assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
723+ require.NotEmpty(t, pty.writes())
724+ },
725+ },
726+ {
727+ name: "key action",
728+ frame: frameInput([]byte("\x1b ")),
729+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
730+ require.True(t, ac.overlays.paletteActive())
731+ },
732+ },
733+ {
734+ name: "overlay mutation",
735+ setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) {
736+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
737+ },
738+ frame: frameInput([]byte("x")),
739+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
740+ ac.overlays.promptMu.Lock()
741+ defer ac.overlays.promptMu.Unlock()
742+ require.Equal(t, "x", ac.overlays.prompt.Value())
743+ },
744+ },
745+ {
746+ name: "theme",
747+ frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
748+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
749+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
750+ })},
751+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
752+ require.True(t, ac.getClientTheme().HasFG)
753+ },
754+ },
755+ {
756+ name: "ACK",
757+ setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
758+ ac.output.next = 3
759+ },
760+ frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})},
761+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
762+ ac.sendMu.Lock()
763+ defer ac.sendMu.Unlock()
764+ require.Equal(t, uint64(1), ac.output.acked)
765+ },
766+ },
767+ }
768+
769+ for _, tt := range tests {
770+ t.Run(tt.name, func(t *testing.T) {
771+ pty := &transactionalResizePTY{}
772+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
773+ oldTransport := newDatagramTestTransport()
774+ old.replaceTransport(oldTransport)
775+ old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
776+ rc := d.attachCoordinator(sess, nil, old, true)
777+ token := sess.attachmentToken(old, oldTransport)
778+ token.lease = rc.attachmentLease(old)
779+ if tt.setup != nil {
780+ tt.setup(d, sess, old, pty)
781+ }
782+
783+ admitted := make(chan struct{})
784+ release := make(chan struct{})
785+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
786+ close(admitted)
787+ <-release
788+ }
789+ effectDone := make(chan struct{})
790+ go func() {
791+ d.handleActiveClientFrame(token, tt.frame)
792+ close(effectDone)
793+ }()
794+ <-admitted
795+
796+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
797+ next.initOverlays()
798+ transitionDone := make(chan error, 1)
799+ go func() {
800+ _, err := d.transitionAttachment(attachmentTransitionRequest{
801+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
802+ expectedTransport: next.transportSnapshot(), ready: true,
803+ })
804+ transitionDone <- err
805+ }()
806+ select {
807+ case err := <-transitionDone:
808+ t.Fatalf("transition overtook admitted effect: %v", err)
809+ case <-time.After(20 * time.Millisecond):
810+ }
811+ close(release)
812+ <-effectDone
813+ require.NoError(t, <-transitionDone)
814+ tt.assert(t, old, pty)
815+ })
816+ }
817+}
818+
819+func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) {
820+ d, sess, old, _, releases := newManualTabSession(t, 2)
821+ defer releaseAll(releases)
822+ sess.mu.Lock()
823+ sess.tabs[1].attention = true
824+ sess.tabs[1].attentionAt = time.Unix(1, 0)
825+ sess.mu.Unlock()
826+
827+ oldTransport := old.transport()
828+ rc := d.attachCoordinator(sess, nil, old, true)
829+ token := sess.attachmentToken(old, oldTransport)
830+ token.lease = rc.attachmentLease(old)
831+ old.publishRoleCapability(token)
832+
833+ admissionEnded := make(chan struct{})
834+ releaseAction := make(chan struct{})
835+ release := releaseTestGate(t, releaseAction)
836+ var admissionEndedOnce sync.Once
837+ d.afterActionRoleEffectEnded = func(action string) {
838+ if action != "jump-attention" {
839+ return
840+ }
841+ admissionEndedOnce.Do(func() { close(admissionEnded) })
842+ <-releaseAction
843+ }
844+ actionDone := make(chan struct{})
845+ go func() {
846+ d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
847+ close(actionDone)
848+ }()
849+ <-admissionEnded
850+
851+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
852+ next.initOverlays()
853+ result, err := d.transitionAttachment(attachmentTransitionRequest{
854+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
855+ expectedTransport: next.transportSnapshot(), ready: true,
856+ })
857+ require.NoError(t, err)
858+ sess.mu.Lock()
859+ sess.active = 0
860+ sess.mu.Unlock()
861+ release()
862+ <-actionDone
863+
864+ require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role")
865+ d.deferAttachmentTransitionCleanups(result)
866+}
867+
868+func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
869+ p1, release1 := newBlockingPTY(t)
870+ p2, release2 := newBlockingPTY(t)
871+ p3, release3 := newBlockingPTY(t)
872+ defer release1()
873+ defer release2()
874+ defer release3()
875+ d, source, ac, _ := newManualSessionWithPTYs(t, p1)
876+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
877+ newTab(p2, domain.Size{Cols: 80, Rows: 23}),
878+ newTab(p3, domain.Size{Cols: 80, Rows: 23}),
879+ }}
880+ target.tabs[1].attention = true
881+ target.tabs[1].attentionAt = time.Unix(1, 0)
882+ d.sessions[target.id] = target
883+
884+ rc := d.attachCoordinator(source, nil, ac, true)
885+ token := source.attachmentToken(ac, ac.transport())
886+ token.lease = rc.attachmentLease(ac)
887+ ac.publishRoleCapability(token)
888+
889+ d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
890+
891+ require.Same(t, target, ac.currentSession())
892+ require.Equal(t, 1, activeTabIndex(target))
893+}
894+
895+func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) {
896+ p1, release1 := newBlockingPTY(t)
897+ p2, release2 := newBlockingPTY(t)
898+ p3, release3 := newBlockingPTY(t)
899+ defer release1()
900+ defer release2()
901+ defer release3()
902+ d, source, old, _ := newManualSessionWithPTYs(t, p1)
903+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
904+ newTab(p2, domain.Size{Cols: 80, Rows: 23}),
905+ newTab(p3, domain.Size{Cols: 80, Rows: 23}),
906+ }}
907+ target.tabs[1].attention = true
908+ target.tabs[1].attentionAt = time.Unix(1, 0)
909+ d.sessions[target.id] = target
910+
911+ oldTransport := old.transport()
912+ rc := d.attachCoordinator(source, nil, old, true)
913+ token := source.attachmentToken(old, oldTransport)
914+ token.lease = rc.attachmentLease(old)
915+ old.publishRoleCapability(token)
916+
917+ admissionEnded := make(chan struct{})
918+ releaseAction := make(chan struct{})
919+ release := releaseTestGate(t, releaseAction)
920+ var admissionEndedOnce sync.Once
921+ d.afterActionRoleEffectEnded = func(action string) {
922+ if action == "jump-attention" {
923+ admissionEndedOnce.Do(func() { close(admissionEnded) })
924+ <-releaseAction
925+ }
926+ }
927+ actionDone := make(chan struct{})
928+ go func() {
929+ d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
930+ close(actionDone)
931+ }()
932+ <-admissionEnded
933+
934+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
935+ next.initOverlays()
936+ result, err := d.transitionAttachment(attachmentTransitionRequest{
937+ target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
938+ expectedTransport: next.transportSnapshot(), ready: true,
939+ })
940+ require.NoError(t, err)
941+ release()
942+ <-actionDone
943+
944+ require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")
945+ target.mu.Lock()
946+ targetClient := target.client
947+ target.mu.Unlock()
948+ require.Nil(t, targetClient, "stale handoff attached the replaced initiator")
949+ d.deferAttachmentTransitionCleanups(result)
950+}
951+
952+func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
953+ p, releasePTY := newBlockingPTY(t)
954+ defer releasePTY()
955+ d, sess, old, _ := newManualSessionWithPTYs(t, p)
956+ d.enterPicker(sess, old)
957+
958+ oldTransport := old.transport()
959+ rc := d.attachCoordinator(sess, nil, old, true)
960+ token := sess.attachmentToken(old, oldTransport)
961+ token.lease = rc.attachmentLease(old)
962+ old.publishRoleCapability(token)
963+
964+ admissionEnded := make(chan struct{})
965+ releaseAction := make(chan struct{})
966+ release := releaseTestGate(t, releaseAction)
967+ var admissionEndedOnce sync.Once
968+ d.afterActionRoleEffectEnded = func(action string) {
969+ if action != "picker-delete" {
970+ return
971+ }
972+ admissionEndedOnce.Do(func() { close(admissionEnded) })
973+ <-releaseAction
974+ }
975+ actionDone := make(chan struct{})
976+ go func() {
977+ d.handleActiveClientFrame(token, frameInput([]byte("x")))
978+ close(actionDone)
979+ }()
980+ <-admissionEnded
981+
982+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
983+ next.initOverlays()
984+ result, err := d.transitionAttachment(attachmentTransitionRequest{
985+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
986+ expectedTransport: next.transportSnapshot(), ready: true,
987+ })
988+ require.NoError(t, err)
989+ release()
990+ <-actionDone
991+
992+ d.mu.Lock()
993+ registered := d.sessions[sess.id]
994+ d.mu.Unlock()
995+ require.Same(t, sess, registered, "the replaced initiator deleted its former source session")
996+ d.deferAttachmentTransitionCleanups(result)
997+}
998+
999+func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) {
1000+ p1, release1 := newBlockingPTY(t)
1001+ p2, release2 := newBlockingPTY(t)
1002+ defer release1()
1003+ defer release2()
1004+ d, source, old, _ := newManualSessionWithPTYs(t, p1)
1005+ source.name = "source"
1006+ target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1007+ d.sessions[target.id] = target
1008+ d.enterPicker(source, old)
1009+ old.overlays.pickerMu.Lock()
1010+ for {
1011+ selected, ok := old.overlays.picker.Selected()
1012+ if ok && selected.Session == target.id {
1013+ break
1014+ }
1015+ old.overlays.picker.Down()
1016+ }
1017+ old.overlays.pickerMu.Unlock()
1018+
1019+ oldTransport := old.transport()
1020+ rc := d.attachCoordinator(source, nil, old, true)
1021+ token := source.attachmentToken(old, oldTransport)
1022+ token.lease = rc.attachmentLease(old)
1023+ old.publishRoleCapability(token)
1024+
1025+ admissionEnded := make(chan struct{})
1026+ releaseAction := make(chan struct{})
1027+ release := releaseTestGate(t, releaseAction)
1028+ var admissionEndedOnce sync.Once
1029+ d.afterActionRoleEffectEnded = func(action string) {
1030+ if action == "picker-delete" {
1031+ admissionEndedOnce.Do(func() { close(admissionEnded) })
1032+ <-releaseAction
1033+ }
1034+ }
1035+ actionDone := make(chan struct{})
1036+ go func() {
1037+ d.handleActiveClientFrame(token, frameInput([]byte("x")))
1038+ close(actionDone)
1039+ }()
1040+ <-admissionEnded
1041+
1042+ d.mu.Lock()
1043+ _, targetStillRegistered := d.sessions[target.id]
1044+ d.mu.Unlock()
1045+ require.True(t, targetStillRegistered, "picker deletion mutated the target before source-token preflight")
1046+
1047+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1048+ next.initOverlays()
1049+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1050+ target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1051+ expectedTransport: next.transportSnapshot(), ready: true,
1052+ })
1053+ require.NoError(t, err)
1054+ release()
1055+ <-actionDone
1056+ d.mu.Lock()
1057+ _, targetStillRegistered = d.sessions[target.id]
1058+ d.mu.Unlock()
1059+ require.True(t, targetStillRegistered, "stale picker deletion mutated the target session")
1060+ d.deferAttachmentTransitionCleanups(result)
1061+}
1062+
1063+func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
1064+ p, release := newBlockingPTY(t)
1065+ defer release()
1066+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1067+ d.enterPicker(sess, ac)
1068+ rc := d.attachCoordinator(sess, nil, ac, true)
1069+ token := sess.attachmentToken(ac, ac.transport())
1070+ token.lease = rc.attachmentLease(ac)
1071+ ac.publishRoleCapability(token)
1072+
1073+ d.handleActiveClientFrame(token, frameInput([]byte("x")))
1074+
1075+ d.mu.Lock()
1076+ _, registered := d.sessions[sess.id]
1077+ d.mu.Unlock()
1078+ require.False(t, registered)
1079+}
1080+
1081+func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T) {
1082+ writes := make(chan []byte, 1)
1083+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
1084+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
1085+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
1086+ d.clock = clock
1087+ oldTransport := newDatagramTestTransport()
1088+ old.replaceTransport(oldTransport)
1089+ old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
1090+ rc := d.attachCoordinator(sess, nil, old, true)
1091+ token := sess.attachmentToken(old, oldTransport)
1092+ token.lease = rc.attachmentLease(old)
1093+ old.publishRoleCapability(token)
1094+
1095+ d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1096+ timer := <-clock.timers
1097+ admitted := make(chan struct{})
1098+ release := make(chan struct{})
1099+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1100+ close(admitted)
1101+ <-release
1102+ }
1103+ timer.ch <- time.Time{}
1104+ select {
1105+ case <-admitted:
1106+ case <-time.After(time.Second):
1107+ t.Fatal("delayed key callback did not acquire a fresh role-effect ticket")
1108+ }
1109+
1110+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1111+ next.initOverlays()
1112+ transitionDone := make(chan error, 1)
1113+ go func() {
1114+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1115+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1116+ expectedTransport: next.transportSnapshot(), ready: true,
1117+ })
1118+ transitionDone <- err
1119+ }()
1120+ select {
1121+ case err := <-transitionDone:
1122+ t.Fatalf("replacement overtook admitted delayed key callback: %v", err)
1123+ case <-time.After(20 * time.Millisecond):
1124+ }
1125+ close(release)
1126+ require.Equal(t, []byte{keys.ESC}, <-writes)
1127+ require.NoError(t, <-transitionDone)
1128+}
1129+
1130+func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
1131+ writes := make(chan []byte, 1)
1132+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
1133+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
1134+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
1135+ d.clock = clock
1136+ oldTransport := newDatagramTestTransport()
1137+ old.replaceTransport(oldTransport)
1138+ old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
1139+ rc := d.attachCoordinator(sess, nil, old, true)
1140+ token := sess.attachmentToken(old, oldTransport)
1141+ token.lease = rc.attachmentLease(old)
1142+ old.publishRoleCapability(token)
1143+
1144+ d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1145+ timer := <-clock.timers
1146+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1147+ next.initOverlays()
1148+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1149+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1150+ expectedTransport: next.transportSnapshot(), ready: true,
1151+ })
1152+ require.NoError(t, err)
1153+
1154+ attempted := make(chan bool, 1)
1155+ d.afterDelayedKeyEffectAttempt = func(admitted bool) { attempted <- admitted }
1156+ timer.ch <- time.Time{}
1157+ require.False(t, <-attempted, "stale delayed callback acquired authority after replacement")
1158+ select {
1159+ case got := <-writes:
1160+ t.Fatalf("stale delayed key callback reached the PTY: %q", got)
1161+ default:
1162+ }
1163+}
1164+
1165+func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) {
1166+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1167+ oldTransport := newDatagramTestTransport()
1168+ old.replaceTransport(oldTransport)
1169+ d.attachCoordinator(sess, nil, old, true)
1170+
1171+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1172+ next.initOverlays()
1173+ replaced, err := d.transitionAttachment(attachmentTransitionRequest{
1174+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1175+ expectedTransport: next.transportSnapshot(), ready: true,
1176+ })
1177+ require.NoError(t, err)
1178+ token := replaced.displaced
1179+
1180+ admitted := make(chan struct{})
1181+ releaseGate := make(chan struct{})
1182+ release := releaseTestGate(t, releaseGate)
1183+ afterFrozen := make(chan struct{})
1184+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1185+ close(admitted)
1186+ <-releaseGate
1187+ }
1188+ d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1189+ if action == "" && ac == old {
1190+ close(afterFrozen)
1191+ }
1192+ }
1193+ effectDone := make(chan struct{})
1194+ wantSize := domain.Size{Cols: 100, Rows: 30}
1195+ go func() {
1196+ d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})})
1197+ close(effectDone)
1198+ }()
1199+ <-admitted
1200+
1201+ transitionDone := make(chan error, 1)
1202+ go func() {
1203+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1204+ target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
1205+ expectedTransport: old.transportSnapshot(), ready: true,
1206+ })
1207+ transitionDone <- err
1208+ }()
1209+ <-afterFrozen
1210+ select {
1211+ case err := <-transitionDone:
1212+ t.Fatalf("promotion overtook admitted snatched panel: %v", err)
1213+ default:
1214+ }
1215+ release()
1216+ <-effectDone
1217+ require.NoError(t, <-transitionDone)
1218+ old.sendMu.Lock()
1219+ gotSize := old.size
1220+ old.sendMu.Unlock()
1221+ require.Equal(t, wantSize, gotSize)
1222+}
1223+
1224+func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
1225+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1226+ oldTransport := newDatagramTestTransport()
1227+ old.replaceTransport(oldTransport)
1228+ rc := d.attachCoordinator(sess, nil, old, true)
1229+ token := sess.attachmentToken(old, oldTransport)
1230+ token.lease = rc.attachmentLease(old)
1231+
1232+ admitted := make(chan struct{})
1233+ release := make(chan struct{})
1234+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1235+ close(admitted)
1236+ <-release
1237+ }
1238+ paintDone := make(chan bool, 1)
1239+ go func() { paintDone <- d.firstPaintForTransition(token) }()
1240+ <-admitted
1241+
1242+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1243+ next.initOverlays()
1244+ transitionDone := make(chan error, 1)
1245+ go func() {
1246+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1247+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1248+ expectedTransport: next.transportSnapshot(), ready: true,
1249+ })
1250+ transitionDone <- err
1251+ }()
1252+ select {
1253+ case err := <-transitionDone:
1254+ t.Fatalf("replacement overtook admitted first paint: %v", err)
1255+ case <-time.After(20 * time.Millisecond):
1256+ }
1257+ close(release)
1258+ require.True(t, <-paintDone)
1259+ require.NoError(t, <-transitionDone)
1260+}
1261+
1262+func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
1263+ d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1264+ aTransport := newDatagramTestTransport()
1265+ a.replaceTransport(aTransport)
1266+ d.attachCoordinator(first, nil, a, true)
1267+ first.attachmentToken(a, aTransport)
1268+
1269+ bTransport := newDatagramTestTransport()
1270+ b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1271+ b.initOverlays()
1272+ second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}
1273+ b.setSession(second)
1274+ d.mu.Lock()
1275+ d.sessions[second.id] = second
1276+ d.mu.Unlock()
1277+ d.attachCoordinator(second, nil, b, true)
1278+ second.attachmentToken(b, bTransport)
1279+
1280+ start := make(chan struct{})
1281+ results := make(chan error, 2)
1282+ go func() {
1283+ <-start
1284+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1285+ source: first, target: second, next: a,
1286+ expectedRole: attachmentActive, targetRole: attachmentActive,
1287+ expectedTransport: a.transportSnapshot(), ready: true,
1288+ })
1289+ results <- err
1290+ }()
1291+ go func() {
1292+ <-start
1293+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1294+ source: second, target: first, next: b,
1295+ expectedRole: attachmentActive, targetRole: attachmentActive,
1296+ expectedTransport: b.transportSnapshot(), ready: true,
1297+ })
1298+ results <- err
1299+ }()
1300+ close(start)
1301+
1302+ errs := make([]error, 0, 2)
1303+ for range 2 {
1304+ select {
1305+ case err := <-results:
1306+ errs = append(errs, err)
1307+ case <-time.After(time.Second):
1308+ t.Fatal("reversed transitions deadlocked")
1309+ }
1310+ }
1311+ successes := 0
1312+ for _, err := range errs {
1313+ if err == nil {
1314+ successes++
1315+ }
1316+ }
1317+ require.Equal(t, 1, successes)
1318+}
1319+
1320+func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1321+ d, source, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1322+ aTransport := newDatagramTestTransport()
1323+ a.replaceTransport(aTransport)
1324+ aCoordinator := d.attachCoordinator(source, nil, a, true)
1325+
1326+ bTransport := newDatagramTestTransport()
1327+ b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1328+ b.initOverlays()
1329+ target := &session{
1330+ id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {},
1331+ client: b, snatched: make(map[*attachedClient]struct{}),
1332+ }
1333+ b.setSession(target)
1334+ d.mu.Lock()
1335+ d.sessions[target.id] = target
1336+ d.mu.Unlock()
1337+ d.attachCoordinator(target, nil, b, true)
1338+
1339+ // Give B the earlier immutable identity. The reverse transition therefore
1340+ // freezes B then A, while the old picker-delete path froze A separately
1341+ // before discovering and freezing B.
1342+ b.roleEffects.immutableOrder()
1343+ a.roleEffects.immutableOrder()
1344+ require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load())
1345+
1346+ token := source.attachmentToken(a, aTransport)
1347+ token.lease = aCoordinator.attachmentLease(a)
1348+ a.publishRoleCapability(token)
1349+ effect, admitted := a.beginRoleEffect(token)
1350+ require.True(t, admitted)
1351+ token = effect.roleToken()
1352+
1353+ deleteDiscovered := make(chan []*attachedClient, 1)
1354+ transitionDiscovered := make(chan []*attachedClient, 1)
1355+ releaseDelete := make(chan struct{})
1356+ releaseTransition := make(chan struct{})
1357+ transitionBFrozen := make(chan struct{})
1358+ deleteFrozen := make(chan *attachedClient, 2)
1359+ d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {
1360+ snapshot := append([]*attachedClient(nil), participants...)
1361+ switch action {
1362+ case "picker-delete":
1363+ deleteDiscovered <- snapshot
1364+ <-releaseDelete
1365+ case "reverse-delete-transition":
1366+ transitionDiscovered <- snapshot
1367+ <-releaseTransition
1368+ }
1369+ }
1370+ d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1371+ switch action {
1372+ case "reverse-delete-transition":
1373+ if ac == b {
1374+ close(transitionBFrozen)
1375+ }
1376+ case "picker-delete":
1377+ deleteFrozen <- ac
1378+ }
1379+ }
1380+
1381+ deleteDone := make(chan error, 1)
1382+ go func() {
1383+ deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token)
1384+ }()
1385+ deleteParticipants := <-deleteDiscovered
1386+ require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants)
1387+
1388+ transitionDone := make(chan error, 1)
1389+ go func() {
1390+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1391+ source: target, target: source, next: b,
1392+ expectedRole: attachmentActive, targetRole: attachmentActive,
1393+ expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true,
1394+ })
1395+ if err == nil {
1396+ d.deferAttachmentTransitionCleanups(result)
1397+ }
1398+ transitionDone <- err
1399+ }()
1400+ transitionParticipants := <-transitionDiscovered
1401+ require.ElementsMatch(t, []*attachedClient{a, b}, transitionParticipants)
1402+
1403+ close(releaseTransition)
1404+ select {
1405+ case <-transitionBFrozen:
1406+ case <-time.After(time.Second):
1407+ t.Fatal("reverse transition did not freeze B first")
1408+ }
1409+ close(releaseDelete)
1410+
1411+ select {
1412+ case err := <-transitionDone:
1413+ require.NoError(t, err)
1414+ case <-time.After(time.Second):
1415+ t.Fatal("reverse transition deadlocked with picker deletion")
1416+ }
1417+ select {
1418+ case err := <-deleteDone:
1419+ require.NoError(t, err)
1420+ case <-time.After(time.Second):
1421+ t.Fatal("picker deletion deadlocked with reverse transition")
1422+ }
1423+ frozenByDelete := make([]*attachedClient, 0, 2)
1424+ for range 2 {
1425+ select {
1426+ case ac := <-deleteFrozen:
1427+ frozenByDelete = append(frozenByDelete, ac)
1428+ case <-time.After(time.Second):
1429+ t.Fatal("picker deletion did not freeze every participant")
1430+ }
1431+ }
1432+ require.ElementsMatch(t, []*attachedClient{a, b}, frozenByDelete, "picker deletion must freeze each deduplicated participant exactly once")
1433+ require.Empty(t, deleteFrozen)
1434+
1435+ d.mu.Lock()
1436+ registered := d.sessions[target.id]
1437+ d.mu.Unlock()
1438+ require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle")
1439+ require.Equal(t, attachmentSnatched, source.attachmentRole(a))
1440+ require.Equal(t, attachmentActive, source.attachmentRole(b))
1441+}
1442+
1443+func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) {
1444+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1445+ oldTransport := newDatagramTestTransport()
1446+ old.replaceTransport(oldTransport)
1447+ rc := d.attachCoordinator(sess, nil, old, true)
1448+ token := sess.attachmentToken(old, oldTransport)
1449+ token.lease = rc.attachmentLease(old)
1450+ old.publishRoleCapability(token)
1451+
1452+ frozen := make(chan struct{})
1453+ release := make(chan struct{})
1454+ d.afterRoleEffectsFrozen = func() {
1455+ close(frozen)
1456+ <-release
1457+ }
1458+
1459+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1460+ next.initOverlays()
1461+ done := make(chan error, 1)
1462+ go func() {
1463+ _, err := d.transitionAttachment(attachmentTransitionRequest{
1464+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1465+ expectedTransport: next.transportSnapshot(), ready: true,
1466+ })
1467+ done <- err
1468+ }()
1469+ <-frozen
1470+
1471+ _, ok := old.beginRoleEffect(token)
1472+ require.False(t, ok, "a frozen capability must admit no new effect")
1473+ close(release)
1474+ require.NoError(t, <-done)
1475+}
M internal/usecase/daemon/session.go +439 -83
@@ -49,10 +49,11 @@ type session struct {
49 teardownMu sync.Mutex49 teardownMu sync.Mutex
50 teardownActive bool50 teardownActive bool
51 teardownDone chan struct{}51 teardownDone chan struct{}
52+ moveReservations uint
52 teardownWaiters uint53 teardownWaiters uint
53 teardownChanged chan struct{}54 teardownChanged chan struct{}
54 lifecycleStopOnce sync.Once55 lifecycleStopOnce sync.Once
55- mu sync.Mutex // guards tabs, active, client, restoreDone, clipFiles, and clipboard queue state56+ mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state
56 // restoreDone remains attached to a restored session after it is published in57 // restoreDone remains attached to a restored session after it is published in
57 // the live registry, so racing attaches cannot bypass restoration completion.58 // the live registry, so racing attaches cannot bypass restoration completion.
58 restoreDone chan struct{}59 restoreDone chan struct{}
@@ -60,6 +61,7 @@ type session struct {
60 tabs []*tab61 tabs []*tab
61 active int62 active int
62 client *attachedClient63 client *attachedClient
64+ snatched map[*attachedClient]struct{}
63 clipboardQueue []clipboardForward65 clipboardQueue []clipboardForward
64 clipboardWorkerRunning bool66 clipboardWorkerRunning bool
65 cwd string67 cwd string
@@ -250,13 +252,24 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
250 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)252 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
251 }253 }
252 command, args := d.ptyCommand(env)254 command, args := d.ptyCommand(env)
253- pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)255+ lifetime := d.newPaneProcessLifetime(d.serveCtx)
256+ pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
254 if err != nil {257 if err != nil {
258+ lifetime.abort()
259+ if pty != nil {
260+ _ = pty.Close()
261+ }
255 closeTabs(tabs)262 closeTabs(tabs)
256 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")263 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
257 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)264 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
258 }265 }
259 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)266 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
267+ if !lifetime.publish(tb.focusedPane()) {
268+ lifetime.abort()
269+ _ = pty.Close()
270+ closeTabs(tabs)
271+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err())
272+ }
260 if i < len(names) {273 if i < len(names) {
261 tb.name = names[i]274 tb.name = names[i]
262 }275 }
@@ -296,6 +309,11 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
296 }309 }
297 sess.mruAt.Store(lastUsedSeq)310 sess.mruAt.Store(lastUsedSeq)
298 sess.snapEligible.Store(!ephemeral && name != "")311 sess.snapEligible.Store(!ephemeral && name != "")
312+ // These tabs are still private construction state. Publish every tiled
313+ // owner before the session enters d.sessions or any PTY reader starts.
314+ for _, tb := range tabs {
315+ publishTiledPaneOwners(sess, tb)
316+ }
299 if !ephemeral {317 if !ephemeral {
300 if d.persistEnabled {318 if d.persistEnabled {
301 record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}319 record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
@@ -338,9 +356,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
338 356
339 func closeTabs(tabs []*tab) {357 func closeTabs(tabs []*tab) {
340 for _, tb := range tabs {358 for _, tb := range tabs {
341- for _, p := range tb.panes {359+ tb.closeAllPanes()
342- _ = p.pty.Close()
343- }
344 }360 }
345 }361 }
346 362
@@ -361,55 +377,95 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
361 d.mu.Unlock()377 d.mu.Unlock()
362 return errSessionNameInUse378 return errSessionNameInUse
363 }379 }
364- // The caller owns d.mu. Make the entire ownership handoff atomic with
365- // global routing so it cannot select a client between source removal and
366- // destination publication.
367- d.notices.routingMu.Lock()
368 from.mu.Lock()380 from.mu.Lock()
369 cwd := from.cwd381 cwd := from.cwd
370 term := from.terminal382 term := from.terminal
371 env := copyEnvironment(from.env)383 env := copyEnvironment(from.env)
372 if from.client != ac {384 if from.client != ac {
373 from.mu.Unlock()385 from.mu.Unlock()
374- d.notices.routingMu.Unlock()
375 d.mu.Unlock()386 d.mu.Unlock()
376 return errors.New("client detached")387 return errors.New("client detached")
377 }388 }
378 from.mu.Unlock()389 from.mu.Unlock()
379 390
380 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)391 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
392+ d.mu.Unlock()
381 if err != nil {393 if err != nil {
382- d.notices.routingMu.Unlock()
383- d.mu.Unlock()
384 return err394 return err
385 }395 }
386- from.mu.Lock()396+ // The newly created target is registered before the role transition, but
387- if from.client != ac {397+ // remains headless. Freeze/drain must occur with no daemon/routing/session
388- from.mu.Unlock()398+ // lock held; the transition revalidates both lifecycles before publication.
389- d.notices.routingMu.Unlock()399+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
390- d.mu.Unlock()400+ source: from,
401+ target: newSess,
402+ next: ac,
403+ expectedRole: attachmentActive,
404+ targetRole: attachmentActive,
405+ expectedTransport: ac.transportSnapshot(),
406+ ready: true,
407+ })
408+ if err != nil {
391 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)409 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
392- return errors.New("client detached")410+ return err
393 }411 }
394- from.client = nil
395- ac.setSession(nil)
396- from.mu.Unlock()
397-
398- // Prepare source invalidation, output rebase, and destination coordinator
399- // identity before publishing the destination attachment.
400- cleanups := d.handoffCoordinator(from, newSess, nil, ac)
401- ac.setSession(newSess)
402- newSess.mu.Lock()
403- newSess.client = ac
404- newSess.mu.Unlock()
405 412
406 d.touchMRU(newSess)413 d.touchMRU(newSess)
407 ac.recordPreviousSession(from)414 ac.recordPreviousSession(from)
408 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)415 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
409- d.notices.routingMu.Unlock()416+ d.deferAttachmentTransitionCleanups(transition)
410- d.mu.Unlock()417+ d.firstPaintForTransition(transition.published)
411- finishRenderLifecycleCleanups(cleanups)418+ return nil
412- d.firstPaint(newSess, ac, sz)419+}
420+
421+// createSessionAndSwitchForRole creates no target until the initiating role
422+// ticket has ended and the exact source capability is frozen and revalidated.
423+func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name string) error {
424+ if name == "" {
425+ return errSessionNameRequired
426+ }
427+ if err := domain.ValidateSessionName(name); err != nil {
428+ return err
429+ }
430+ if token.sess == nil || token.ac == nil || token.effect == nil || token.role != attachmentActive {
431+ return errAttachmentTransition
432+ }
433+ token.effect.bindActionEnd(d, "create-session")
434+ token.effect.End()
435+
436+ var created *session
437+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
438+ source: token.sess, next: token.ac,
439+ expectedRole: attachmentActive, targetRole: attachmentActive,
440+ expectedTransport: token.transport, sourceToken: &token, action: "create-session",
441+ copySourceEnvironment: true, ready: true,
442+ createTargetLocked: func() (*session, error) {
443+ if d.closing {
444+ return nil, errors.New("daemon is shutting down")
445+ }
446+ if d.nameLiveOrStoppedLocked(name) {
447+ return nil, errSessionNameInUse
448+ }
449+ token.sess.mu.Lock()
450+ cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env)
451+ token.sess.mu.Unlock()
452+ var createErr error
453+ created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)
454+ return created, createErr
455+ },
456+ })
457+ if err != nil {
458+ if created != nil {
459+ _ = d.killSession(created, ports.ReasonSessionKilled, true)
460+ }
461+ return err
462+ }
463+
464+ d.touchMRU(created)
465+ token.ac.recordPreviousSession(token.sess)
466+ d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
467+ d.deferAttachmentTransitionCleanups(transition)
468+ d.firstPaintForTransition(transition.published)
413 return nil469 return nil
414 }470 }
415 471
@@ -427,8 +483,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
427 return err483 return err
428 }484 }
429 command, args := d.ptyCommand(env)485 command, args := d.ptyCommand(env)
430- pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)486+ lifetime := d.newPaneProcessLifetime(sess.ctx)
487+ pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
431 if err != nil {488 if err != nil {
489+ lifetime.abort()
490+ if pty != nil {
491+ _ = pty.Close()
492+ }
432 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")493 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
433 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)494 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
434 }495 }
@@ -447,14 +508,22 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
447 d.mu.Lock()508 d.mu.Lock()
448 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {509 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
449 d.mu.Unlock()510 d.mu.Unlock()
511+ lifetime.abort()
450 _ = pty.Close()512 _ = pty.Close()
451 return errors.New("daemon: session closed")513 return errors.New("daemon: session closed")
452 }514 }
453 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)515 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
454- for _, p := range tb.panes {
455- p.ctx, p.cancel = context.WithCancel(tb.ctx)
456- }
457 sess.mu.Lock()516 sess.mu.Lock()
517+ p := tb.focusedPane()
518+ if !lifetime.publish(p) {
519+ sess.mu.Unlock()
520+ d.mu.Unlock()
521+ lifetime.abort()
522+ _ = pty.Close()
523+ return errors.New("daemon: session closed")
524+ }
525+ // Publish ownership before appending the tab to the visible session.
526+ publishPaneOwner(p, sess, tb, 0)
458 sess.tabs = append(sess.tabs, tb)527 sess.tabs = append(sess.tabs, tb)
459 sess.active = len(sess.tabs) - 1528 sess.active = len(sess.tabs) - 1
460 tabIndex := sess.active529 tabIndex := sess.active
@@ -519,12 +588,7 @@ func (tb *tab) closeAllPanes() {
519 panes = append(panes, floating)588 panes = append(panes, floating)
520 }589 }
521 for _, p := range panes {590 for _, p := range panes {
522- if p.cancel != nil {591+ closePaneProcess(p)
523- p.cancel()
524- }
525- if p.pty != nil {
526- _ = p.pty.Close()
527- }
528 }592 }
529 }593 }
530 594
@@ -573,15 +637,25 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
573 }637 }
574 638
575 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {639 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
576- if p != nil {640+ if p == nil {
577- sess.mu.Lock()641+ return
578- name := sess.name642+ }
579- sess.mu.Unlock()643+ owner := p.ownerSnapshot()
580- d.log.Info("pane created", "session", name, "pane", p.id)644+ if owner == nil {
645+ // Teardown may win after publication but before reader startup. In that
646+ // case the process is already closed and no ownerless reader may start.
647+ return
648+ }
649+ if owner.session != sess || owner.tab != tb {
650+ panic("daemon: starting pane reader for a different published owner")
581 }651 }
652+ sess.mu.Lock()
653+ name := sess.name
654+ sess.mu.Unlock()
655+ d.log.Info("pane created", "session", name, "pane", p.id)
582 // Scheduler ownership was removed; this launch creates exactly one reader.656 // Scheduler ownership was removed; this launch creates exactly one reader.
583 d.sessWg.Add(1)657 d.sessWg.Add(1)
584- go d.ptyReader(sess, tb, p)658+ go d.readPanePTY(p)
585 }659 }
586 660
587 // attachClient makes ac the session's current client, displacing any prior one661 // attachClient makes ac the session's current client, displacing any prior one
@@ -598,13 +672,79 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
598 return false672 return false
599 }673 }
600 674
601-// detachIfCurrent serializes client removal with global notice routing. The675+// detachIfCurrent publishes terminal role invalidation through the attachment
602-// routing lock is released before the caller performs teardown, so it covers676+// gate. Freeze/drain happens before routing or session locks are acquired.
603-// only attachment ownership and is never held across transport work.
604 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {677 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
678+ if sess == nil || ac == nil {
679+ return false
680+ }
681+ frozen := freezeRoleEffectGates(ac)
682+ defer frozen.unfreeze()
683+ if d.afterDetachRoleEffectsFrozen != nil {
684+ d.afterDetachRoleEffectsFrozen()
685+ }
605 d.notices.routingMu.Lock()686 d.notices.routingMu.Lock()
606- defer d.notices.routingMu.Unlock()687+ sess.mu.Lock()
607- return sess.detachIfCurrent(ac)688+ current := sess.client == ac && ac.currentSession() == sess
689+ if current {
690+ sess.client = nil
691+ ac.roleGeneration.Add(1)
692+ ac.setSession(nil)
693+ ac.invalidateFrozenRoleCapability()
694+ }
695+ sess.mu.Unlock()
696+ d.notices.routingMu.Unlock()
697+ return current
698+}
699+
700+// detachIfRoleCurrent invalidates only the exact role generation and transport
701+// whose admitted send failed. A queued stale error cannot detach a later role
702+// publication or a rebound transport on the same attachment object.
703+func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool {
704+ return d.detachIfRoleCurrentUntil(token, nil)
705+}
706+
707+func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool {
708+ if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
709+ return false
710+ }
711+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)
712+ defer frozen.unfreeze()
713+ if !frozen.acquired || !frozen.drained {
714+ return false
715+ }
716+ if d.afterDetachRoleEffectsFrozen != nil {
717+ d.afterDetachRoleEffectsFrozen()
718+ }
719+ d.mu.Lock()
720+ if d.sessions[token.sess.id] != token.sess {
721+ d.mu.Unlock()
722+ return false
723+ }
724+ d.notices.routingMu.Lock()
725+ token.sess.mu.Lock()
726+ coordinator := token.sess.renderCoordinator()
727+ if coordinator != nil {
728+ coordinator.mu.Lock()
729+ }
730+ req := attachmentTransitionRequest{
731+ source: token.sess, next: token.ac, expectedRole: token.role,
732+ expectedTransport: token.transport,
733+ }
734+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
735+ if current {
736+ token.sess.client = nil
737+ token.ac.roleGeneration.Add(1)
738+ token.ac.setSession(nil)
739+ token.ac.invalidateFrozenRoleCapability()
740+ }
741+ if coordinator != nil {
742+ coordinator.mu.Unlock()
743+ }
744+ token.sess.mu.Unlock()
745+ d.notices.routingMu.Unlock()
746+ d.mu.Unlock()
747+ return current
608 }748 }
609 749
610 func (s *session) activeTab() *tab {750 func (s *session) activeTab() *tab {
@@ -921,7 +1061,145 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro
921 }1061 }
922 1062
923 func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {1063 func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
924- return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil)1064+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil)
1065+}
1066+
1067+type sessionKillAdmission struct {
1068+ token attachmentRoleToken
1069+ action string
1070+}
1071+
1072+type sessionKillParticipants struct {
1073+ active *attachedClient
1074+ snatched map[*attachedClient]struct{}
1075+ transports map[*attachedClient]transportSnapshot
1076+ roleGates []*attachedClient
1077+ interrupts []roleTransportInterrupt
1078+ source *session
1079+ sourceToken *attachmentRoleToken
1080+ target *session
1081+}
1082+
1083+func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error {
1084+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
1085+}
1086+
1087+// snapshotSessionKillParticipants captures role membership under the canonical
1088+// architecture locks, but releases every lock before any role gate is frozen or
1089+// drained. The initiator is part of the same deduplicated global gate set even
1090+// when it belongs to a different source session.
1091+func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
1092+ snapshot := sessionKillParticipants{target: target}
1093+ if admission != nil {
1094+ token := admission.token
1095+ if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
1096+ return sessionKillParticipants{}, false
1097+ }
1098+ snapshot.source = token.sess
1099+ snapshot.sourceToken = &admission.token
1100+ }
1101+
1102+ d.mu.Lock()
1103+ if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1104+ d.mu.Unlock()
1105+ return sessionKillParticipants{}, false
1106+ }
1107+ d.notices.routingMu.Lock()
1108+ source := snapshot.source
1109+ if source == nil {
1110+ source = target
1111+ }
1112+ unlockSessions := lockAttachmentSessions(source, target)
1113+ snapshot.active = target.client
1114+ snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))
1115+ snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched))
1116+ if snapshot.active != nil {
1117+ snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
1118+ }
1119+ for ac := range target.snatched {
1120+ snapshot.snatched[ac] = struct{}{}
1121+ snapshot.transports[ac] = ac.transportSnapshot()
1122+ }
1123+ unlockSessions()
1124+ d.notices.routingMu.Unlock()
1125+ d.mu.Unlock()
1126+
1127+ seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched))
1128+ add := func(ac *attachedClient) {
1129+ if ac == nil {
1130+ return
1131+ }
1132+ if _, duplicate := seen[ac]; duplicate {
1133+ return
1134+ }
1135+ seen[ac] = struct{}{}
1136+ snapshot.roleGates = append(snapshot.roleGates, ac)
1137+ }
1138+ if admission != nil {
1139+ add(admission.token.ac)
1140+ }
1141+ add(snapshot.active)
1142+ for ac := range snapshot.snatched {
1143+ add(ac)
1144+ }
1145+ for ac, transport := range snapshot.transports {
1146+ if transport.transport != nil {
1147+ snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})
1148+ }
1149+ }
1150+ return snapshot, true
1151+}
1152+
1153+// sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1154+// target-membership validation while every discovered role gate is frozen.
1155+func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1156+ d.mu.Lock()
1157+ defer d.mu.Unlock()
1158+ if d.sessions[snapshot.target.id] != snapshot.target ||
1159+ snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1160+ return false
1161+ }
1162+ d.notices.routingMu.Lock()
1163+ defer d.notices.routingMu.Unlock()
1164+ source := snapshot.source
1165+ if source == nil {
1166+ source = snapshot.target
1167+ }
1168+ sourceCoordinator := source.renderCoordinator()
1169+ targetCoordinator := snapshot.target.renderCoordinator()
1170+ unlockSessions := lockAttachmentSessions(source, snapshot.target)
1171+ defer unlockSessions()
1172+ unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator)
1173+ defer unlockCoordinators()
1174+ return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator)
1175+}
1176+
1177+// sessionKillParticipantsCurrentLocked requires the ordered source/target
1178+// session and coordinator locks.
1179+func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1180+ target := snapshot.target
1181+ if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) {
1182+ return false
1183+ }
1184+ for ac := range snapshot.snatched {
1185+ if _, current := target.snatched[ac]; !current {
1186+ return false
1187+ }
1188+ }
1189+ for ac, transport := range snapshot.transports {
1190+ if !ac.transportSnapshotCurrent(transport) {
1191+ return false
1192+ }
1193+ }
1194+ if snapshot.sourceToken == nil {
1195+ return true
1196+ }
1197+ token := *snapshot.sourceToken
1198+ req := attachmentTransitionRequest{
1199+ source: token.sess, next: token.ac, expectedRole: token.role,
1200+ expectedTransport: token.transport,
1201+ }
1202+ return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
925 }1203 }
926 1204
927 // stopInMemoryLifecycle cancels every session producer and closes every PTY1205 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -933,7 +1211,9 @@ func (s *session) stopInMemoryLifecycle() {
933 }1211 }
934 s.lifecycleStopOnce.Do(func() {1212 s.lifecycleStopOnce.Do(func() {
935 s.stopFloatingLaunches()1213 s.stopFloatingLaunches()
936- s.cancel()1214+ if s.cancel != nil {
1215+ s.cancel()
1216+ }
937 s.mu.Lock()1217 s.mu.Lock()
938 tabs := append([]*tab(nil), s.tabs...)1218 tabs := append([]*tab(nil), s.tabs...)
939 s.mu.Unlock()1219 s.mu.Unlock()
@@ -970,7 +1250,7 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
970 }1250 }
971 1251
972 s.teardownMu.Lock()1252 s.teardownMu.Lock()
973- if !s.teardownActive {1253+ if !s.teardownActive && s.moveReservations == 0 {
974 if deadline != nil {1254 if deadline != nil {
975 select {1255 select {
976 case <-deadline.Done():1256 case <-deadline.Done():
@@ -985,17 +1265,17 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
985 s.teardownMu.Unlock()1265 s.teardownMu.Unlock()
986 return true1266 return true
987 }1267 }
988- done := s.teardownDone
989 s.teardownWaiters++1268 s.teardownWaiters++
990 s.signalTeardownChangedLocked()1269 s.signalTeardownChangedLocked()
1270+ changed := s.teardownChangeLocked()
991 s.teardownMu.Unlock()1271 s.teardownMu.Unlock()
992 1272
993 timedOut := false1273 timedOut := false
994 if deadline == nil {1274 if deadline == nil {
995- <-done1275+ <-changed
996 } else {1276 } else {
997 select {1277 select {
998- case <-done:1278+ case <-changed:
999 case <-deadline.Done():1279 case <-deadline.Done():
1000 timedOut = true1280 timedOut = true
1001 }1281 }
@@ -1023,10 +1303,52 @@ func (s *session) finishTeardown() {
1023 // terminal checkpoint and coordinator join. A timed-out repository call keeps1303 // terminal checkpoint and coordinator join. A timed-out repository call keeps
1024 // only immutable capture state; it never observes closed worker channels or1304 // only immutable capture state; it never observes closed worker channels or
1025 // session-owned cache that teardown has released.1305 // session-owned cache that teardown has released.
1026-func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error {1306+func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error {
1027 if sess == nil {1307 if sess == nil {
1028 return nil1308 return nil
1029 }1309 }
1310+ // Discover every role whose membership can be invalidated before freezing
1311+ // any one of them. The common gate helper deduplicates and freezes the whole
1312+ // immutable identity set in the same global order used by transitions.
1313+ participants, ok := d.snapshotSessionKillParticipants(sess, admission)
1314+ if !ok {
1315+ return nil
1316+ }
1317+ if admission != nil {
1318+ d.endActionRoleEffect(admission.token.effect, admission.action)
1319+ }
1320+ action := ""
1321+ if admission != nil {
1322+ action = admission.action
1323+ }
1324+ if d.afterRoleEffectParticipantsSnapshotted != nil {
1325+ d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates)
1326+ }
1327+ var drainDone func() <-chan struct{}
1328+ var drainDeadline *roleEffectDrainDeadline
1329+ if deadline != nil {
1330+ drainDone = deadline.Done
1331+ } else {
1332+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
1333+ drainDone = drainDeadline.Done
1334+ defer drainDeadline.stop()
1335+ }
1336+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1337+ if d.afterRoleEffectGateFrozen != nil {
1338+ d.afterRoleEffectGateFrozen(action, ac)
1339+ }
1340+ }}, participants.roleGates...)
1341+ defer func() { frozen.unfreeze() }()
1342+ if !frozen.acquired {
1343+ // The acquisition helper has already rolled back exactly the gates it
1344+ // owned. Do not enter architecture locks or publish terminal ownership
1345+ // changes unless the complete canonical participant set is frozen.
1346+ return nil
1347+ }
1348+ if !d.sessionKillParticipantsCurrent(participants) {
1349+ return nil
1350+ }
1351+
1030 // Pane EOF, explicit close, and daemon shutdown can all converge on the same1352 // Pane EOF, explicit close, and daemon shutdown can all converge on the same
1031 // session. Only one owner may create the terminal mutation and checkpoint;1353 // session. Only one owner may create the terminal mutation and checkpoint;
1032 // later callers observe the completed registry transition instead of1354 // later callers observe the completed registry transition instead of
@@ -1119,21 +1441,62 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1119 sess.snapshotMu.Unlock()1441 sess.snapshotMu.Unlock()
1120 }1442 }
1121 }1443 }
1444+ // Revalidate the exact source capability, target lifecycle, and active plus
1445+ // snatched memberships under canonical architecture locks immediately before
1446+ // publication. A changed preflight aborts without mutating either lifecycle.
1122 d.mu.Lock()1447 d.mu.Lock()
1123- if _, ok := d.sessions[sess.id]; !ok {1448+ if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
1124 d.mu.Unlock()1449 d.mu.Unlock()
1125 return nil1450 return nil
1126 }1451 }
1452+ d.notices.routingMu.Lock()
1453+ source := participants.source
1454+ if source == nil {
1455+ source = sess
1456+ }
1457+ sourceCoordinator := source.renderCoordinator()
1458+ targetCoordinator := sess.renderCoordinator()
1459+ unlockSessions := lockAttachmentSessions(source, sess)
1460+ unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator)
1461+ if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) {
1462+ unlockCoordinators()
1463+ unlockSessions()
1464+ d.notices.routingMu.Unlock()
1465+ d.mu.Unlock()
1466+ return nil
1467+ }
1468+
1469+ // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1470+ // here. Registry removal and every role invalidation are one publication
1471+ // while the complete immutable gate set remains frozen.
1127 delete(d.sessions, sess.id)1472 delete(d.sessions, sess.id)
1128 d.clearBarScriptsForSession(sess.id)1473 d.clearBarScriptsForSession(sess.id)
1129- d.purgeParkedForSessionLocked(sess)1474+ parkedRetirements := d.purgeParkedForSessionLocked(sess)
1130- sess.mu.Lock()1475+ detachedActive := sess.client
1476+ attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1477+ capture := func(attached *attachedClient) {
1478+ if attached == nil {
1479+ return
1480+ }
1481+ attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1482+ attached.roleGeneration.Add(1)
1483+ attached.setSession(nil)
1484+ attached.invalidateFrozenRoleCapability()
1485+ }
1486+ capture(sess.client)
1487+ for snatched := range sess.snatched {
1488+ capture(snatched)
1489+ }
1490+ sess.client = nil
1491+ clear(sess.snatched)
1131 stoppedName := sess.name1492 stoppedName := sess.name
1132 stoppedCwd := sess.cwd1493 stoppedCwd := sess.cwd
1133 createdAt := sess.createdAt1494 createdAt := sess.createdAt
1134- tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames1495+ tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames
1135 ephemeral := sess.ephemeral1496 ephemeral := sess.ephemeral
1136- sess.mu.Unlock()1497+ unlockCoordinators()
1498+ unlockSessions()
1499+ d.notices.routingMu.Unlock()
1137 if !ephemeral {1500 if !ephemeral {
1138 stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}1501 stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1139 d.stopped[stoppedName] = stopped1502 d.stopped[stoppedName] = stopped
@@ -1146,18 +1509,12 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1146 d.closing = true1509 d.closing = true
1147 }1510 }
1148 d.mu.Unlock()1511 d.mu.Unlock()
1512+ finishParkedAttachmentRetirements(parkedRetirements)
1513+ frozen.unfreeze()
1514+ frozen = frozenRoleEffectGates{}
1149 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1515 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1150 1516
1151 var purgeErr error1517 var purgeErr error
1152-
1153- // A global route that selected this attachment must publish before terminal
1154- // teardown makes it stale.
1155- d.notices.routingMu.Lock()
1156- sess.mu.Lock()
1157- ac := sess.client
1158- sess.client = nil
1159- sess.mu.Unlock()
1160- d.notices.routingMu.Unlock()
1161 if rc := sess.renderCoordinator(); rc != nil {1518 if rc := sess.renderCoordinator(); rc != nil {
1162 // Terminal teardown has two phases: this session owner first prevents any1519 // Terminal teardown has two phases: this session owner first prevents any
1163 // new worker registration and detaches all tokens, then stops and waits1520 // new worker registration and detaches all tokens, then stops and waits
@@ -1165,11 +1522,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1165 rc.beginSessionTeardown().finish()1522 rc.beginSessionTeardown().finish()
1166 rc.waitForTimerWorkers()1523 rc.waitForTimerWorkers()
1167 }1524 }
1168- if ac != nil {1525+ for _, attachment := range attachments {
1169- d.unregisterPreview(ac)1526+ d.unregisterPreview(attachment.ac)
1170- ac.clearPreviousSession()1527+ attachment.ac.clearPreviousSession()
1171- ac.setSession(nil)1528+ attachment.ac.clearCaptureFrames()
1172- ac.clearCaptureFrames()
1173 }1529 }
1174 1530
1175 // Prevent queued launches from entering Open, cancel the parent context, and1531 // Prevent queued launches from entering Open, cancel the parent context, and
@@ -1183,7 +1539,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1183 sess.mu.Unlock()1539 sess.mu.Unlock()
1184 for _, tb := range tabs {1540 for _, tb := range tabs {
1185 d.clearDestroyedTabPreview(tb)1541 d.clearDestroyedTabPreview(tb)
1186- d.teardownFloating(tb, ac)1542+ d.teardownFloating(tb, detachedActive)
1187 }1543 }
1188 for _, path := range clipFiles {1544 for _, path := range clipFiles {
1189 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {1545 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -1212,8 +1568,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1212 d.repaintAllAttachedClients()1568 d.repaintAllAttachedClients()
1213 }1569 }
1214 1570
1215- if ac != nil {1571+ for _, attachment := range attachments {
1216- d.notifyDetachedAsync(ac, reason)1572+ d.notifyDetachedSnapshotAsync(attachment, reason)
1217 }1573 }
1218 return errors.Join(purgeErr, terminalSnapshotErr)1574 return errors.Join(purgeErr, terminalSnapshotErr)
1219 }1575 }
M internal/usecase/daemon/session_back.go +12 -0
@@ -4,6 +4,18 @@ import "github.com/bnema/vev/internal/usecase/picker"
4 4
5 // backSession toggles this attachment between its current session and the5 // backSession toggles this attachment between its current session and the
6 // immediately preceding successfully activated session.6 // immediately preceding successfully activated session.
7+func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
8+ if d == nil || token.sess == nil || token.ac == nil {
9+ return nil
10+ }
11+ target := token.ac.previousSession.Get()
12+ if target == nil || target == token.sess || d.sessionByID(target.id) != target {
13+ token.ac.clearPreviousSessionIf(target)
14+ return nil
15+ }
16+ return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")
17+}
18+
7 func (d *Daemon) backSession(current *session, ac *attachedClient) {19 func (d *Daemon) backSession(current *session, ac *attachedClient) {
8 if d == nil || current == nil || ac == nil {20 if d == nil || current == nil || ac == nil {
9 return21 return
A internal/usecase/daemon/session_move_locator_test.go +45 -0
@@ -0,0 +1,45 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) {
14+ sess := &session{
15+ id: domain.SessionID("session-id"),
16+ incarnation: domain.IncarnationID{1},
17+ name: "initial",
18+ }
19+ start := make(chan struct{})
20+ var wg sync.WaitGroup
21+ wg.Add(2)
22+ go func() {
23+ defer wg.Done()
24+ <-start
25+ for i := range 10_000 {
26+ sess.mu.Lock()
27+ sess.name = fmt.Sprintf("session-%d", i)
28+ sess.mu.Unlock()
29+ }
30+ }()
31+ go func() {
32+ defer wg.Done()
33+ <-start
34+ for range 10_000 {
35+ _ = sessionMoveLocator(sess)
36+ }
37+ }()
38+ close(start)
39+ wg.Wait()
40+
41+ locator := sessionMoveLocator(sess)
42+ require.Equal(t, sess.id, locator.ID)
43+ require.Equal(t, sess.incarnation, locator.Incarnation)
44+ require.Equal(t, "session-9999", locator.Name)
45+}
M internal/usecase/daemon/session_test.go +10 -7
@@ -283,7 +283,7 @@ func TestEphemeralNumberingReuse(t *testing.T) {
283 283
284 // --- attach replace ---------------------------------------------------------284 // --- attach replace ---------------------------------------------------------
285 285
286-func TestAttachReplaceDetachesOld(t *testing.T) {286+func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
287 p, releasePTY := newBlockingPTY(t)287 p, releasePTY := newBlockingPTY(t)
288 d := newTestDaemon(t, newFactory(t, p), stubClock{})288 d := newTestDaemon(t, newFactory(t, p), stubClock{})
289 289
@@ -295,21 +295,24 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
295 awaitFrame(t, sendsA, ports.MsgOutput)295 awaitFrame(t, sendsA, ports.MsgOutput)
296 sess := firstSession(d)296 sess := firstSession(d)
297 require.NotNil(t, sess)297 require.NotNil(t, sess)
298+ sess.mu.Lock()
299+ acA := sess.client
300+ sess.mu.Unlock()
301+ require.NotNil(t, acA)
298 302
299 // Client B attaches to the same session, displacing A.303 // Client B attaches to the same session, displacing A.
300 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
301 hg.Go(func() { d.handleConn(trB) })305 hg.Go(func() { d.handleConn(trB) })
302 awaitFrame(t, sendsB, ports.MsgWelcome)306 awaitFrame(t, sendsB, ports.MsgWelcome)
307+ d.attachmentCleanupWg.Wait()
303 308
304- // A is notified it was detached.309+ // B is now the sole active client while A remains a live snatched member.
305- dA := awaitFrame(t, sendsA, ports.MsgDetached)
306- det, _ := ports.UnmarshalDetached(dA.Payload)
307- require.Equal(t, ports.ReasonReplaced, det.Reason)
308-
309- // B is now the current client.
310 sess.mu.Lock()310 sess.mu.Lock()
311 require.NotNil(t, sess.client)311 require.NotNil(t, sess.client)
312+ require.NotSame(t, acA, sess.client)
313+ require.Contains(t, sess.snatched, acA)
312 sess.mu.Unlock()314 sess.mu.Unlock()
315+ require.Same(t, sess, acA.currentSession())
313 316
314 releaseA()317 releaseA()
315 releaseB()318 releaseB()
M internal/usecase/daemon/snapshot_restore.go +17 -4
@@ -261,16 +261,23 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
261 }261 }
262 }262 }
263 command, args := d.ptyCommand(restoreEnv)263 command, args := d.ptyCommand(restoreEnv)
264- pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))264+ lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
265+ pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
265 if err != nil {266 if err != nil {
267+ lifetime.abort()
268+ if pty != nil {
269+ _ = pty.Close()
270+ }
266 return nil, err271 return nil, err
267 }272 }
268 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))273 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))
269- p.ctx, p.cancel = context.WithCancel(tabCtx)274+ if !lifetime.publish(p) {
275+ _ = pty.Close()
276+ return nil, lifetime.ctx.Err()
277+ }
270 p.rect = contentRect278 p.rect = contentRect
271 if err := restorePaneTerminal(p, paneSnap); err != nil {279 if err := restorePaneTerminal(p, paneSnap); err != nil {
272- p.cancel()280+ closePaneProcess(p)
273- _ = pty.Close()
274 return nil, err281 return nil, err
275 }282 }
276 if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {283 if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
@@ -284,6 +291,12 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
284 291
285 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
286 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}293 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294+ // Restored tabs remain private until persistAndRegisterRestoredSession.
295+ // Initialize owners now so registration and reader startup cannot expose an
296+ // ownerless pane.
297+ for _, tb := range tabs {
298+ publishTiledPaneOwners(sess, tb)
299+ }
287 sess.snapEligible.Store(true)300 sess.snapEligible.Store(true)
288 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {301 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
289 sess.cwd = snap.Tabs[0].Panes[0].Cwd302 sess.cwd = snap.Tabs[0].Panes[0].Cwd
A internal/usecase/daemon/snatched_cleanup.go +171 -0
@@ -0,0 +1,171 @@
1+package daemon
2+
3+// removeSnatchedAttachment removes exactly one role generation and transport
4+// from session routing. It never touches the active attachment or closes a
5+// transport, allowing callers that already interrupted a blocked send to avoid
6+// closing the same link twice.
7+func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
8+ return d.unrouteSnatchedAttachment(token, true)
9+}
10+
11+func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
12+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
13+ return false
14+ }
15+ frozen := freezeRoleEffectGates(token.ac)
16+ defer frozen.unfreeze()
17+ d.notices.routingMu.Lock()
18+ token.sess.mu.Lock()
19+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
20+ token.ac.roleGeneration.Load() == token.generation &&
21+ token.ac.transportSnapshotCurrent(token.transport)
22+ if current {
23+ delete(token.sess.snatched, token.ac)
24+ token.ac.roleGeneration.Add(1)
25+ token.ac.invalidateFrozenRoleCapability()
26+ }
27+ token.sess.mu.Unlock()
28+ d.notices.routingMu.Unlock()
29+ if !current {
30+ return false
31+ }
32+
33+ token.ac.clearSnatchedInput()
34+ d.unregisterPreview(token.ac)
35+ if terminal {
36+ token.ac.clearPreviousSession()
37+ }
38+ token.ac.setSession(nil)
39+ token.ac.clearCaptureFrames()
40+ return true
41+}
42+
43+// parkSnatchedAttachment removes only the exact lost snatched incarnation and
44+// retains its resume identity for a role-preserving reconnect.
45+func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
46+ if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {
47+ return false
48+ }
49+ if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
50+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
51+ return true
52+ }
53+ token.ac.clearPreviousSession()
54+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
55+ return false
56+}
57+
58+func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
59+ if d.parkSnatchedAttachment(token) {
60+ return true
61+ }
62+ return d.dropSnatchedAttachment(token)
63+}
64+
65+// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
66+// transport that was closed to drain an admitted send. It waits on the gate's
67+// condition rather than ordinary effect admission, then removes only the exact
68+// published snatched incarnation. Attachment-local cleanup runs without routing
69+// or session locks while the terminal capability remains frozen.
70+func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
71+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
72+ return false
73+ }
74+ frozen := freezeRoleEffectGates(token.ac)
75+ defer frozen.unfreeze()
76+
77+ d.notices.routingMu.Lock()
78+ token.sess.mu.Lock()
79+ current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
80+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
81+ token.ac.transportSnapshotCurrent(token.transport)
82+ token.sess.mu.Unlock()
83+ d.notices.routingMu.Unlock()
84+ if !current {
85+ return false
86+ }
87+
88+ // The frozen gate prevents a promotion from publishing between overlay
89+ // cleanup and terminal registry publication.
90+ d.clearForSnatch(token)
91+
92+ d.notices.routingMu.Lock()
93+ token.sess.mu.Lock()
94+ current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
95+ token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
96+ token.ac.transportSnapshotCurrent(token.transport)
97+ if current {
98+ delete(token.sess.snatched, token.ac)
99+ token.ac.roleGeneration.Add(1)
100+ token.ac.invalidateFrozenRoleCapability()
101+ }
102+ token.sess.mu.Unlock()
103+ d.notices.routingMu.Unlock()
104+ if !current {
105+ return false
106+ }
107+
108+ token.ac.setSession(nil)
109+ if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
110+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
111+ token.ac.clearSnatchedInput()
112+ d.unregisterPreview(token.ac)
113+ return true
114+ }
115+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
116+ token.ac.clearSnatchedInput()
117+ d.unregisterPreview(token.ac)
118+ token.ac.clearPreviousSession()
119+ token.ac.clearCaptureFrames()
120+ return true
121+}
122+
123+// dropSnatchedAttachment removes and closes only the captured snatched link.
124+func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
125+ if !d.removeSnatchedAttachment(token) {
126+ return false
127+ }
128+ _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
129+ return true
130+}
131+
132+// deferAttachmentTransitionCleanups retires coordinator timers and accounting
133+// after ownership publication. Cleanup never gates the attaching handshake. A
134+// displaced render that already owns sendMu is interrupted by closing only its
135+// captured transport; an idle healthy snatched transport remains connected and
136+// receives a dependency-free reset panel.
137+func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
138+ if token := result.displaced; token.ac != nil && token.transport.transport != nil {
139+ blockedRender := result.displacedInterrupted
140+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
141+ blockedRender = !token.ac.sendMu.TryLock()
142+ if !blockedRender {
143+ token.ac.sendMu.Unlock()
144+ }
145+ }
146+ d.attachmentCleanupWg.Go(func() {
147+ if d.afterDisplacedCleanupStarted != nil {
148+ d.afterDisplacedCleanupStarted()
149+ }
150+ if blockedRender {
151+ // This path owns terminal cleanup independently of ordinary role
152+ // admission: a stale send-error detach may transiently freeze the gate.
153+ _ = token.ac.closeCapturedTransport(token.transport.transport)
154+ d.cleanupInterruptedSnatchedAttachment(token)
155+ return
156+ }
157+ ticket, admitted := token.ac.beginRoleEffect(token)
158+ if !admitted {
159+ return
160+ }
161+ defer ticket.End()
162+ if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
163+ ticket.End()
164+ d.parkOrDropSnatchedAttachment(token)
165+ }
166+ })
167+ }
168+ for _, cleanup := range result.cleanups {
169+ d.attachmentCleanupWg.Go(cleanup.finish)
170+ }
171+}
A internal/usecase/daemon/snatched_client.go +107 -0
@@ -0,0 +1,107 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+type initialSnatchedPanelAttempt struct {
10+ done chan struct{}
11+ err error
12+}
13+
14+func (ac *attachedClient) claimInitialSnatchedPanel(generation uint64) (*initialSnatchedPanelAttempt, bool) {
15+ ac.initialSnatchedMu.Lock()
16+ defer ac.initialSnatchedMu.Unlock()
17+ if generation < ac.initialSnatchedGeneration {
18+ return nil, false
19+ }
20+ if generation == ac.initialSnatchedGeneration && ac.initialSnatchedAttempt != nil {
21+ return ac.initialSnatchedAttempt, false
22+ }
23+ attempt := &initialSnatchedPanelAttempt{done: make(chan struct{})}
24+ ac.initialSnatchedGeneration = generation
25+ ac.initialSnatchedAttempt = attempt
26+ return attempt, true
27+}
28+
29+func (ac *attachedClient) initialSnatchedPanelClaimed(generation uint64) bool {
30+ ac.initialSnatchedMu.Lock()
31+ defer ac.initialSnatchedMu.Unlock()
32+ return ac.initialSnatchedGeneration == generation && ac.initialSnatchedAttempt != nil
33+}
34+
35+func (ac *attachedClient) completeInitialSnatchedPanel(attempt *initialSnatchedPanelAttempt, err error) {
36+ ac.initialSnatchedMu.Lock()
37+ attempt.err = err
38+ close(attempt.done)
39+ ac.initialSnatchedMu.Unlock()
40+}
41+
42+func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *roleEffectTicket) error {
43+ attempt, owner := token.ac.claimInitialSnatchedPanel(token.generation)
44+ if attempt == nil {
45+ return errSnatchedOutputStale
46+ }
47+ if !owner {
48+ <-attempt.done
49+ return attempt.err
50+ }
51+
52+ if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) {
53+ token.ac.completeInitialSnatchedPanel(attempt, errSnatchedOutputStale)
54+ return errSnatchedOutputStale
55+ }
56+ err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket)
57+ token.ac.completeInitialSnatchedPanel(attempt, err)
58+ return err
59+}
60+
61+func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {
62+ ac := token.ac
63+ ac.sendMu.Lock()
64+ defer ac.sendMu.Unlock()
65+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
66+ return false
67+ }
68+ ac.captureFrames = nil
69+ return true
70+}
71+
72+func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
73+ ac := token.ac
74+ ac.sendMu.Lock()
75+ defer ac.sendMu.Unlock()
76+ if ac.roleGeneration.Load() != token.generation {
77+ return errSnatchedOutputStale
78+ }
79+ if !ac.transportSnapshotCurrent(token.transport) {
80+ return errTransportReplaced
81+ }
82+ if token.effect == nil || !token.effect.beginTransportSend(token.transport) {
83+ return errAttachmentTransition
84+ }
85+ err := token.transport.transport.Send(frame)
86+ if err != nil {
87+ token.effect.reportTransportFailure(token.transport)
88+ }
89+ token.effect.endTransportSend()
90+ return err
91+}
92+
93+// boundedSendSnatchedControl keeps a best-effort control acknowledgement bound
94+// to the exact admitted role and link incarnation. On deadline it closes only
95+// that captured link, releasing either the transport Send or a wait on sendMu.
96+func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
97+ if token.ac == nil || token.effect == nil || token.transport.transport == nil {
98+ return errAttachmentTransition
99+ }
100+ _, err := d.boundedSendWith(token.transport.transport, func() error {
101+ return d.sendSnatchedControl(token, frame)
102+ })
103+ if errors.Is(err, errSendTimedOut) {
104+ _ = token.ac.closeCapturedTransport(token.transport.transport)
105+ }
106+ return err
107+}
A internal/usecase/daemon/snatched_client_test.go +1145 -0
@@ -0,0 +1,1145 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/keys"
16+ themeui "github.com/bnema/vev/internal/usecase/theme"
17+ "github.com/bnema/vev/pkg/renderer"
18+)
19+
20+func TestSessionAttachmentRole(t *testing.T) {
21+ active := &attachedClient{}
22+ waiting := &attachedClient{}
23+ unknown := &attachedClient{}
24+ sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}
25+
26+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
27+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
28+ require.Equal(t, attachmentDetached, sess.attachmentRole(unknown))
29+}
30+
31+func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
32+ d := newTestDaemon(t, nil, stubClock{})
33+ sess := &session{id: domain.SessionID("work")}
34+ oldTransport := &closeTrackingTransport{}
35+ old := &attachedClient{tr: oldTransport}
36+ old.setSession(sess)
37+ sess.client = old
38+ d.sessions[sess.id] = sess
39+
40+ newTransport := &closeTrackingTransport{}
41+ next := &attachedClient{tr: newTransport}
42+ expected := next.transportSnapshot()
43+ result, err := d.transitionAttachment(attachmentTransitionRequest{
44+ target: sess,
45+ next: next,
46+ expectedRole: attachmentDetached,
47+ targetRole: attachmentActive,
48+ expectedTransport: expected,
49+ })
50+ require.NoError(t, err)
51+
52+ sess.mu.Lock()
53+ require.Same(t, next, sess.client)
54+ require.Contains(t, sess.snatched, old)
55+ _, nextSnatched := sess.snatched[next]
56+ require.False(t, nextSnatched)
57+ sess.mu.Unlock()
58+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
59+ require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
60+ require.True(t, result.published.current())
61+ require.True(t, result.displaced.current())
62+ require.Equal(t, uint64(1), result.published.generation)
63+ require.Equal(t, uint64(1), result.displaced.generation)
64+ require.Same(t, sess, next.currentSession())
65+ require.Same(t, sess, old.currentSession())
66+}
67+
68+func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {
69+ d := newTestDaemon(t, nil, stubClock{})
70+ sess := &session{id: domain.SessionID("work")}
71+ oldTransport := &closeTrackingTransport{}
72+ old := &attachedClient{
73+ tr: oldTransport,
74+ output: newOutputStateStream(),
75+ size: domain.Size{Cols: 80, Rows: 24},
76+ }
77+ old.initOverlays()
78+ old.setThemeForTest(themeui.Theme{})
79+ old.setSession(sess)
80+ sess.client = old
81+ d.sessions[sess.id] = sess
82+
83+ next := &attachedClient{tr: &closeTrackingTransport{}}
84+ result, err := d.transitionAttachment(attachmentTransitionRequest{
85+ target: sess,
86+ next: next,
87+ expectedRole: attachmentDetached,
88+ targetRole: attachmentActive,
89+ expectedTransport: next.transportSnapshot(),
90+ })
91+ require.NoError(t, err)
92+
93+ d.deferAttachmentTransitionCleanups(result)
94+ d.attachmentCleanupWg.Wait()
95+
96+ require.False(t, oldTransport.Closed(), "snatched transport must stay open")
97+ frames := oldTransport.Sends()
98+ require.Len(t, frames, 1)
99+ require.Equal(t, ports.MsgOutput, frames[0].Type)
100+ output, err := ports.UnmarshalOutput(frames[0].Payload)
101+ require.NoError(t, err)
102+ require.Contains(t, string(output.Data), "Session snatched")
103+}
104+
105+func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ setup func(*attachedClient, *session, *transactionalResizePTY)
109+ frame func() ports.Frame
110+ assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport)
111+ }{
112+ {
113+ name: "forwarded input",
114+ frame: func() ports.Frame { return frameInput([]byte("stale")) },
115+ assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
116+ require.Empty(t, pty.writes(), "stale input reached the PTY")
117+ },
118+ },
119+ {
120+ name: "key action",
121+ frame: func() ports.Frame { return frameInput([]byte("\x1b ")) },
122+ assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
123+ require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay")
124+ require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay")
125+ },
126+ },
127+ {
128+ name: "mouse input",
129+ setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) {
130+ pane := sess.activeTab().focusedPane()
131+ pane.mu.Lock()
132+ pane.screen.Write([]byte("\x1b[?1000h"))
133+ pane.mu.Unlock()
134+ },
135+ frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) },
136+ assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
137+ require.Empty(t, pty.writes(), "stale mouse input reached the PTY")
138+ },
139+ },
140+ {
141+ name: "image push",
142+ frame: func() ports.Frame {
143+ return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})}
144+ },
145+ assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
146+ sess.mu.Lock()
147+ files := append([]string(nil), sess.clipFiles...)
148+ sess.mu.Unlock()
149+ require.Empty(t, files, "stale image push created a session clipboard file")
150+ require.Empty(t, pty.writes(), "stale image path reached the PTY")
151+ },
152+ },
153+ {
154+ name: "resize",
155+ frame: func() ports.Frame {
156+ return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})}
157+ },
158+ assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
159+ require.Empty(t, pty.requested(), "stale resize reached a session PTY")
160+ require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client")
161+ },
162+ },
163+ {
164+ name: "theme",
165+ frame: func() ports.Frame {
166+ return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
167+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
169+ })}
170+ },
171+ assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
172+ require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client")
173+ },
174+ },
175+ {
176+ name: "client notice",
177+ frame: func() ports.Frame {
178+ return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})}
179+ },
180+ assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
181+ require.Empty(t, d.notices.history(), "stale notice mutated daemon history")
182+ oldToasts, _ := visibleToasts(old)
183+ nextToasts, _ := visibleToasts(next)
184+ require.Empty(t, oldToasts)
185+ require.Empty(t, nextToasts)
186+ },
187+ },
188+ {
189+ name: "ack",
190+ setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) {
191+ old.output.next = 2
192+ },
193+ frame: func() ports.Frame {
194+ return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}
195+ },
196+ assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
197+ require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state")
198+ },
199+ },
200+ {
201+ name: "ping",
202+ frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} },
203+ assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) {
204+ select {
205+ case frame := <-tr.sends:
206+ t.Fatalf("stale ping wrote frame type %d", frame.Type)
207+ default:
208+ }
209+ },
210+ },
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.name, func(t *testing.T) {
215+ pty := &transactionalResizePTY{}
216+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
217+ d.tempDir = t.TempDir()
218+ oldTransport := newDatagramTestTransport()
219+ old.replaceTransport(oldTransport)
220+ old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
221+ rc := d.attachCoordinator(sess, nil, old, true)
222+ token := sess.attachmentToken(old, oldTransport)
223+ token.lease = rc.attachmentLease(old)
224+ require.True(t, token.activeCurrent())
225+ if tt.setup != nil {
226+ tt.setup(old, sess, pty)
227+ }
228+
229+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
230+ next.initOverlays()
231+ var transition attachmentTransitionResult
232+ d.afterActiveFrameDispatch = func(attachmentRoleToken) {
233+ d.afterActiveFrameDispatch = nil
234+ var err error
235+ transition, err = d.transitionAttachment(attachmentTransitionRequest{
236+ target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
237+ expectedTransport: next.transportSnapshot(), ready: true,
238+ })
239+ require.NoError(t, err)
240+ }
241+
242+ d.handleActiveClientFrame(token, tt.frame())
243+ for _, cleanup := range transition.cleanups {
244+ cleanup.finish()
245+ }
246+ tt.assert(t, d, sess, old, next, pty, oldTransport)
247+ })
248+ }
249+}
250+
251+func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
252+ pty := &transactionalResizePTY{}
253+ d, sess, first, _ := newManualSessionWithPTYs(t, pty)
254+ firstTransport := newDatagramTestTransport()
255+ first.replaceTransport(firstTransport)
256+
257+ secondTransport := newDatagramTestTransport()
258+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
259+ second.initOverlays()
260+ firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
261+ target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive,
262+ expectedTransport: second.transportSnapshot(), ready: true,
263+ })
264+ require.NoError(t, err)
265+ for _, cleanup := range firstReplacement.cleanups {
266+ cleanup.finish()
267+ }
268+
269+ thirdTransport := newDatagramTestTransport()
270+ third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
271+ third.initOverlays()
272+ secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
273+ target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive,
274+ expectedTransport: third.transportSnapshot(), ready: true,
275+ })
276+ require.NoError(t, err)
277+ for _, cleanup := range secondReplacement.cleanups {
278+ cleanup.finish()
279+ }
280+
281+ otherCtx, otherCancel := context.WithCancel(d.serveCtx)
282+ defer otherCancel()
283+ otherTransport := newDatagramTestTransport()
284+ otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}
285+ otherClient.initOverlays()
286+ other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient}
287+ otherClient.setSession(other)
288+ d.mu.Lock()
289+ d.sessions[other.id] = other
290+ d.mu.Unlock()
291+ d.attachCoordinator(other, nil, otherClient, true)
292+
293+ roles := []*attachedClient{first, second, third}
294+ generations := make([]uint64, len(roles))
295+ for i, ac := range roles {
296+ generations[i] = ac.roleGeneration.Load()
297+ }
298+ var killedLoops sync.WaitGroup
299+ for _, ac := range roles {
300+ killedLoops.Go(func() { d.runConnLoop(ac) })
301+ }
302+ otherDone := make(chan struct{})
303+ go func() {
304+ d.runConnLoop(otherClient)
305+ close(otherDone)
306+ }()
307+ defer func() {
308+ _ = firstTransport.Close()
309+ _ = secondTransport.Close()
310+ _ = thirdTransport.Close()
311+ _ = otherTransport.Close()
312+ killedLoops.Wait()
313+ <-otherDone
314+ }()
315+
316+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
317+ d.waitNotifies()
318+ select {
319+ case <-waitGroupDone(&killedLoops):
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("killed active and snatched connection loops did not retire")
322+ }
323+
324+ sess.mu.Lock()
325+ require.Nil(t, sess.client)
326+ require.Empty(t, sess.snatched)
327+ sess.mu.Unlock()
328+ for i, ac := range roles {
329+ require.Nil(t, ac.currentSession())
330+ require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation")
331+ }
332+ for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} {
333+ detached := awaitFrame(t, transport.sends, ports.MsgDetached)
334+ message, err := ports.UnmarshalDetached(detached.Payload)
335+ require.NoError(t, err)
336+ require.Equal(t, ports.ReasonSessionKilled, message.Reason)
337+ _, recvErr := transport.Recv()
338+ require.ErrorIs(t, recvErr, io.EOF)
339+ }
340+ require.Same(t, otherClient, other.client)
341+ require.Same(t, other, otherClient.currentSession())
342+ require.Equal(t, attachmentActive, other.attachmentRole(otherClient))
343+
344+ otherTransport.recv <- ports.Frame{Type: ports.MsgPing}
345+ require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type)
346+ select {
347+ case <-otherDone:
348+ t.Fatal("killing another session retired the active client's connection")
349+ default:
350+ }
351+}
352+
353+func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) {
354+ pty, release := newBlockingPTY(t)
355+ defer release()
356+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
357+
358+ parkedTransport := &closeTrackingTransport{}
359+ sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
360+ require.NoError(t, err)
361+ parkedToken := parkedClient.resumeToken
362+ d.clientGone(sess, parkedClient, parkedTransport, false)
363+
364+ activeTransport := &closeTrackingTransport{}
365+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
366+ require.NoError(t, err)
367+ d.mu.Lock()
368+ parked := d.parked[parkedToken]
369+ d.mu.Unlock()
370+ require.NotNil(t, parked)
371+ require.Equal(t, attachmentSnatched, parked.role)
372+ parkedGeneration := parkedClient.roleGeneration.Load()
373+
374+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
375+ d.waitNotifies()
376+
377+ require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership")
378+ require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation")
379+ require.Zero(t, parkedClient.resumeToken)
380+ require.False(t, parkedClient.parked)
381+ select {
382+ case <-parked.done:
383+ default:
384+ t.Fatal("terminal teardown left the parked expiry goroutine live")
385+ }
386+ require.True(t, parkedTransport.Closed())
387+ require.Nil(t, parkedClient.currentSession())
388+ require.Nil(t, active.currentSession())
389+ require.True(t, activeTransport.Closed())
390+}
391+
392+func TestDaemonShutdownClosesSnatchedClients(t *testing.T) {
393+ pty, release := newBlockingPTY(t)
394+ defer release()
395+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
396+
397+ parkedTransport := &closeTrackingTransport{}
398+ sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
399+ require.NoError(t, err)
400+ parkedToken := parkedClient.resumeToken
401+ d.clientGone(sess, parkedClient, parkedTransport, false)
402+
403+ waitingTransport := &closeTrackingTransport{}
404+ _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport)
405+ require.NoError(t, err)
406+ activeTransport := &closeTrackingTransport{}
407+ _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
408+ require.NoError(t, err)
409+ d.attachmentCleanupWg.Wait()
410+
411+ d.mu.Lock()
412+ parked := d.parked[parkedToken]
413+ d.mu.Unlock()
414+ require.NotNil(t, parked)
415+ require.Equal(t, attachmentSnatched, parked.role)
416+ parkedGeneration := parkedClient.roleGeneration.Load()
417+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
418+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
419+
420+ require.False(t, d.shutdownAll(ports.ReasonServerShutdown))
421+ d.waitNotifies()
422+ d.attachmentCleanupWg.Wait()
423+
424+ require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership")
425+ require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration)
426+ select {
427+ case <-parked.done:
428+ default:
429+ t.Fatal("shutdown left the parked expiry goroutine live")
430+ }
431+ for _, client := range []*attachedClient{parkedClient, waiting, active} {
432+ require.Nil(t, client.currentSession())
433+ }
434+ for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} {
435+ require.True(t, transport.Closed())
436+ }
437+ for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} {
438+ frames := transport.Sends()
439+ require.NotEmpty(t, frames)
440+ detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload)
441+ require.NoError(t, err)
442+ require.Equal(t, ports.ReasonServerShutdown, detached.Reason)
443+ }
444+}
445+
446+func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) {
447+ pty := &transactionalResizePTY{}
448+ d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
449+ waitingTransport := &closeTrackingTransport{}
450+ waiting.replaceTransport(waitingTransport)
451+
452+ activeTransport := &closeTrackingTransport{}
453+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
454+ active.initOverlays()
455+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
456+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
457+ expectedTransport: active.transportSnapshot(), ready: true,
458+ })
459+ require.NoError(t, err)
460+ for _, cleanup := range transition.cleanups {
461+ cleanup.finish()
462+ }
463+
464+ activeTheme := ports.Theme{
465+ HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
466+ HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
467+ TrueColor: true,
468+ }
469+ d.applyTheme(sess, active, activeTheme)
470+ activeSize := domain.Size{Cols: 101, Rows: 37}
471+ d.resize(sess, active, activeSize)
472+ require.Equal(t, activeSize, active.size)
473+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
474+ requestedBeforeQuit := pty.requested()
475+
476+ token := sess.attachmentToken(waiting, waitingTransport)
477+ require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{
478+ Type: ports.MsgInput,
479+ Payload: ports.MarshalInput(ports.Input{
480+ Data: []byte{'q'},
481+ }),
482+ }))
483+
484+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
485+ require.Same(t, active, sess.client)
486+ require.False(t, activeTransport.Closed())
487+ require.True(t, waitingTransport.Closed())
488+ require.Equal(t, activeSize, active.size)
489+ require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes")
490+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
491+}
492+
493+func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {
494+ writes := make(chan []byte, 1)
495+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
496+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
497+ oldTransport := newDatagramTestTransport()
498+ old.replaceTransport(oldTransport)
499+
500+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
501+ next.initOverlays()
502+ result, err := d.transitionAttachment(attachmentTransitionRequest{
503+ target: sess,
504+ next: next,
505+ expectedRole: attachmentDetached,
506+ targetRole: attachmentActive,
507+ expectedTransport: next.transportSnapshot(),
508+ })
509+ require.NoError(t, err)
510+ d.deferAttachmentTransitionCleanups(result)
511+ d.attachmentCleanupWg.Wait()
512+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
513+
514+ oldTransport.recv <- frameInput([]byte("restricted"))
515+ require.NoError(t, oldTransport.Close())
516+ d.runConnLoop(old)
517+
518+ select {
519+ case got := <-writes:
520+ t.Fatalf("snatched input reached PTY: %q", got)
521+ default:
522+ }
523+}
524+
525+func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) {
526+ writes := make(chan []byte, 1)
527+ pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
528+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
529+ old.replaceTransport(&closeTrackingTransport{})
530+
531+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
532+ next.initOverlays()
533+ _, err := d.transitionAttachment(attachmentTransitionRequest{
534+ target: sess,
535+ next: next,
536+ expectedRole: attachmentDetached,
537+ targetRole: attachmentActive,
538+ expectedTransport: next.transportSnapshot(),
539+ })
540+ require.NoError(t, err)
541+
542+ (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale"))
543+
544+ select {
545+ case got := <-writes:
546+ t.Fatalf("stale active key forward reached PTY: %q", got)
547+ default:
548+ }
549+}
550+
551+func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) {
552+ pty := &transactionalResizePTY{}
553+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
554+ oldTransport := newDatagramTestTransport()
555+ old.replaceTransport(oldTransport)
556+
557+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
558+ next.initOverlays()
559+ result, err := d.transitionAttachment(attachmentTransitionRequest{
560+ target: sess,
561+ next: next,
562+ expectedRole: attachmentDetached,
563+ targetRole: attachmentActive,
564+ expectedTransport: next.transportSnapshot(),
565+ })
566+ require.NoError(t, err)
567+ d.deferAttachmentTransitionCleanups(result)
568+ d.attachmentCleanupWg.Wait()
569+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
570+
571+ want := domain.Size{Cols: 100, Rows: 30}
572+ oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})}
573+ require.NoError(t, oldTransport.Close())
574+ d.runConnLoop(old)
575+
576+ require.Equal(t, want, old.size)
577+ require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs")
578+ panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
579+ output, err := ports.UnmarshalOutput(panel.Payload)
580+ require.NoError(t, err)
581+ require.Contains(t, string(output.Data), "Session snatched")
582+}
583+
584+func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) {
585+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
586+ oldTransport := newDatagramTestTransport()
587+ old.replaceTransport(oldTransport)
588+
589+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
590+ next.initOverlays()
591+ result, err := d.transitionAttachment(attachmentTransitionRequest{
592+ target: sess,
593+ next: next,
594+ expectedRole: attachmentDetached,
595+ targetRole: attachmentActive,
596+ expectedTransport: next.transportSnapshot(),
597+ })
598+ require.NoError(t, err)
599+ d.deferAttachmentTransitionCleanups(result)
600+ d.attachmentCleanupWg.Wait()
601+ panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
602+ panel, err := ports.UnmarshalOutput(panelFrame.Payload)
603+ require.NoError(t, err)
604+ require.Equal(t, uint64(1), old.output.outstanding())
605+
606+ oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})}
607+ oldTransport.recv <- ports.Frame{Type: ports.MsgPing}
608+ require.NoError(t, oldTransport.Close())
609+ d.runConnLoop(old)
610+
611+ require.Zero(t, old.output.outstanding())
612+ require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type)
613+}
614+
615+func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) {
616+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
617+ oldTransport := newDatagramTestTransport()
618+ old.replaceTransport(oldTransport)
619+
620+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
621+ next.initOverlays()
622+ result, err := d.transitionAttachment(attachmentTransitionRequest{
623+ target: sess,
624+ next: next,
625+ expectedRole: attachmentDetached,
626+ targetRole: attachmentActive,
627+ expectedTransport: next.transportSnapshot(),
628+ })
629+ require.NoError(t, err)
630+ d.deferAttachmentTransitionCleanups(result)
631+ d.attachmentCleanupWg.Wait()
632+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
633+
634+ activeTheme := ports.Theme{
635+ HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30},
636+ HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60},
637+ TrueColor: true,
638+ }
639+ d.applyTheme(sess, next, activeTheme)
640+ snatchedTheme := ports.Theme{
641+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
642+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
643+ TrueColor: true,
644+ }
645+ oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)}
646+ require.NoError(t, oldTransport.Close())
647+ d.runConnLoop(old)
648+
649+ require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground)
650+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
651+ panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
652+ output, err := ports.UnmarshalOutput(panel.Payload)
653+ require.NoError(t, err)
654+ require.Contains(t, string(output.Data), "Session snatched")
655+}
656+
657+func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) {
658+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
659+ oldTransport := newDatagramTestTransport()
660+ old.replaceTransport(oldTransport)
661+
662+ activeTransport := &closeTrackingTransport{}
663+ next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
664+ next.initOverlays()
665+ result, err := d.transitionAttachment(attachmentTransitionRequest{
666+ target: sess,
667+ next: next,
668+ expectedRole: attachmentDetached,
669+ targetRole: attachmentActive,
670+ expectedTransport: next.transportSnapshot(),
671+ })
672+ require.NoError(t, err)
673+ d.deferAttachmentTransitionCleanups(result)
674+ d.attachmentCleanupWg.Wait()
675+ awaitFrame(t, oldTransport.sends, ports.MsgOutput)
676+
677+ oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}
678+ d.runConnLoop(old)
679+
680+ detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached)
681+ message, err := ports.UnmarshalDetached(detached.Payload)
682+ require.NoError(t, err)
683+ require.Equal(t, ports.ReasonDetach, message.Reason)
684+ require.Equal(t, attachmentDetached, sess.attachmentRole(old))
685+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
686+ require.Same(t, next, sess.client)
687+ require.False(t, activeTransport.Closed())
688+}
689+
690+func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) {
691+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
692+ oldTransport := &closeTrackingTransport{}
693+ old.replaceTransport(oldTransport)
694+
695+ d.enterPicker(sess, old)
696+ d.enterPalette(sess, old)
697+ d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil })
698+ d.enterCopyMode(sess, old)
699+ d.enterNotices(sess, old)
700+ old.overlays.resizeMu.Lock()
701+ old.overlays.resizeActive = true
702+ old.overlays.resizePending = []byte("pending")
703+ old.overlays.resizeMu.Unlock()
704+ old.sendMu.Lock()
705+ old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
706+ old.sendMu.Unlock()
707+ require.True(t, old.overlays.Active())
708+
709+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
710+ next.initOverlays()
711+ result, err := d.transitionAttachment(attachmentTransitionRequest{
712+ target: sess,
713+ next: next,
714+ expectedRole: attachmentDetached,
715+ targetRole: attachmentActive,
716+ expectedTransport: next.transportSnapshot(),
717+ })
718+ require.NoError(t, err)
719+ d.deferAttachmentTransitionCleanups(result)
720+ d.attachmentCleanupWg.Wait()
721+
722+ require.False(t, old.overlays.Active())
723+ old.overlays.pickerMu.Lock()
724+ require.Nil(t, old.overlays.pickerPreview)
725+ require.Nil(t, old.overlays.pickerPreviewSession)
726+ old.overlays.pickerMu.Unlock()
727+ old.sendMu.Lock()
728+ require.Empty(t, old.captureFrames)
729+ old.sendMu.Unlock()
730+ require.False(t, oldTransport.Closed())
731+}
732+
733+type failingSnatchedTransport struct {
734+ mu sync.Mutex
735+ closed bool
736+}
737+
738+func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") }
739+func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
740+func (t *failingSnatchedTransport) Close() error {
741+ t.mu.Lock()
742+ t.closed = true
743+ t.mu.Unlock()
744+ return nil
745+}
746+func (t *failingSnatchedTransport) Closed() bool {
747+ t.mu.Lock()
748+ defer t.mu.Unlock()
749+ return t.closed
750+}
751+
752+func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
753+ d := newTestDaemon(t, nil, stubClock{})
754+ oldTransport := &failingSnatchedTransport{}
755+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
756+ old.initOverlays()
757+ sess := &session{id: "work", client: old}
758+ old.setSession(sess)
759+ d.sessions[sess.id] = sess
760+
761+ activeTransport := &closeTrackingTransport{}
762+ next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
763+ next.initOverlays()
764+ result, err := d.transitionAttachment(attachmentTransitionRequest{
765+ target: sess,
766+ next: next,
767+ expectedRole: attachmentDetached,
768+ targetRole: attachmentActive,
769+ expectedTransport: next.transportSnapshot(),
770+ })
771+ require.NoError(t, err)
772+ d.deferAttachmentTransitionCleanups(result)
773+ d.attachmentCleanupWg.Wait()
774+
775+ require.True(t, oldTransport.Closed())
776+ require.Equal(t, attachmentDetached, sess.attachmentRole(old))
777+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
778+ require.Same(t, next, sess.client)
779+ require.False(t, activeTransport.Closed())
780+}
781+
782+func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) {
783+ pty := &transactionalResizePTY{}
784+ d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
785+ failed := &failingSnatchedTransport{}
786+ waiting.replaceTransport(failed)
787+
788+ activeTransport := &closeTrackingTransport{}
789+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
790+ active.initOverlays()
791+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
792+ target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
793+ expectedTransport: active.transportSnapshot(), ready: true,
794+ })
795+ require.NoError(t, err)
796+
797+ activeTheme := ports.Theme{
798+ HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
799+ HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
800+ TrueColor: true,
801+ }
802+ d.applyTheme(sess, active, activeTheme)
803+ activeSize := domain.Size{Cols: 103, Rows: 39}
804+ d.resize(sess, active, activeSize)
805+ require.Equal(t, activeSize, active.size)
806+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
807+ requestedBeforeFailure := pty.requested()
808+
809+ d.deferAttachmentTransitionCleanups(transition)
810+ d.attachmentCleanupWg.Wait()
811+
812+ require.True(t, failed.Closed())
813+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
814+ require.Same(t, active, sess.client)
815+ require.False(t, activeTransport.Closed())
816+ require.Equal(t, activeSize, active.size)
817+ require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes")
818+ assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
819+}
820+
821+func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
822+ d := newTestDaemon(t, nil, stubClock{})
823+ oldTransport := &failingSnatchedTransport{}
824+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
825+ old.initOverlays()
826+ sess := &session{id: "work", client: old}
827+ old.setSession(sess)
828+ d.sessions[sess.id] = sess
829+
830+ activeTransport := &closeTrackingTransport{}
831+ next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
832+ next.initOverlays()
833+ result, err := d.transitionAttachment(attachmentTransitionRequest{
834+ target: sess,
835+ next: next,
836+ expectedRole: attachmentDetached,
837+ targetRole: attachmentActive,
838+ expectedTransport: next.transportSnapshot(),
839+ })
840+ require.NoError(t, err)
841+ for _, cleanup := range result.cleanups {
842+ cleanup.finish()
843+ }
844+
845+ theme := ports.Theme{
846+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
847+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
848+ TrueColor: true,
849+ }
850+ require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{
851+ Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme),
852+ }))
853+
854+ require.True(t, oldTransport.Closed())
855+ require.Equal(t, attachmentDetached, sess.attachmentRole(old))
856+ require.Equal(t, attachmentActive, sess.attachmentRole(next))
857+ require.Same(t, next, sess.client)
858+ require.False(t, activeTransport.Closed())
859+}
860+
861+func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) {
862+ d := newTestDaemon(t, nil, stubClock{})
863+ tr := &closeTrackingTransport{}
864+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
865+ ac.initOverlays()
866+ ac.roleGeneration.Store(1)
867+ sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}
868+ ac.setSession(sess)
869+ token := attachmentRoleToken{
870+ sess: sess, ac: ac, role: attachmentSnatched,
871+ generation: 1, transport: ac.transportSnapshot(),
872+ }
873+
874+ pickerCleared := make(chan struct{})
875+ d.afterSnatchOverlayFamily = func(family string) {
876+ if family == "picker" {
877+ close(pickerCleared)
878+ }
879+ }
880+ ac.overlays.resizeMu.Lock()
881+ resizeLocked := true
882+ defer func() {
883+ if resizeLocked {
884+ ac.overlays.resizeMu.Unlock()
885+ }
886+ }()
887+ cleared := make(chan bool, 1)
888+ go func() { cleared <- d.clearForSnatch(token) }()
889+ select {
890+ case <-pickerCleared:
891+ case <-time.After(2 * time.Second):
892+ t.Fatal("picker family did not clear before blocked resize family")
893+ }
894+ require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu")
895+ ac.overlays.pickerMu.Unlock()
896+
897+ ac.overlays.resizeMu.Unlock()
898+ resizeLocked = false
899+ require.True(t, <-cleared)
900+}
901+
902+func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) {
903+ d := newTestDaemon(t, nil, stubClock{})
904+ tr := &closeTrackingTransport{}
905+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
906+ ac.initOverlays()
907+ ac.roleGeneration.Store(1)
908+ sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}
909+ ac.setSession(sess)
910+ token := attachmentRoleToken{
911+ sess: sess, ac: ac, role: attachmentSnatched,
912+ generation: 1, transport: ac.transportSnapshot(),
913+ }
914+
915+ ac.overlays.pickerMu.Lock()
916+ started := make(chan struct{})
917+ cleared := make(chan bool, 1)
918+ go func() {
919+ close(started)
920+ cleared <- d.clearForSnatch(token)
921+ }()
922+ <-started
923+ ac.roleGeneration.Add(1)
924+ ac.overlays.resizeMu.Lock()
925+ ac.overlays.resizeActive = true
926+ ac.overlays.resizeMu.Unlock()
927+ ac.overlays.pickerMu.Unlock()
928+
929+ require.False(t, <-cleared)
930+ require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation")
931+}
932+
933+func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
934+ tests := []struct {
935+ name string
936+ mutate func(*Daemon, *session, *attachedClient)
937+ }{
938+ {
939+ name: "stale transport",
940+ mutate: func(_ *Daemon, _ *session, next *attachedClient) {
941+ next.replaceTransport(&closeTrackingTransport{})
942+ },
943+ },
944+ {
945+ name: "session lifecycle ended",
946+ mutate: func(d *Daemon, sess *session, _ *attachedClient) {
947+ delete(d.sessions, sess.id)
948+ },
949+ },
950+ {
951+ name: "unexpected role",
952+ mutate: func(_ *Daemon, sess *session, next *attachedClient) {
953+ sess.mu.Lock()
954+ sess.addSnatchedLocked(next)
955+ sess.mu.Unlock()
956+ },
957+ },
958+ }
959+
960+ for _, tt := range tests {
961+ t.Run(tt.name, func(t *testing.T) {
962+ d := newTestDaemon(t, nil, stubClock{})
963+ sess := &session{id: domain.SessionID("work")}
964+ old := &attachedClient{tr: &closeTrackingTransport{}}
965+ old.setSession(sess)
966+ sess.client = old
967+ d.sessions[sess.id] = sess
968+ next := &attachedClient{tr: &closeTrackingTransport{}}
969+ expected := next.transportSnapshot()
970+ tt.mutate(d, sess, next)
971+
972+ _, err := d.transitionAttachment(attachmentTransitionRequest{
973+ target: sess,
974+ next: next,
975+ expectedRole: attachmentDetached,
976+ targetRole: attachmentActive,
977+ expectedTransport: expected,
978+ })
979+ require.ErrorIs(t, err, errAttachmentTransition)
980+ require.Same(t, old, sess.client)
981+ require.Zero(t, old.roleGeneration.Load())
982+ require.Zero(t, next.roleGeneration.Load())
983+ })
984+ }
985+}
986+
987+func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {
988+ d := newTestDaemon(t, nil, stubClock{})
989+ from := &session{id: "z-source"}
990+ target := &session{id: "a-target"}
991+ moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
992+ displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}
993+ moving.initOverlays()
994+ displaced.initOverlays()
995+ moving.setSession(from)
996+ displaced.setSession(target)
997+ from.client = moving
998+ target.client = displaced
999+ d.sessions[from.id] = from
1000+ d.sessions[target.id] = target
1001+ sourceCoordinator := d.attachCoordinator(from, nil, moving, true)
1002+ targetCoordinator := d.attachCoordinator(target, nil, displaced, true)
1003+
1004+ moving.sendMu.Lock()
1005+ locked := true
1006+ defer func() {
1007+ if locked {
1008+ moving.sendMu.Unlock()
1009+ }
1010+ }()
1011+ transitionDone := make(chan attachmentTransitionResult, 1)
1012+ transitionErr := make(chan error, 1)
1013+ go func() {
1014+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1015+ source: from, target: target, next: moving,
1016+ expectedRole: attachmentActive, targetRole: attachmentActive,
1017+ expectedTransport: moving.transportSnapshot(), ready: true,
1018+ })
1019+ transitionDone <- result
1020+ transitionErr <- err
1021+ }()
1022+
1023+ var result attachmentTransitionResult
1024+ select {
1025+ case result = <-transitionDone:
1026+ require.NoError(t, <-transitionErr)
1027+ case <-time.After(2 * time.Second):
1028+ t.Fatal("ownership publication waited for blocked sendMu")
1029+ }
1030+
1031+ from.mu.Lock()
1032+ require.Nil(t, from.client)
1033+ from.mu.Unlock()
1034+ target.mu.Lock()
1035+ require.Same(t, moving, target.client)
1036+ require.Contains(t, target.snatched, displaced)
1037+ target.mu.Unlock()
1038+ require.Same(t, target, moving.currentSession())
1039+ sourceCoordinator.mu.Lock()
1040+ require.NotNil(t, sourceCoordinator.lease)
1041+ require.False(t, sourceCoordinator.lease.active)
1042+ sourceCoordinator.mu.Unlock()
1043+ targetCoordinator.mu.Lock()
1044+ require.Same(t, result.published.lease, targetCoordinator.lease)
1045+ require.True(t, targetCoordinator.lease.active)
1046+ require.Same(t, moving, targetCoordinator.lease.attachment)
1047+ targetCoordinator.mu.Unlock()
1048+ require.True(t, result.published.activeCurrent())
1049+
1050+ beforeSendWait := make(chan struct{})
1051+ d.beforeFirstPaintSendWait = func(token attachmentRoleToken) {
1052+ if token.ac == moving {
1053+ close(beforeSendWait)
1054+ }
1055+ }
1056+ paintDone := make(chan bool, 1)
1057+ go func() {
1058+ paintDone <- d.firstPaintForTransition(result.published)
1059+ }()
1060+ <-beforeSendWait
1061+ require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu")
1062+ d.mu.Unlock()
1063+ require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu")
1064+ d.notices.routingMu.Unlock()
1065+ require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu")
1066+ from.mu.Unlock()
1067+ require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu")
1068+ target.mu.Unlock()
1069+ require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu")
1070+ sourceCoordinator.mu.Unlock()
1071+ require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu")
1072+ targetCoordinator.mu.Unlock()
1073+
1074+ moving.sendMu.Unlock()
1075+ locked = false
1076+ require.True(t, <-paintDone)
1077+}
1078+
1079+func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) {
1080+ d := newTestDaemon(t, nil, stubClock{})
1081+ tr := &closeTrackingTransport{}
1082+ ac := &attachedClient{
1083+ tr: tr,
1084+ output: newOutputStateStream(),
1085+ size: domain.Size{Cols: 80, Rows: 24},
1086+ }
1087+ ac.initOverlays()
1088+ ac.roleGeneration.Store(2)
1089+ ac.output.next = 4
1090+ ac.output.acked = 1
1091+ sess := &session{id: "work", client: ac}
1092+ ac.setSession(sess)
1093+ d.sessions[sess.id] = sess
1094+ rc := d.attachCoordinator(sess, nil, ac, true)
1095+ rebased := false
1096+ ac.renderStages.handoffRebase = func() { rebased = true }
1097+
1098+ stale := attachmentRoleToken{
1099+ sess: sess, ac: ac, role: attachmentActive,
1100+ generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true,
1101+ }
1102+ require.False(t, d.firstPaintForTransition(stale))
1103+
1104+ require.False(t, rebased)
1105+ require.Equal(t, uint64(4), ac.output.next)
1106+ require.Equal(t, uint64(1), ac.output.acked)
1107+ require.Empty(t, tr.Sends(), "stale first paint emitted a reset")
1108+}
1109+
1110+func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {
1111+ d := newTestDaemon(t, nil, stubClock{})
1112+ from := &session{id: domain.SessionID("z-source")}
1113+ target := &session{id: domain.SessionID("a-target")}
1114+ moving := &attachedClient{tr: &closeTrackingTransport{}}
1115+ displaced := &attachedClient{tr: &closeTrackingTransport{}}
1116+ moving.setSession(from)
1117+ displaced.setSession(target)
1118+ from.client = moving
1119+ target.client = displaced
1120+ d.sessions[from.id] = from
1121+ d.sessions[target.id] = target
1122+
1123+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1124+ source: from,
1125+ target: target,
1126+ next: moving,
1127+ expectedRole: attachmentActive,
1128+ targetRole: attachmentActive,
1129+ expectedTransport: moving.transportSnapshot(),
1130+ ready: true,
1131+ })
1132+ require.NoError(t, err)
1133+
1134+ from.mu.Lock()
1135+ require.Nil(t, from.client)
1136+ require.NotContains(t, from.snatched, moving)
1137+ from.mu.Unlock()
1138+ target.mu.Lock()
1139+ require.Same(t, moving, target.client)
1140+ require.Contains(t, target.snatched, displaced)
1141+ target.mu.Unlock()
1142+ require.True(t, result.published.current())
1143+ require.True(t, result.displaced.current())
1144+ require.Same(t, target, moving.currentSession())
1145+}
A internal/usecase/daemon/snatched_input.go +261 -0
@@ -0,0 +1,261 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/usecase/keys"
8+)
9+
10+const snatchedInputSequenceLimit = 64
11+
12+type snatchedInputAction uint8
13+
14+const (
15+ snatchedInputNone snatchedInputAction = iota
16+ snatchedInputReclaim
17+ snatchedInputQuit
18+)
19+
20+// clearSnatchedInput invalidates every parser continuation before stopping its
21+// timer. Callers must not hold daemon, routing, session, coordinator, send, or
22+// transport locks because Timer.Stop is an external call.
23+func (ac *attachedClient) clearSnatchedInput() {
24+ if ac == nil {
25+ return
26+ }
27+ ac.snatchedInputMu.Lock()
28+ ac.snatchedInputPending = nil
29+ ac.snatchedInputDrain = false
30+ pendingESC := ac.snatchedInputESC
31+ ac.snatchedInputESC = pendingByteTimer{}
32+ ac.snatchedInputMu.Unlock()
33+ pendingESC.stop()
34+}
35+
36+// handleSnatchedInput accepts actions only as exact one-byte input events.
37+// Terminal escape sequences and arbitrary coalesced input are consumed as
38+// indivisible events so a byte in a paste, sequence final, or frame tail can
39+// never become an action.
40+func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool {
41+ action := d.parseSnatchedInput(token, input.Data)
42+ switch action {
43+ case snatchedInputReclaim:
44+ d.reclaimSnatchedAttachment(token)
45+ case snatchedInputQuit:
46+ return d.quitSnatchedAttachment(token)
47+ }
48+ return false
49+}
50+
51+func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction {
52+ ac := token.ac
53+ if ac == nil || len(data) == 0 {
54+ return snatchedInputNone
55+ }
56+
57+ ac.snatchedInputMu.Lock()
58+
59+ if ac.snatchedInputDrain {
60+ if snatchedSequenceFinal(data) >= 0 {
61+ ac.snatchedInputDrain = false
62+ }
63+ ac.snatchedInputMu.Unlock()
64+ return snatchedInputNone
65+ }
66+
67+ if len(ac.snatchedInputPending) != 0 {
68+ pendingESC := ac.snatchedInputESC
69+ ac.snatchedInputESC = pendingByteTimer{}
70+ combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data))
71+ combined = append(combined, ac.snatchedInputPending...)
72+ combined = append(combined, data...)
73+ ac.snatchedInputPending = nil
74+ d.retainSnatchedSequenceLocked(ac, combined)
75+ ac.snatchedInputMu.Unlock()
76+ pendingESC.stop()
77+ return snatchedInputNone
78+ }
79+
80+ if len(data) == 1 {
81+ var action snatchedInputAction
82+ switch data[0] {
83+ case 'r', 'R':
84+ action = snatchedInputReclaim
85+ case 'q', 'Q':
86+ action = snatchedInputQuit
87+ case keys.ESC:
88+ ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC)
89+ ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
90+ d.fireSnatchedEscape(token, timer)
91+ })
92+ }
93+ ac.snatchedInputMu.Unlock()
94+ return action
95+ }
96+
97+ // A complete/coalesced event is always ignored. Only an incomplete CSI or
98+ // SS3 prefix is retained to prevent its future final byte from acting.
99+ d.retainSnatchedSequenceLocked(ac, data)
100+ ac.snatchedInputMu.Unlock()
101+ return snatchedInputNone
102+}
103+
104+func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) {
105+ if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 {
106+ return
107+ }
108+ if len(data) > snatchedInputSequenceLimit {
109+ ac.snatchedInputDrain = true
110+ return
111+ }
112+ ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...)
113+}
114+
115+func snatchedSequencePrefix(data []byte) bool {
116+ return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
117+}
118+
119+func snatchedSequenceFinal(data []byte) int {
120+ for i, b := range data {
121+ if b >= 0x40 && b <= 0x7e {
122+ return i
123+ }
124+ }
125+ return -1
126+}
127+
128+func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) {
129+ ac := token.ac
130+ if ac == nil {
131+ return
132+ }
133+ ac.snatchedInputMu.Lock()
134+ if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC {
135+ ac.snatchedInputMu.Unlock()
136+ return
137+ }
138+ ac.snatchedInputPending = nil
139+ ac.snatchedInputESC.timer = nil
140+ ac.snatchedInputESC.done = nil
141+ ac.snatchedInputMu.Unlock()
142+
143+ if d.afterSnatchedEscapeAccepted != nil {
144+ d.afterSnatchedEscapeAccepted()
145+ }
146+ if !token.current() {
147+ if d.afterSnatchedEscapeAttempt != nil {
148+ d.afterSnatchedEscapeAttempt(false)
149+ }
150+ return
151+ }
152+ ticket, admitted := ac.beginRoleEffect(token)
153+ if d.afterSnatchedEscapeAttempt != nil {
154+ d.afterSnatchedEscapeAttempt(admitted)
155+ }
156+ if !admitted {
157+ return
158+ }
159+ token.effect = ticket
160+ defer token.endRoleEffect()
161+ d.quitSnatchedAttachment(token)
162+}
163+
164+// acquireActivationBarrier waits for the requester's serialized output path
165+// without retaining role, daemon, routing, session, coordinator, or pane locks.
166+// The caller has already frozen and drained the requester's role gate, so no new
167+// role-bound sender can race this acquisition. A deadline closes only the exact
168+// captured link, which releases a transport Send currently holding sendMu.
169+func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) {
170+ if token.ac == nil || token.transport.transport == nil || !token.current() {
171+ return nil, errAttachmentTransition
172+ }
173+
174+ acquired := make(chan struct{})
175+ cancel := make(chan struct{})
176+ go func() {
177+ token.ac.sendMu.Lock()
178+ select {
179+ case acquired <- struct{}{}:
180+ // The receiver now owns the matching Unlock obligation.
181+ case <-cancel:
182+ token.ac.sendMu.Unlock()
183+ }
184+ }()
185+
186+ timer := d.clock.NewTimer(detachNotifyTimeout)
187+ select {
188+ case <-acquired:
189+ timer.Stop()
190+ if token.ac.roleGeneration.Load() != token.generation ||
191+ !token.ac.transportSnapshotCurrent(token.transport) {
192+ token.ac.sendMu.Unlock()
193+ return nil, errAttachmentTransition
194+ }
195+ return token.ac.sendMu.Unlock, nil
196+ case <-timer.C():
197+ close(cancel)
198+ _ = token.ac.closeCapturedTransport(token.transport.transport)
199+ return nil, errSendTimedOut
200+ }
201+}
202+
203+func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool {
204+ if token.role != attachmentSnatched || token.effect == nil {
205+ return false
206+ }
207+ token.effect.bindActionEnd(d, "reclaim-snatched")
208+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
209+ source: token.sess, target: token.sess, next: token.ac,
210+ expectedRole: attachmentSnatched, targetRole: attachmentActive,
211+ expectedTransport: token.transport, sourceToken: &token,
212+ action: "reclaim-snatched", ready: true, activationBarrier: true,
213+ })
214+ if err != nil {
215+ if errors.Is(err, errSendTimedOut) {
216+ d.attachmentCleanupWg.Go(func() { d.parkOrDropSnatchedAttachment(token) })
217+ } else {
218+ d.showSnatchedUnavailable(token)
219+ }
220+ return false
221+ }
222+ d.deferAttachmentTransitionCleanups(transition)
223+ if d.beforeReclaimFirstPaint != nil {
224+ d.beforeReclaimFirstPaint(transition.published)
225+ }
226+ d.firstPaintForTransition(transition.published)
227+ return true
228+}
229+
230+// showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role
231+// admission and the generation-aware sender make it inert after a concurrent
232+// reclaim, quit, transport replacement, or session teardown.
233+func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) {
234+ if !token.current() {
235+ return
236+ }
237+ fresh := token
238+ fresh.effect = nil
239+ ticket, admitted := token.ac.beginRoleEffect(fresh)
240+ if !admitted {
241+ return
242+ }
243+ fresh.effect = ticket
244+ defer fresh.endRoleEffect()
245+ if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil {
246+ fresh.endRoleEffect()
247+ d.parkOrDropSnatchedAttachment(token)
248+ }
249+}
250+
251+func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool {
252+ if token.role != attachmentSnatched || token.effect == nil {
253+ return false
254+ }
255+ token.effect.bindActionEnd(d, "quit-snatched")
256+ _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach))
257+ // Terminal cleanup freezes the same role gate, so release this action's
258+ // admission before attempting the generation-bound removal.
259+ token.endRoleEffect()
260+ return d.dropSnatchedAttachment(token)
261+}
A internal/usecase/daemon/snatched_input_test.go +365 -0
@@ -0,0 +1,365 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/keys"
16+)
17+
18+type snatchedInputTimer struct {
19+ ch chan time.Time
20+}
21+
22+func newSnatchedInputTimer() *snatchedInputTimer {
23+ return &snatchedInputTimer{ch: make(chan time.Time, 1)}
24+}
25+
26+func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch }
27+func (*snatchedInputTimer) Reset(time.Duration) bool { return false }
28+func (*snatchedInputTimer) Stop() bool { return true }
29+func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} }
30+
31+type snatchedInputClock struct {
32+ timers chan *snatchedInputTimer
33+}
34+
35+func newSnatchedInputClock() *snatchedInputClock {
36+ return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)}
37+}
38+
39+func (*snatchedInputClock) Now() time.Time { return time.Time{} }
40+func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer {
41+ if delay != keys.ESCDelay {
42+ return stubTimer{}
43+ }
44+ timer := newSnatchedInputTimer()
45+ c.timers <- timer
46+ return timer
47+}
48+
49+type snatchedCloseSignalTransport struct {
50+ mu sync.Mutex
51+ closed bool
52+ closedCh chan struct{}
53+ closeOnce sync.Once
54+}
55+
56+func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport {
57+ return &snatchedCloseSignalTransport{closedCh: make(chan struct{})}
58+}
59+
60+func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil }
61+func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) {
62+ return ports.Frame{}, errors.New("closed")
63+}
64+func (t *snatchedCloseSignalTransport) Close() error {
65+ t.mu.Lock()
66+ t.closed = true
67+ t.mu.Unlock()
68+ t.closeOnce.Do(func() { close(t.closedCh) })
69+ return nil
70+}
71+func (t *snatchedCloseSignalTransport) Closed() bool {
72+ t.mu.Lock()
73+ defer t.mu.Unlock()
74+ return t.closed
75+}
76+
77+type blockedSnatchedQuitTransport struct {
78+ recv chan ports.Frame
79+ sendStarted chan struct{}
80+ sendDone chan struct{}
81+ closed chan struct{}
82+ closeOnce sync.Once
83+ sendOnce sync.Once
84+ mu sync.Mutex
85+ isClosed bool
86+}
87+
88+func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport {
89+ return &blockedSnatchedQuitTransport{
90+ recv: make(chan ports.Frame, 1),
91+ sendStarted: make(chan struct{}),
92+ sendDone: make(chan struct{}),
93+ closed: make(chan struct{}),
94+ }
95+}
96+
97+func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error {
98+ if frame.Type != ports.MsgDetached {
99+ return nil
100+ }
101+ t.sendOnce.Do(func() { close(t.sendStarted) })
102+ <-t.closed
103+ close(t.sendDone)
104+ return io.ErrClosedPipe
105+}
106+
107+func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) {
108+ select {
109+ case frame := <-t.recv:
110+ return frame, nil
111+ case <-t.closed:
112+ return ports.Frame{}, io.ErrClosedPipe
113+ }
114+}
115+
116+func (t *blockedSnatchedQuitTransport) Close() error {
117+ t.mu.Lock()
118+ t.isClosed = true
119+ t.mu.Unlock()
120+ t.closeOnce.Do(func() { close(t.closed) })
121+ return nil
122+}
123+
124+func (t *blockedSnatchedQuitTransport) Closed() bool {
125+ t.mu.Lock()
126+ defer t.mu.Unlock()
127+ return t.isClosed
128+}
129+
130+func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) {
131+ t.Helper()
132+ d := newTestDaemon(t, nil, clock)
133+ waitingTransport := newSnatchedCloseSignalTransport()
134+ waiting := &attachedClient{
135+ tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
136+ }
137+ active := &attachedClient{
138+ tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size,
139+ }
140+ waiting.initOverlays()
141+ active.initOverlays()
142+ sess := &session{
143+ id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}},
144+ }
145+ waiting.setSession(sess)
146+ active.setSession(sess)
147+ d.sessions[sess.id] = sess
148+ d.attachCoordinator(sess, nil, active, true)
149+ return d, sess, waiting, active, waitingTransport
150+}
151+
152+func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool {
153+ tr := ac.transport()
154+ token := sess.attachmentToken(ac, tr)
155+ return d.handleSnatchedClientFrame(token, ports.Frame{
156+ Type: ports.MsgInput,
157+ Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}),
158+ })
159+}
160+
161+func TestSnatchedInputStrictActions(t *testing.T) {
162+ tests := []struct {
163+ name string
164+ chunks []string
165+ fireEscape bool
166+ wantRole attachmentRole
167+ wantClosed bool
168+ }{
169+ {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive},
170+ {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive},
171+ {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true},
172+ {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true},
173+ {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true},
174+ {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched},
175+ {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched},
176+ {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched},
177+ {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched},
178+ {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched},
179+ {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched},
180+ {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched},
181+ {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched},
182+ {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched},
183+ {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched},
184+ {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched},
185+ }
186+
187+ for _, tt := range tests {
188+ t.Run(tt.name, func(t *testing.T) {
189+ clock := newSnatchedInputClock()
190+ d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock)
191+ for _, chunk := range tt.chunks {
192+ sendSnatchedInput(d, sess, waiting, chunk)
193+ }
194+ if tt.fireEscape {
195+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay")
196+ require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay")
197+ timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
198+ timer.Fire()
199+ awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit")
200+ }
201+ d.attachmentCleanupWg.Wait()
202+
203+ require.Equal(t, tt.wantRole, sess.attachmentRole(waiting))
204+ require.Equal(t, tt.wantClosed, waitingTransport.Closed())
205+ if tt.wantRole == attachmentActive {
206+ require.Same(t, waiting, sess.client)
207+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
208+ } else {
209+ require.Same(t, active, sess.client)
210+ }
211+ })
212+ }
213+}
214+
215+func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
216+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
217+ waitingTransport := newBlockedSnatchedQuitTransport()
218+ defer func() { _ = waitingTransport.Close() }()
219+ activeTransport := &closeTrackingTransport{}
220+ d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
221+
222+ otherTransport := &closeTrackingTransport{}
223+ other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size}
224+ other.initOverlays()
225+ other.setSession(sess)
226+ sess.mu.Lock()
227+ sess.addSnatchedLocked(other)
228+ sess.mu.Unlock()
229+ otherToken := sess.attachmentToken(other, otherTransport)
230+ require.Equal(t, attachmentSnatched, otherToken.role)
231+
232+ ownerLease := sess.renderCoordinator().attachmentLease(active)
233+ require.NotNil(t, ownerLease)
234+ activeGeneration := active.roleGeneration.Load()
235+ otherGeneration := other.roleGeneration.Load()
236+
237+ loopDone := make(chan struct{})
238+ go func() {
239+ d.runConnLoop(waiting)
240+ close(loopDone)
241+ }()
242+ waitingTransport.recv <- ports.Frame{
243+ Type: ports.MsgInput,
244+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
245+ }
246+ awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement")
247+ select {
248+ case <-loopDone:
249+ t.Fatal("snatched quit returned before its acknowledgement deadline")
250+ default:
251+ }
252+
253+ timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
254+ require.Equal(t, detachNotifyTimeout, timer.duration)
255+ timer.ch <- time.Time{}
256+ awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send")
257+ awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline")
258+
259+ require.True(t, waitingTransport.Closed())
260+ requireRoleGateRetired(t, waiting)
261+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
262+ require.Nil(t, waiting.currentSession())
263+ require.Nil(t, waiting.transport())
264+ require.Same(t, active, sess.client)
265+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
266+ require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active))
267+ require.Equal(t, activeGeneration, active.roleGeneration.Load())
268+ require.False(t, activeTransport.Closed())
269+ require.Equal(t, attachmentSnatched, sess.attachmentRole(other))
270+ require.Equal(t, otherGeneration, other.roleGeneration.Load())
271+ require.Same(t, otherTransport, other.transport())
272+ require.False(t, otherTransport.Closed())
273+}
274+
275+func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
276+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
277+ staleTransport := newBlockedSnatchedQuitTransport()
278+ defer func() { _ = staleTransport.Close() }()
279+ ownerTransport := &closeTrackingTransport{}
280+ d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
281+
282+ ticketEnded := make(chan struct{})
283+ releaseQuit := make(chan struct{})
284+ d.afterActionRoleEffectEnded = func(action string) {
285+ if action == "quit-snatched" {
286+ close(ticketEnded)
287+ <-releaseQuit
288+ }
289+ }
290+ loopDone := make(chan struct{})
291+ go func() {
292+ d.runConnLoop(waiting)
293+ close(loopDone)
294+ }()
295+ staleTransport.recv <- ports.Frame{
296+ Type: ports.MsgInput,
297+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}),
298+ }
299+ awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport")
300+ timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
301+ timer.ch <- time.Time{}
302+ awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send")
303+ awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup")
304+
305+ freshTransport := &closeTrackingTransport{}
306+ waiting.replaceTransport(freshTransport)
307+ reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{
308+ target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
309+ expectedTransport: waiting.transportSnapshot(), ready: true,
310+ })
311+ require.NoError(t, err)
312+ d.deferAttachmentTransitionCleanups(reclaimed)
313+ freshGeneration := reclaimed.published.generation
314+ freshLease := reclaimed.published.lease
315+
316+ close(releaseQuit)
317+ awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim")
318+ d.attachmentCleanupWg.Wait()
319+
320+ require.True(t, staleTransport.Closed())
321+ requireRoleGateRetired(t, waiting)
322+ require.Same(t, waiting, sess.client)
323+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
324+ require.Equal(t, freshGeneration, waiting.roleGeneration.Load())
325+ require.Same(t, freshTransport, waiting.transport())
326+ require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport")
327+ require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting))
328+ require.Equal(t, attachmentSnatched, sess.attachmentRole(owner))
329+ require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner")
330+}
331+
332+func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) {
333+ clock := newSnatchedInputClock()
334+ d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock)
335+
336+ sendSnatchedInput(d, sess, waiting, "\x1b")
337+ timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
338+ accepted := make(chan struct{})
339+ release := make(chan struct{})
340+ attempted := make(chan bool, 1)
341+ d.afterSnatchedEscapeAccepted = func() {
342+ close(accepted)
343+ <-release
344+ }
345+ d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted }
346+ timer.Fire()
347+ awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state")
348+
349+ result, err := d.transitionAttachment(attachmentTransitionRequest{
350+ target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
351+ expectedTransport: waiting.transportSnapshot(), ready: true,
352+ })
353+ require.NoError(t, err)
354+ d.deferAttachmentTransitionCleanups(result)
355+ newTransport := newSnatchedCloseSignalTransport()
356+ waiting.replaceTransport(newTransport)
357+ close(release)
358+ require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role"))
359+ d.attachmentCleanupWg.Wait()
360+
361+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
362+ require.Same(t, newTransport, waiting.transport())
363+ require.False(t, newTransport.Closed(), "stale timer closed the replacement transport")
364+ require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation")
365+}
A internal/usecase/daemon/snatched_reclaim_test.go +445 -0
@@ -0,0 +1,445 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ themeui "github.com/bnema/vev/internal/usecase/theme"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+type failingReclaimTransport struct {
18+ mu sync.Mutex
19+ closed bool
20+}
21+
22+func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") }
23+func (*failingReclaimTransport) Recv() (ports.Frame, error) {
24+ return ports.Frame{}, errors.New("closed")
25+}
26+func (t *failingReclaimTransport) Close() error {
27+ t.mu.Lock()
28+ t.closed = true
29+ t.mu.Unlock()
30+ return nil
31+}
32+func (t *failingReclaimTransport) Closed() bool {
33+ t.mu.Lock()
34+ defer t.mu.Unlock()
35+ return t.closed
36+}
37+
38+func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) {
39+ t.Helper()
40+ d := newTestDaemon(t, nil, clock)
41+ waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
42+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
43+ waiting.initOverlays()
44+ active.initOverlays()
45+ sess := &session{
46+ id: "atomic-reclaim", client: active,
47+ snatched: map[*attachedClient]struct{}{waiting: {}},
48+ }
49+ waiting.setSession(sess)
50+ active.setSession(sess)
51+ d.sessions[sess.id] = sess
52+ d.attachCoordinator(sess, nil, active, true)
53+ return d, sess, waiting, active
54+}
55+
56+func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) {
57+ t.Helper()
58+ sess.mu.Lock()
59+ owner := sess.client
60+ snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
61+ for ac := range sess.snatched {
62+ snatched[ac] = struct{}{}
63+ }
64+ sess.mu.Unlock()
65+ require.NotNil(t, owner)
66+
67+ activeCount := 0
68+ for _, ac := range clients {
69+ if ac == owner {
70+ activeCount++
71+ continue
72+ }
73+ _, waiting := snatched[ac]
74+ require.True(t, waiting, "every non-owner must remain snatched")
75+ }
76+ require.Equal(t, 1, activeCount)
77+
78+ rc := sess.renderCoordinator()
79+ rc.mu.Lock()
80+ lease := rc.lease
81+ rc.mu.Unlock()
82+ require.NotNil(t, lease)
83+ require.True(t, lease.active)
84+ require.Same(t, owner, lease.attachment)
85+}
86+
87+func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
88+ d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
89+ requesterTransport := &closeTrackingTransport{}
90+ requester.replaceTransport(requesterTransport)
91+ d.attachCoordinator(sess, nil, requester, true)
92+
93+ active := &attachedClient{
94+ tr: &closeTrackingTransport{},
95+ output: newOutputStateStream(),
96+ size: requester.size,
97+ }
98+ active.initOverlays()
99+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
100+ target: sess, next: active,
101+ expectedRole: attachmentDetached, targetRole: attachmentActive,
102+ expectedTransport: active.transportSnapshot(), ready: true,
103+ })
104+ require.NoError(t, err)
105+ d.deferAttachmentTransitionCleanups(displaced)
106+ d.attachmentCleanupWg.Wait()
107+
108+ requester.sendMu.Lock()
109+ beforeReclaimState := requester.output.next
110+ requester.sendMu.Unlock()
111+ requesterTheme := themeui.Theme{
112+ Foreground: renderer.RGB{R: 11, G: 22, B: 33},
113+ Background: renderer.RGB{R: 44, G: 55, B: 66},
114+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
115+ }
116+ requester.setClientTheme(requesterTheme)
117+
118+ token := sess.attachmentToken(requester, requesterTransport)
119+ require.Equal(t, attachmentSnatched, token.role)
120+ require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{
121+ Type: ports.MsgInput,
122+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
123+ }))
124+
125+ sess.mu.Lock()
126+ require.Same(t, requester, sess.client)
127+ _, activeIsSnatched := sess.snatched[active]
128+ _, requesterIsSnatched := sess.snatched[requester]
129+ sess.mu.Unlock()
130+ require.True(t, activeIsSnatched)
131+ require.False(t, requesterIsSnatched)
132+ rc := sess.renderCoordinator()
133+ lease := rc.attachmentLease(requester)
134+ require.NotNil(t, lease)
135+ require.Nil(t, rc.attachmentLease(active))
136+ require.True(t, lease.active)
137+
138+ requester.sendMu.Lock()
139+ require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint")
140+ require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain")
141+ requester.sendMu.Unlock()
142+ assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background)
143+
144+ d.attachmentCleanupWg.Wait()
145+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
146+ activeTransport, ok := active.transport().(*closeTrackingTransport)
147+ require.True(t, ok, "active transport has unexpected type")
148+ require.False(t, activeTransport.Closed())
149+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
150+}
151+
152+func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) {
153+ waitingTransport := &closeTrackingTransport{}
154+ activeTransport := &closeTrackingTransport{}
155+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
156+ token := sess.attachmentToken(waiting, waitingTransport)
157+
158+ d.mu.Lock()
159+ delete(d.sessions, sess.id)
160+ d.mu.Unlock()
161+ d.handleSnatchedClientFrame(token, ports.Frame{
162+ Type: ports.MsgInput,
163+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
164+ })
165+
166+ requireSingleOwnerAndLease(t, sess, waiting, active)
167+ require.Same(t, active, sess.client)
168+ require.False(t, waitingTransport.Closed())
169+ frames := waitingTransport.Sends()
170+ require.Len(t, frames, 1)
171+ output, err := ports.UnmarshalOutput(frames[0].Payload)
172+ require.NoError(t, err)
173+ require.Contains(t, string(output.Data), "Session is no longer available.")
174+}
175+
176+func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) {
177+ waitingTransport := &closeTrackingTransport{}
178+ activeTransport := &failingReclaimTransport{}
179+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
180+ token := sess.attachmentToken(waiting, waitingTransport)
181+
182+ d.handleSnatchedClientFrame(token, ports.Frame{
183+ Type: ports.MsgInput,
184+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
185+ })
186+ d.attachmentCleanupWg.Wait()
187+
188+ require.Same(t, waiting, sess.client)
189+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
190+ require.Equal(t, attachmentDetached, sess.attachmentRole(active))
191+ require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting))
192+ require.Nil(t, sess.renderCoordinator().attachmentLease(active))
193+ require.False(t, waitingTransport.Closed())
194+ require.True(t, activeTransport.Closed())
195+}
196+
197+func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) {
198+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
199+ waitingTransport := newSnatchedCloseSignalTransport()
200+ activeTransport := &closeTrackingTransport{}
201+ d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
202+
203+ waiting.sendMu.Lock()
204+ locked := true
205+ defer func() {
206+ if locked {
207+ waiting.sendMu.Unlock()
208+ }
209+ }()
210+ done := make(chan struct{})
211+ go func() {
212+ token := sess.attachmentToken(waiting, waitingTransport)
213+ d.handleSnatchedClientFrame(token, ports.Frame{
214+ Type: ports.MsgInput,
215+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
216+ })
217+ close(done)
218+ }()
219+
220+ timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline")
221+ require.Equal(t, detachNotifyTimeout, timer.duration)
222+ timer.ch <- time.Time{}
223+ awaitTestCompletion(t, done, "deadline did not release blocked reclaim")
224+ require.Same(t, active, sess.client)
225+ require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment)
226+ require.True(t, waitingTransport.Closed())
227+
228+ waiting.sendMu.Unlock()
229+ locked = false
230+ d.attachmentCleanupWg.Wait()
231+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
232+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
233+}
234+
235+func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) {
236+ waitingTransport := &closeTrackingTransport{}
237+ activeTransport := &closeTrackingTransport{}
238+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
239+
240+ finalizerStarted := make(chan struct{})
241+ releaseFinalizer := make(chan struct{})
242+ d.afterDisplacedCleanupStarted = func() {
243+ close(finalizerStarted)
244+ <-releaseFinalizer
245+ }
246+ waitingToken := sess.attachmentToken(waiting, waitingTransport)
247+ d.handleSnatchedClientFrame(waitingToken, ports.Frame{
248+ Type: ports.MsgInput,
249+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
250+ })
251+ awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause")
252+
253+ activeToken := sess.attachmentToken(active, activeTransport)
254+ require.Equal(t, attachmentSnatched, activeToken.role)
255+ require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{
256+ Type: ports.MsgInput,
257+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
258+ }))
259+ close(releaseFinalizer)
260+ d.attachmentCleanupWg.Wait()
261+
262+ require.Equal(t, attachmentDetached, sess.attachmentRole(active))
263+ frames := activeTransport.Sends()
264+ require.Len(t, frames, 1, "stale finalizer emitted output after quit")
265+ require.Equal(t, ports.MsgDetached, frames[0].Type)
266+}
267+
268+func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) {
269+ d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
270+ requesterTransport := &closeTrackingTransport{}
271+ requester.replaceTransport(requesterTransport)
272+ d.attachCoordinator(sess, nil, requester, true)
273+
274+ activeTransport := &closeTrackingTransport{}
275+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size}
276+ active.initOverlays()
277+ initial, err := d.transitionAttachment(attachmentTransitionRequest{
278+ target: sess, next: active,
279+ expectedRole: attachmentDetached, targetRole: attachmentActive,
280+ expectedTransport: active.transportSnapshot(), ready: true,
281+ })
282+ require.NoError(t, err)
283+ d.deferAttachmentTransitionCleanups(initial)
284+ d.attachmentCleanupWg.Wait()
285+ baselineActiveFrames := len(activeTransport.Sends())
286+
287+ paintAdmitted := make(chan struct{})
288+ releasePaint := make(chan struct{})
289+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
290+ if token.ac == active && token.role == attachmentActive {
291+ close(paintAdmitted)
292+ <-releasePaint
293+ }
294+ }
295+ activeToken := sess.attachmentToken(active, activeTransport)
296+ paintDone := make(chan bool, 1)
297+ go func() { paintDone <- d.firstPaintForTransition(activeToken) }()
298+ awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted")
299+
300+ reclaimFrozen := make(chan struct{})
301+ var frozenOnce sync.Once
302+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
303+ if action == "reclaim-snatched" {
304+ frozenOnce.Do(func() { close(reclaimFrozen) })
305+ }
306+ }
307+ reclaimDone := make(chan struct{})
308+ go func() {
309+ token := sess.attachmentToken(requester, requesterTransport)
310+ d.handleSnatchedClientFrame(token, ports.Frame{
311+ Type: ports.MsgInput,
312+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
313+ })
314+ close(reclaimDone)
315+ }()
316+ awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint")
317+ close(releasePaint)
318+ require.True(t, awaitTestValue(t, paintDone, "old paint did not finish"))
319+ awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint")
320+ d.attachmentCleanupWg.Wait()
321+
322+ frames := activeTransport.Sends()[baselineActiveFrames:]
323+ require.NotEmpty(t, frames)
324+ last := frames[len(frames)-1]
325+ require.Equal(t, ports.MsgOutput, last.Type)
326+ output, err := ports.UnmarshalOutput(last.Payload)
327+ require.NoError(t, err)
328+ require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel")
329+ requireSingleOwnerAndLease(t, sess, requester, active)
330+}
331+
332+func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) {
333+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
334+ firstTransport := &closeTrackingTransport{}
335+ first.replaceTransport(firstTransport)
336+ d.attachCoordinator(sess, nil, first, true)
337+
338+ secondTransport := &closeTrackingTransport{}
339+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
340+ second.initOverlays()
341+ initial, err := d.transitionAttachment(attachmentTransitionRequest{
342+ target: sess, next: second,
343+ expectedRole: attachmentDetached, targetRole: attachmentActive,
344+ expectedTransport: second.transportSnapshot(), ready: true,
345+ })
346+ require.NoError(t, err)
347+ d.deferAttachmentTransitionCleanups(initial)
348+ d.attachmentCleanupWg.Wait()
349+ baselineFirstFrames := len(firstTransport.Sends())
350+
351+ firstPaintPaused := make(chan struct{})
352+ releaseFirstPaint := make(chan struct{})
353+ d.beforeReclaimFirstPaint = func(token attachmentRoleToken) {
354+ if token.ac != first {
355+ return
356+ }
357+ close(firstPaintPaused)
358+ <-releaseFirstPaint
359+ }
360+ firstDone := make(chan struct{})
361+ go func() {
362+ token := sess.attachmentToken(first, firstTransport)
363+ d.handleSnatchedClientFrame(token, ports.Frame{
364+ Type: ports.MsgInput,
365+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
366+ })
367+ close(firstDone)
368+ }()
369+ awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint")
370+ require.Same(t, first, sess.client, "first reclaim was not committed before its paint")
371+
372+ secondToken := sess.attachmentToken(second, secondTransport)
373+ require.Equal(t, attachmentSnatched, secondToken.role)
374+ d.handleSnatchedClientFrame(secondToken, ports.Frame{
375+ Type: ports.MsgInput,
376+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
377+ })
378+ require.Same(t, second, sess.client)
379+ close(releaseFirstPaint)
380+ awaitTestCompletion(t, firstDone, "stale first paint did not return")
381+ d.attachmentCleanupWg.Wait()
382+
383+ newFirstFrames := firstTransport.Sends()[baselineFirstFrames:]
384+ require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim")
385+ require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type)
386+ output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload)
387+ require.NoError(t, err)
388+ require.Contains(t, string(output.Data), "Session snatched")
389+ requireSingleOwnerAndLease(t, sess, first, second)
390+}
391+
392+func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) {
393+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
394+ firstTransport := &closeTrackingTransport{}
395+ first.replaceTransport(firstTransport)
396+ d.attachCoordinator(sess, nil, first, true)
397+
398+ secondTransport := &closeTrackingTransport{}
399+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
400+ second.initOverlays()
401+ toSecond, err := d.transitionAttachment(attachmentTransitionRequest{
402+ target: sess, next: second,
403+ expectedRole: attachmentDetached, targetRole: attachmentActive,
404+ expectedTransport: second.transportSnapshot(), ready: true,
405+ })
406+ require.NoError(t, err)
407+ d.deferAttachmentTransitionCleanups(toSecond)
408+ d.attachmentCleanupWg.Wait()
409+
410+ thirdTransport := &closeTrackingTransport{}
411+ third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
412+ third.initOverlays()
413+ toThird, err := d.transitionAttachment(attachmentTransitionRequest{
414+ target: sess, next: third,
415+ expectedRole: attachmentDetached, targetRole: attachmentActive,
416+ expectedTransport: third.transportSnapshot(), ready: true,
417+ })
418+ require.NoError(t, err)
419+ d.deferAttachmentTransitionCleanups(toThird)
420+ d.attachmentCleanupWg.Wait()
421+ requireSingleOwnerAndLease(t, sess, first, second, third)
422+
423+ firstToken := sess.attachmentToken(first, firstTransport)
424+ secondToken := sess.attachmentToken(second, secondTransport)
425+ require.Equal(t, attachmentSnatched, firstToken.role)
426+ require.Equal(t, attachmentSnatched, secondToken.role)
427+ start := make(chan struct{})
428+ done := make(chan struct{}, 2)
429+ for _, token := range []attachmentRoleToken{firstToken, secondToken} {
430+ go func() {
431+ <-start
432+ d.handleSnatchedClientFrame(token, ports.Frame{
433+ Type: ports.MsgInput,
434+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
435+ })
436+ done <- struct{}{}
437+ }()
438+ }
439+ close(start)
440+ awaitTestCompletion(t, done, "first concurrent reclaim did not return")
441+ awaitTestCompletion(t, done, "second concurrent reclaim did not return")
442+ d.attachmentCleanupWg.Wait()
443+
444+ requireSingleOwnerAndLease(t, sess, first, second, third)
445+}
A internal/usecase/daemon/snatched_render.go +105 -0
@@ -0,0 +1,105 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+var snatchedModal = ui.Modal{
13+ FixedWidth: 42, FixedHeight: 7,
14+ Title: " Session snatched ", Anchor: domain.AnchorCenter,
15+}
16+
17+func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame {
18+ width, height := max(size.Cols, 1), max(size.Rows, 1)
19+ frame := renderer.NewFrame(width, height)
20+ ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase})
21+ if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight {
22+ drawSnatchedCompact(frame, styles, feedback)
23+ return frame
24+ }
25+
26+ presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height})
27+ inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase)
28+ message := "This session is now active elsewhere."
29+ if feedback != "" {
30+ message = feedback
31+ }
32+ drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
33+ drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection)
34+ return frame
35+}
36+
37+func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) {
38+ status := "Session snatched"
39+ if feedback != "" {
40+ status = "Session unavailable"
41+ }
42+ lines := []string{status, "r Resume", "q Quit"}
43+ startY := max((frame.Height-len(lines))/2, 0)
44+ for i, line := range lines {
45+ drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase)
46+ }
47+}
48+
49+func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
50+ width := 0
51+ for _, r := range text {
52+ width += renderer.RuneWidth(r)
53+ }
54+ x := bounds.X + max((bounds.Width-width)/2, 0)
55+ ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style)
56+}
57+
58+var errSnatchedOutputStale = errors.New("snatched output role changed")
59+
60+// sendSnatchedPanel serializes a dependency-free panel reset onto one exact
61+// attachment role and transport lifetime. It does not inspect session state or
62+// capture pane content.
63+func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error {
64+ if expected.transport == nil {
65+ return errors.New("snatched client transport is nil")
66+ }
67+ var effect *roleEffectTicket
68+ if len(effects) != 0 {
69+ effect = effects[0]
70+ }
71+ tr, err := d.boundedSendWith(expected.transport, func() error {
72+ ac.sendMu.Lock()
73+ defer ac.sendMu.Unlock()
74+ if !ac.transportSnapshotCurrent(expected) {
75+ return errTransportReplaced
76+ }
77+ if ac.roleGeneration.Load() != generation {
78+ return errSnatchedOutputStale
79+ }
80+
81+ applied := ac.getAppliedTheme()
82+ frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
83+ ac.output.rebase()
84+ prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
85+ if err != nil {
86+ return err
87+ }
88+ data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)
89+ if effect != nil && !effect.beginTransportSend(expected) {
90+ return errAttachmentTransition
91+ }
92+ err = prepared.send(data, ac.echoAck.Load(), expected.transport.Send)
93+ if effect != nil {
94+ if err != nil {
95+ effect.reportTransportFailure(expected)
96+ }
97+ effect.endTransportSend()
98+ }
99+ return err
100+ })
101+ if errors.Is(err, errSendTimedOut) {
102+ _ = ac.closeCapturedTransport(tr)
103+ }
104+ return err
105+}
A internal/usecase/daemon/snatched_render_test.go +256 -0
@@ -0,0 +1,256 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ themeui "github.com/bnema/vev/internal/usecase/theme"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func TestSnatchedPanelFrame(t *testing.T) {
19+ styles := themeui.Styles{
20+ PickerBase: renderer.Style{Foreground: 1},
21+ PickerSelection: renderer.Style{Foreground: 2},
22+ BorderMuted: renderer.Style{Foreground: 3},
23+ }
24+ tests := []struct {
25+ name string
26+ size domain.Size
27+ feedback string
28+ wantSize domain.Size
29+ want []string
30+ wantBounds domain.Rect
31+ compact bool
32+ }{
33+ {
34+ name: "centered bordered panel",
35+ size: domain.Size{Cols: 80, Rows: 24},
36+ wantSize: domain.Size{Cols: 80, Rows: 24},
37+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
38+ wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
39+ },
40+ {
41+ name: "compact fallback",
42+ size: domain.Size{Cols: 24, Rows: 4},
43+ wantSize: domain.Size{Cols: 24, Rows: 4},
44+ want: []string{"Session snatched", "r Resume", "q Quit"},
45+ compact: true,
46+ },
47+ {
48+ name: "compact unavailable feedback",
49+ size: domain.Size{Cols: 24, Rows: 4},
50+ feedback: "Session is no longer available.",
51+ wantSize: domain.Size{Cols: 24, Rows: 4},
52+ want: []string{"Session unavailable", "r Resume", "q Quit"},
53+ compact: true,
54+ },
55+ {
56+ name: "bordered unavailable feedback",
57+ size: domain.Size{Cols: 80, Rows: 24},
58+ feedback: "Session is no longer available.",
59+ wantSize: domain.Size{Cols: 80, Rows: 24},
60+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
61+ wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
62+ },
63+ {
64+ name: "zero dimensions clamp",
65+ size: domain.Size{},
66+ wantSize: domain.Size{Cols: 1, Rows: 1},
67+ compact: true,
68+ },
69+ {
70+ name: "negative dimensions clamp",
71+ size: domain.Size{Cols: -10, Rows: -20},
72+ wantSize: domain.Size{Cols: 1, Rows: 1},
73+ compact: true,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ frame := snatchedPanelFrame(tt.size, styles, tt.feedback)
80+ require.Equal(t, tt.wantSize.Cols, frame.Width)
81+ require.Equal(t, tt.wantSize.Rows, frame.Height)
82+ require.NoError(t, frame.Validate())
83+
84+ text := snatchedFrameText(frame)
85+ for _, want := range tt.want {
86+ require.Contains(t, text, want)
87+ }
88+
89+ if tt.compact {
90+ require.NotContains(t, text, "┌")
91+ require.NotContains(t, text, "┘")
92+ return
93+ }
94+
95+ require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds)
96+ topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
97+ bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
98+ require.Equal(t, '┌', topLeft.Rune)
99+ require.Equal(t, '┘', bottomRight.Rune)
100+ require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
101+ require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
102+ requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase)
103+ })
104+ }
105+}
106+
107+func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
108+ d := newTestDaemon(t, nil, stubClock{})
109+ tr := &closeTrackingTransport{}
110+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
111+ ac.output.next = 7
112+ ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
113+ ac.roleGeneration.Store(1)
114+
115+ expected := ac.transportSnapshot()
116+ require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, ""))
117+
118+ frames := tr.Sends()
119+ require.Len(t, frames, 1)
120+ require.Equal(t, ports.MsgOutput, frames[0].Type)
121+ out, err := ports.UnmarshalOutput(frames[0].Payload)
122+ require.NoError(t, err)
123+ require.Zero(t, out.BaseStateNum)
124+ require.Equal(t, uint64(8), out.NewStateNum)
125+ require.Contains(t, string(out.Data), "Session snatched")
126+ require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
127+}
128+
129+func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
130+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
131+ d := newTestDaemon(t, nil, clock)
132+ old := newBlockingSnatchedTransport()
133+ fresh := &closeTrackingTransport{}
134+ ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
135+ ac.roleGeneration.Store(1)
136+ expected := ac.transportSnapshot()
137+
138+ result := make(chan error, 1)
139+ go func() {
140+ result <- d.sendSnatchedPanel(ac, expected, 1, "")
141+ }()
142+
143+ timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout")
144+ awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport")
145+ ac.replaceTransport(fresh)
146+ timer.ch <- time.Time{}
147+
148+ require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
149+ defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails.
150+ require.True(t, old.Closed(), "timed-out captured transport was not closed")
151+ require.False(t, fresh.Closed(), "replacement transport must remain open")
152+
153+ sendMuReleased := make(chan uint64, 1)
154+ go func() {
155+ ac.sendMu.Lock()
156+ generation := ac.roleGeneration.Load()
157+ ac.sendMu.Unlock()
158+ sendMuReleased <- generation
159+ }()
160+ require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu"))
161+}
162+
163+type blockingSnatchedTransport struct {
164+ sendEntered chan struct{}
165+ closed chan struct{}
166+ sendOnce sync.Once
167+ closeOnce sync.Once
168+}
169+
170+func newBlockingSnatchedTransport() *blockingSnatchedTransport {
171+ return &blockingSnatchedTransport{
172+ sendEntered: make(chan struct{}),
173+ closed: make(chan struct{}),
174+ }
175+}
176+
177+func (t *blockingSnatchedTransport) Send(ports.Frame) error {
178+ t.sendOnce.Do(func() { close(t.sendEntered) })
179+ <-t.closed
180+ return errors.New("transport closed")
181+}
182+
183+func (*blockingSnatchedTransport) Recv() (ports.Frame, error) {
184+ return ports.Frame{}, errors.New("not implemented")
185+}
186+
187+func (t *blockingSnatchedTransport) Close() error {
188+ t.closeOnce.Do(func() { close(t.closed) })
189+ return nil
190+}
191+
192+func (t *blockingSnatchedTransport) Closed() bool {
193+ select {
194+ case <-t.closed:
195+ return true
196+ default:
197+ return false
198+ }
199+}
200+
201+func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) {
202+ d := newTestDaemon(t, nil, stubClock{})
203+ old := &closeTrackingTransport{}
204+ fresh := &closeTrackingTransport{}
205+ ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
206+ ac.roleGeneration.Store(1)
207+ stale := ac.transportSnapshot()
208+ ac.replaceTransport(fresh)
209+
210+ require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced)
211+ require.Empty(t, old.Sends())
212+ require.Empty(t, fresh.Sends())
213+}
214+
215+func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) {
216+ d := newTestDaemon(t, nil, stubClock{})
217+ tr := &closeTrackingTransport{}
218+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
219+ ac.roleGeneration.Store(2)
220+ expected := ac.transportSnapshot()
221+
222+ require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale)
223+ require.Empty(t, tr.Sends())
224+}
225+
226+func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) {
227+ t.Helper()
228+ for y := range frame.Height {
229+ for x := range frame.Width {
230+ if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height {
231+ continue
232+ }
233+ cell := frame.At(x, y)
234+ require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y)
235+ require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y)
236+ }
237+ }
238+}
239+
240+func snatchedFrameText(frame renderer.Frame) string {
241+ var out strings.Builder
242+ for y := 0; y < frame.Height; y++ {
243+ for _, cell := range frame.Row(y) {
244+ if cell.Continuation {
245+ continue
246+ }
247+ r := cell.Rune
248+ if r == 0 {
249+ r = ' '
250+ }
251+ out.WriteRune(r)
252+ }
253+ out.WriteByte('\n')
254+ }
255+ return out.String()
256+}
M internal/usecase/daemon/stalled_attachment_test.go +32 -21 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +4 -2
@@ -528,7 +528,8 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
528 }528 }
529 tr, _ := newCapturingTransport(t)529 tr, _ := newCapturingTransport(t)
530 530
531- ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})531+ ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
532+ require.NoError(t, err)
532 533
533 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)534 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
534 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)535 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
@@ -717,7 +718,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
717 require.True(t, sess.detachIfCurrent(ac))718 require.True(t, sess.detachIfCurrent(ac))
718 719
719 tr, _ := newCapturingTransport(t)720 tr, _ := newCapturingTransport(t)
720- newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})721+ newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
722+ require.NoError(t, err)
721 d.applyTheme(sess, newAC, ports.Theme{723 d.applyTheme(sess, newAC, ports.Theme{
722 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},724 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
723 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},725 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
M internal/usecase/daemon/theme_resolve.go +8 -0 collapsed to keep the page light
M internal/usecase/daemon/transactional_resize.go +98 -21 collapsed to keep the page light
M internal/usecase/daemon/transactional_resize_test.go +72 -11 collapsed to keep the page light
M internal/usecase/keys/router.go +65 -34 collapsed to keep the page light
M internal/usecase/picker/model.go +172 -41 collapsed to keep the page light
M internal/usecase/picker/model_test.go +237 -26 collapsed to keep the page light

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