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)
3838
3939 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.
4040
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+
4143 ## Keys
4244
4345 No prefix key; everything is Alt.
@@ -50,7 +52,7 @@ No prefix key; everything is Alt.
5052 | Alt+h/j/k/l, Alt+Arrow | focus pane |
5153 | Alt+a | jump to a session needing attention |
5254
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.
5456
5557 ## Remote attach
5658
@@ -78,6 +80,17 @@ vev cmd list-panes --json
7880
7981 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.
8082
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+
8194 ## Configuration
8295
8396 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 (
66 "io"
77 "math"
88 "reflect"
9+ "strconv"
910 "strings"
1011 "testing"
1112
@@ -107,15 +108,77 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
107108 }
108109 }
109110
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+
110178 func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
111179 for _, slug := range []string{
112- "grow-pane-width",
113- "shrink-pane-width",
114- "grow-pane-height",
115- "shrink-pane-height",
116- "equalize-panes",
117- "consume-or-expel-pane-left",
118- "consume-or-expel-pane-right",
180+ "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
181+ "consume-or-expel-pane-left", "consume-or-expel-pane-right",
119182 } {
120183 t.Run(slug, func(t *testing.T) {
121184 invocation, err := parseArgs([]string{"cmd", "--self", slug})
M internal/domain/types.go +7 -1
@@ -92,5 +92,11 @@ type Rect struct {
9292 // SessionID uniquely identifies a session.
9393 type SessionID string
9494
95-// TabID uniquely identifies a tab within a session.
95+// TabID identifies a tab by its session-local, user-facing ID.
9696 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
11181118 releaseSender()
11191119 }
11201120
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.
11221124 for _, tt := range []struct {
11231125 name string
11241126 reason uint8
11251127 wantText string
11261128 }{
11271129 {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"},
11291131 } {
11301132 t.Run(tt.name, func(t *testing.T) {
11311133 err := detachedResult(tt.reason)
M internal/usecase/command/command.go +15 -0
@@ -50,6 +50,8 @@ type Context interface {
5050 ToggleStack() error
5151 ToggleFloatingPane() error
5252 ClosePane() error
53+ OpenMovePanePicker() error
54+ OpenMoveTabPicker() error
5355 FocusPaneLeft() error
5456 FocusPaneRight() error
5557 FocusPaneUp() error
@@ -99,6 +101,16 @@ const (
99101 TargetPane
100102 )
101103
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+
102114 // ControlOptions carries per-invocation output preferences.
103115 type ControlOptions struct {
104116 JSON bool
@@ -115,6 +127,8 @@ type ControlContext interface {
115127 CreateSessionNamed(name string) error
116128 CloseTab() error
117129 ClosePane() error
130+ MovePane(destinationSession, destinationTabID string) error
131+ MoveTab(destinationSession string) error
118132 SplitRight() error
119133 SplitLeft() error
120134 SplitUp() error
@@ -152,6 +166,7 @@ type Command struct {
152166 Scriptable bool
153167 PaletteVisible bool
154168 Target TargetKind
169+ Scope CommandScope
155170 Run func(Context, []string) error
156171 Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
157172 }
M internal/usecase/command/context_mock_test.go +88 -0
@@ -878,6 +878,94 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_
878878 return _c
879879 }
880880
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+
881969 // OpenNotifications provides a mock function for the type MockContext
882970 func (_mock *MockContext) OpenNotifications() error {
883971 ret := _mock.Called()
M internal/usecase/command/registry.go +32 -0
@@ -18,6 +18,8 @@ func Registry() []Command {
1818 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() }),
1919 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
2020 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(),
2123 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() }),
2224 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() }),
2325 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
7880 return cmd
7981 }
8082
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+
81113 func toastCommand() Command {
82114 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) {
83115 severity := "info"
M internal/usecase/command/registry_test.go +99 -22
@@ -9,7 +9,7 @@ import (
99
1010 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
1111 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"}
1313
1414 if len(commands) != len(wantCodes) {
1515 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -39,6 +39,76 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
3939 }
4040 }
4141
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+
42112 func TestBySlugExactMatch(t *testing.T) {
43113 tests := []struct {
44114 slug string
@@ -73,6 +143,7 @@ func TestRegistryControlMetadata(t *testing.T) {
73143 "new-session": TargetSession, "toast": TargetSession,
74144 "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,
75145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146+ "move-pane": TargetPane, "move-tab": TargetTab,
76147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
77148 }
78149 notScriptable := []string{
@@ -193,27 +264,31 @@ func (s *controlSpy) CreateTab() error { return s.record("new-t
193264 func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }
194265 func (s *controlSpy) CloseTab() error { return s.record("close-tab") }
195266 func (s *controlSpy) ClosePane() error { return s.record("close-pane") }
196-func (s *controlSpy) SplitRight() error { return s.record("split-right") }
197-func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
198-func (s *controlSpy) SplitUp() error { return s.record("split-up") }
199-func (s *controlSpy) SplitDown() error { return s.record("split-down") }
200-func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }
201-func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
202-func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
203-func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
204-func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
205-func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }
206-func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }
207-func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }
208-func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }
209-func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
210-func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
211-func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
212-func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") }
213-func (s *controlSpy) NextTab() error { return s.record("next-tab") }
214-func (s *controlSpy) PrevTab() error { return s.record("previous-tab") }
215-func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) }
216-func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) }
267+func (s *controlSpy) MovePane(session, tab string) error {
268+ return s.record("move-pane:" + session + ":" + tab)
269+}
270+func (s *controlSpy) MoveTab(session string) error { return s.record("move-tab:" + session) }
271+func (s *controlSpy) SplitRight() error { return s.record("split-right") }
272+func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
273+func (s *controlSpy) SplitUp() error { return s.record("split-up") }
274+func (s *controlSpy) SplitDown() error { return s.record("split-down") }
275+func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }
276+func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
277+func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
278+func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
279+func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
280+func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }
281+func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }
282+func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }
283+func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }
284+func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
285+func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
286+func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
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) }
217292 func (s *controlSpy) Toast(level, message string) error {
218293 return s.record("toast:" + level + ":" + message)
219294 }
@@ -298,6 +373,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
298373 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
299374 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
300375 {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() }},
301378 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
302379 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
303380 {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) {
4747 // happened — it also covers "no target exists", which is routine and not an
4848 // error. Only a failure to reach a target that does exist is a genuine error.
4949 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 {
5054 if sess == nil || ac == nil {
5155 return nil
5256 }
@@ -62,7 +66,11 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
6266 if !ok {
6367 return nil
6468 }
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)
6674 }
6775
6876 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +274 -306
@@ -39,10 +39,16 @@ type attachedClient struct {
3939 overlays *overlayRuntime
4040 overlayOnce sync.Once
4141 clientID [16]byte
42- resumeCapable bool
43- resumeToken uint64
44- parked bool
45- echoAck atomic.Uint64
42+ // roleGeneration is the wire-facing capability generation. roleEffects is
43+ // the linearization gate for every role-bound observable operation. A role
44+ // transition freezes and drains this gate before changing either generation
45+ // 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
4652 // prepareFailureFallback prevents a direct fallback paint from recursively
4753 // reporting the same failed prepare through its notice repaint. It is only
4854 // needed while no render coordinator is installed.
@@ -56,20 +62,33 @@ type attachedClient struct {
5662 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
5763 // snapshots cannot leak when an attachment switches tabs or sessions.
5864 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
59- size domain.Size
60- keys *keys.Router
61- sess Guarded[*session]
62- mouseScan mouse.Scanner
63- themeMu sync.Mutex
64- clientTheme themeui.Theme
65- appliedTheme appliedTheme
66- lastCursor cursorOut
67- renderStages renderStageHooks // optional render and handoff observability hooks
65+ // initialSnatchedMu elects exactly one reset-panel sender per role generation.
66+ // A Welcome handshake and displaced-client cleanup may both discover the
67+ // same post-transition snatched role; the loser waits for the elected send.
68+ initialSnatchedMu sync.Mutex
69+ initialSnatchedGeneration uint64
70+ initialSnatchedAttempt *initialSnatchedPanelAttempt
71+ size domain.Size
72+ keys *keys.Router
73+ 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
6880 // previousSession is guarded independently. It is retained through temporary
6981 // setSession(nil) hand-offs and cleared only on terminal teardown.
7082 previousSession Guarded[*session]
71- linkMu sync.Mutex
72- sendMu sync.Mutex
83+ // snatchedInputMu serializes the restricted input parser with its delayed
84+ // 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
7392 }
7493
7594 type cursorOut struct {
@@ -209,12 +228,14 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
209228 return nil
210229 }
211230 ac.linkMu.Lock()
212- defer ac.linkMu.Unlock()
213231 if ac.tr != tr {
232+ ac.linkMu.Unlock()
214233 return nil
215234 }
216235 ac.tr = nil
217236 ac.transportIncarnation++
237+ ac.linkMu.Unlock()
238+ ac.clearSnatchedInput()
218239 return tr
219240 }
220241
@@ -232,14 +253,6 @@ func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) b
232253 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
233254 }
234255
235-func (ac *attachedClient) closeTransport() error {
236- tr := ac.transport()
237- if tr == nil {
238- return nil
239- }
240- return tr.Close()
241-}
242-
243256 func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
244257 if tr == nil {
245258 return nil
@@ -263,22 +276,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
263276 ac.output.ack(state)
264277 }
265278
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-
282279 var errTransportReplaced = errors.New("client transport was replaced")
283280
284281 // sendExpectedTransport writes only when expected is still the attachment's
@@ -295,6 +292,21 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
295292 return expected.transport.Send(f)
296293 }
297294
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+
298310 // boundedSend sends f to ac with a deadline watchdog: if the send (including
299311 // waiting on sendMu behind a wedged paint) does not complete within
300312 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -372,12 +384,18 @@ func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transpor
372384 }
373385 }
374386
375-// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
376-// goroutine and then closes the transport. Session teardown (killSession) must
377-// complete regardless of client state, so the notice is both asynchronous and
378-// deadline-bounded; Serve waits for pending notices before force-closing
379-// connections so a graceful notice is not raced by the hard close.
380-func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
387+type detachedAttachmentSnapshot struct {
388+ ac *attachedClient
389+ transport transportSnapshot
390+}
391+
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+ }
381399 done := make(chan struct{})
382400 d.mu.Lock()
383401 // Prune completed entries so the slice stays bounded by the number of
@@ -395,8 +413,14 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
395413
396414 go func() {
397415 defer close(done)
398- d.boundedSend(ac, frameDetached(reason))
399- _ = ac.closeTransport()
416+ _, _ = d.boundedSendWith(snapshot.transport.transport, func() error {
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)
400424 d.log.Info("client detached", "reason", reason)
401425 }()
402426 }
@@ -424,13 +448,39 @@ type attachClientOptions struct {
424448 maxOutputInFlight uint8
425449 }
426450
427-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
428- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts)
429- cleanup.finish()
430- return ac, old
451+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
452+ d.mu.Lock()
453+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
454+ 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)
431478 }
432479
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 {
434484 resumeToken := uint64(0)
435485 if opts.resumeCapable {
436486 resumeToken = d.nextResumeTokenLocked()
@@ -444,54 +494,8 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
444494 resumeToken: resumeToken,
445495 }
446496 ac.initOverlays()
447- ac.setSession(sess)
448497 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
449- // Daemon attachment callers hold d.mu, serialising this preparation with
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- }
498+ return ac
495499 }
496500
497501 // attachCoordinator is the sole direct attachment handoff. It creates at
@@ -504,37 +508,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
504508 }
505509
506510 func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
507- sess.mu.Lock()
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()
511+ rc := d.ensureRenderCoordinator(sess)
538512 var cleanup renderLifecycleCleanup
539513 if old != nil {
540514 cleanup = rc.beginReplace(old, current, ready)
@@ -544,6 +518,51 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
544518 return rc, cleanup
545519 }
546520
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+
547566 // resetScreenDefaultColors clears the known default foreground/background
548567 // colors on every tab in sess. Called once a client has been detached, this
549568 // makes child OSC 10/11 queries go back to being swallowed (Known=false)
@@ -559,43 +578,51 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
559578 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
560579 }
561580
562-// retireReplacedClient owns old-link retirement and obsolete render-worker
563-// joins outside the replacement handshake. If a render already owns sendMu,
564-// close immediately interrupts it; no Detached frame can safely overtake that
565-// render. Otherwise the old link gets its bounded protocol notice first.
566-func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) {
567- if old == nil {
568- d.attachmentCleanupWg.Go(cleanup.finish)
569- return
581+// firstPaintForTransition is the only active post-transition paint entry
582+// point. It rejects a stale role capability before rebasing output and carries
583+// the exact coordinator lease through every resize and render effect.
584+func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
585+ if !token.activeCurrent() {
586+ return false
570587 }
571- oldTransport := old.transportSnapshot().transport
572- blockedRender := !old.sendMu.TryLock()
573- if !blockedRender {
574- old.sendMu.Unlock()
588+ ticket, admitted := token.ac.beginRoleEffect(token)
589+ if !admitted {
590+ return false
575591 }
576-
577- d.attachmentCleanupWg.Go(func() {
578- if !blockedRender {
579- // A healthy idle client observes the required replacement notice. Its
580- // watchdog still bounds a peer that stops draining after this check.
581- d.boundedSend(old, frameDetached(ports.ReasonReplaced))
592+ token.effect = ticket
593+ defer token.endRoleEffect()
594+ if d.afterRoleEffectAdmitted != nil {
595+ d.afterRoleEffectAdmitted(token)
596+ }
597+ if token.rebase {
598+ if d.beforeFirstPaintSendWait != nil {
599+ d.beforeFirstPaintSendWait(token)
582600 }
583- _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))
584- d.unregisterPreview(old)
585- old.clearPreviousSession()
586- old.setSession(nil)
587- d.log.Info("client displaced")
588- })
589- // Join independently: the close above releases a blocked render while the
590- // new attachment is already fully published.
591- d.attachmentCleanupWg.Go(cleanup.finish)
601+ token.ac.sendMu.Lock()
602+ if token.ac.renderStages.handoffRebase != nil {
603+ token.ac.renderStages.handoffRebase()
604+ }
605+ if token.ac.output != nil {
606+ token.ac.output.rebase()
607+ }
608+ // Captures belong to the old session even when pane-local IDs happen
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
592615 }
593616
594617 // firstPaint guarantees the freshly attached client sees the full screen: if
595618 // 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 fallback
597-// timer.
619+// emits a full redraw. It is retained for direct test/headless setup; active
620+// role transitions use firstPaintForTransition with their exact lease.
598621 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) {
599626 // Global notices raised while nothing was attached surface on this client.
600627 // Drained before the early return below so a session without an active tab
601628 // cannot swallow the queue.
@@ -610,134 +637,30 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
610637
611638 outerResizeAccepted := false
612639 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+ }
614645 }
615646 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
616647 // Activation can synchronously resize a retained floating pane. An accepted
617648 // synchronous outer request already includes that pane and emits the reset,
618649 // but activation still performs its warmup work.
619- activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
650+ activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)
620651 if !outerResizeAccepted && !activationResized {
621- d.invalidateRenderNow(sess, ac, true, "client.go")
622- }
623-}
624-
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.
652+ if lease == nil {
653+ d.invalidateRenderNow(sess, ac, true, "client.go")
654+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
655+ rc.fireCurrent(false)
690656 }
691657 }
692658 }
693659
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-
737660 // detachIfCurrent clears the client iff ac is the current one, reporting
738661
739-func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
740- clientTheme := themeui.Theme{
662+func themeFromMessage(msg ports.Theme) themeui.Theme {
663+ return themeui.Theme{
741664 Foreground: msg.Foreground,
742665 Background: msg.Background,
743666 Palette: msg.Palette,
@@ -749,6 +672,10 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
749672 SchemeKnown: msg.SchemeKnown,
750673 Light: msg.Light,
751674 }
675+}
676+
677+func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
678+ clientTheme := themeFromMessage(msg)
752679 ac.setClientTheme(clientTheme)
753680
754681 if !d.applyHostTheme(sess, ac, clientTheme, false) {
@@ -757,6 +684,24 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
757684 d.invalidateRender(sess, ac, true, "client.go")
758685 }
759686
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+
760705 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
761706 d.requestTransactionalResize(sess, ac, sz, false)
762707 }
@@ -767,32 +712,78 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
767712 // publishing sess.client. Never retain sess.mu while touching notice or overlay
768713 // state, and retain the routingMu -> sess.mu order used by attachment paths.
769714 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) {
770730 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+}
772748
749+func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
750+ d.notices.routingMu.Lock()
773751 sess.mu.Lock()
774752 current := sess.client == ac
775753 sess.mu.Unlock()
776754 if !current {
755+ d.notices.routingMu.Unlock()
777756 return
778757 }
779758 if d.notices.beforeClientNoticeMutation != nil {
780759 d.notices.beforeClientNoticeMutation()
781760 }
761+ repaint := d.mutateClientNotice(sess, ac, notice)
762+ d.notices.routingMu.Unlock()
763+ if repaint {
764+ d.repaintForNotice(ac)
765+ }
766+}
782767
768+func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
783769 switch notice.Action {
784770 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")
786772 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")
788774 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")
790776 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
792780 }
793781 }
794782
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 {
796787 n := d.notices.record(domain.Notification{
797788 Code: code,
798789 Severity: sev,
@@ -800,7 +791,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai
800791 Time: d.clock.Now(),
801792 SessionID: sess.id,
802793 })
803- d.showToast(ac, n)
794+ return d.publishToast(ac, n)
804795 }
805796
806797 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
@@ -809,29 +800,6 @@ func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domai
809800 return d.requestTransactionalResize(sess, ac, sz, true)
810801 }
811802
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-
835803 // killSession removes a session and tears down its resources. It is
836804 // idempotent: only the caller that wins the registry delete acts. When the
837805 // 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 @@
44 package daemon
55
66 import (
7+ "errors"
78 "os"
89
910 "github.com/bnema/vev/internal/ports"
@@ -25,12 +26,27 @@ var (
2526 clipPasteCloseMarker = []byte("\x1b[201~")
2627 )
2728
28-// handleSequencedImagePush routes an ImagePush through the same input-sequence
29-// surface as ordinary keystrokes. Echo prediction is still conservative; the
30-// sequence is carried so resume/dedup plumbing can order image pushes with
31-// surrounding input when that state reader grows beyond ordinary input.
32-func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33- d.handleImagePush(sess, ip)
29+func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {
30+ if !token.activeEffect() {
31+ return
32+ }
33+ d.handleImagePushForRole(token, 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)
3450 }
3551
3652 // 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) {
5874 // writeClipboardImage writes ip's bytes to a new 0600 temp file and records
5975 // the path on sess for best-effort cleanup at session end (killSession).
6076 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) {
61104 dir := d.tempDir
62105 if dir == "" {
63106 dir = os.TempDir()
@@ -76,9 +119,6 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
76119 _ = os.Remove(path)
77120 return "", err
78121 }
79- sess.mu.Lock()
80- sess.clipFiles = append(sess.clipFiles, path)
81- sess.mu.Unlock()
82122 return path, nil
83123 }
84124
@@ -86,6 +126,17 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
86126 // the same PTY write path as ordinary input, wrapped in bracketed-paste
87127 // markers iff the pane's Screen reports bracketed-paste mode enabled.
88128 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) {
89140 tb := sess.activeTab()
90141 if tb == nil {
91142 return
@@ -109,6 +160,9 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
109160 wrapped = append(wrapped, clipPasteCloseMarker...)
110161 data = wrapped
111162 }
163+ if token != nil && !token.activeEffect() {
164+ return
165+ }
112166 d.writeToPane(sess, p, data)
113167 }
114168
@@ -132,8 +186,9 @@ func clipboardExt(mime string) string {
132186 }
133187
134188 type clipboardForward struct {
135- ac *attachedClient
136- seq []byte
189+ owner paneEffectLease
190+ token attachmentRoleToken
191+ seq []byte
137192 }
138193
139194 // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
@@ -145,11 +200,12 @@ type clipboardForward struct {
145200 //
146201 // Requests are queued on the session and drained by one worker so clipboard
147202 // 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) {
149204 seq := scopy.OSC52FromBase64(b64)
150- if seq == nil {
205+ if seq == nil || !owner.Current() {
151206 return
152207 }
208+ sess := owner.owner.session
153209
154210 sess.mu.Lock()
155211 if sess.ctx != nil {
@@ -161,11 +217,31 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
161217 }
162218 }
163219 ac := sess.client
220+ sess.mu.Unlock()
164221 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 {
165241 sess.mu.Unlock()
166242 return
167243 }
168- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
244+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq})
169245 if !sess.clipboardWorkerRunning {
170246 sess.clipboardWorkerRunning = true
171247 go d.clipboardWorker(sess)
@@ -173,15 +249,88 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
173249 sess.mu.Unlock()
174250 }
175251
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+
176314 func (d *Daemon) clipboardWorker(sess *session) {
177315 for {
178316 item, ok := nextClipboardForward(sess)
179317 if !ok {
180318 return
181319 }
182- failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
183- if err != nil {
184- d.detachOnSendError(sess, item.ac, failed)
320+ if !item.owner.Current() {
321+ continue
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)
185334 }
186335 }
187336 }
M internal/usecase/daemon/clipboard_test.go +220 -13
@@ -3,10 +3,12 @@ package daemon
33 import (
44 "context"
55 "encoding/base64"
6+ "errors"
67 "io"
78 "os"
89 "path/filepath"
910 "strings"
11+ "sync"
1012 "testing"
1113 "time"
1214
@@ -200,6 +202,25 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
200202 return p
201203 }
202204
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+
203224 func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
204225 d := newTestDaemon(t, nil, stubClock{})
205226 p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
@@ -211,16 +232,25 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
211232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
212233 ac.initOverlays()
213234 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
235+ publishTiledPaneOwners(sess, win)
214236 ac.setSession(sess)
215237 d.sessions[sess.id] = sess
238+ publishActiveClipboardCapability(d, sess, ac, tr)
216239
217240 d.sessWg.Add(1)
218241 d.ptyReader(sess, win, win.focusedPane())
219242
220- f := awaitFrame(t, sends, ports.MsgOutput)
221- out, err := ports.UnmarshalOutput(f.Payload)
222- require.NoError(t, err)
223- require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
243+ var clipboardOutput string
244+ for range 3 {
245+ f := awaitFrame(t, sends, ports.MsgOutput)
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")
224254 }
225255
226256 func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
@@ -236,6 +266,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
236266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
237267 ac.initOverlays()
238268 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
269+ publishTiledPaneOwners(sess, win)
239270 ac.setSession(sess)
240271 d.sessions[sess.id] = sess
241272
@@ -260,6 +291,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
260291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
261292 ac.initOverlays()
262293 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
294+ publishTiledPaneOwners(sess, win)
263295 ac.setSession(sess)
264296 d.sessions[sess.id] = sess
265297
@@ -281,6 +313,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
281313 defer cancel()
282314 win := newTestTabWithContext(p, sctx, cancel)
283315 sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
316+ publishTiledPaneOwners(sess, win)
284317 d.sessions[sess.id] = sess
285318
286319 d.sessWg.Add(1)
@@ -289,22 +322,196 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
289322 })
290323 }
291324
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+
292502 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
293- d := newTestDaemon(t, nil, stubClock{})
503+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
294504 tr := newBlockingClipboardTransport()
295- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
296- ac.initOverlays()
297- sctx, cancel := context.WithCancel(context.Background())
298- defer cancel()
299- sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
300- ac.setSession(sess)
505+ ac.replaceTransport(tr)
506+ publishActiveClipboardCapability(d, sess, ac, tr)
507+ owner := clipboardOwnerLease(t, sess)
301508
302509 first := base64.StdEncoding.EncodeToString([]byte("first"))
303510 second := base64.StdEncoding.EncodeToString([]byte("second"))
304- d.forwardClipboardAsync(sess, first)
511+ d.forwardClipboardAsync(owner, first)
305512 require.Equal(t, "first", <-tr.started)
306513
307- d.forwardClipboardAsync(sess, second)
514+ d.forwardClipboardAsync(owner, second)
308515 select {
309516 case got := <-tr.started:
310517 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
5252 if sess == nil {
5353 return commandFailure(code, text)
5454 }
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+
5568 sess.dispatchMu.Lock()
5669 defer sess.dispatchMu.Unlock()
5770
@@ -72,6 +85,8 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
7285 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
7386 case errors.Is(err, errSessionNameInUse):
7487 return commandFailure(ports.ErrNameTaken, err.Error())
88+ case isMoveCommandError(err):
89+ return moveCommandFailure(err)
7590 case errors.Is(err, layout.ErrNotInSplit):
7691 return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")
7792 case errors.Is(err, layout.ErrTooSmall):
@@ -86,8 +101,8 @@ func commandFailure(code uint16, text string) ports.CommandResult {
86101 }
87102
88103 // resolveTargetSession applies explicit-name, stable-ID, then unique-session
89-// resolution. A name paired with IDs is advisory only when no live session has
90-// that name (the VEV value can survive a rename).
104+// resolution. A name paired with complete stable IDs is always advisory because
105+// a process keeps its original VEV session component after relocation.
91106 func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
92107 d.mu.Lock()
93108 defer d.mu.Unlock()
@@ -106,13 +121,18 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
106121 if request.TargetTab == "" || request.TargetPane == "" {
107122 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
108123 }
124+ var match *session
109125 for _, sess := range d.sessions {
110- if sess.containsStableIDs(request.TargetTab, request.TargetPane) {
111- if named != nil && named != sess {
112- return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session"
113- }
114- return sess, 0, ""
126+ if !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
127+ continue
128+ }
129+ if match != nil {
130+ return nil, ports.ErrAmbiguousTarget, "target tab and pane IDs are ambiguous"
115131 }
132+ match = sess
133+ }
134+ if match != nil {
135+ return match, 0, ""
116136 }
117137 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
118138 }
@@ -402,6 +422,51 @@ func (e controlExec) CloseTab() error {
402422 func (e controlExec) ClosePane() error {
403423 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
404424 }
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+}
405470 func (e controlExec) split(direction layout.Direction) error {
406471 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
407472 }
M internal/usecase/daemon/control_test.go +373 -4
@@ -169,10 +169,10 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
169169 {name: "missing stable IDs", arrange: func(d *Daemon) {
170170 addControlSession(d, "work", "t_work", "p_work")
171171 }, 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) {
173- addControlSession(d, "one", "t_one", "p_one")
174- addControlSession(d, "two", "t_two", "p_two")
175- }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget},
172+ {name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) {
173+ addControlSession(d, "one", "t_shared", "p_shared")
174+ addControlSession(d, "two", "t_shared", "p_shared")
175+ }, request: ports.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: ports.ErrAmbiguousTarget},
176176 {name: "self requires both IDs", arrange: func(d *Daemon) {
177177 addControlSession(d, "work", "t_work", "p_work")
178178 }, 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) {
707707 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
708708 }
709709
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+
7101079 func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
7111080 t.Helper()
7121081 if request.Version == 0 {
M internal/usecase/daemon/copymode_lifecycle_test.go +33 -0
@@ -55,6 +55,39 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
5555 ac.overlays.copyMu.Unlock()
5656 }
5757
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+
5891 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
5992 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
6093 tb := sess.activeTab()
M internal/usecase/daemon/copymode_test.go +2 -1
@@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
845845 awaitFrame(t, sends, ports.MsgOutput)
846846 tb.mu.Lock()
847847 generation := tb.floating.generation
848+ publishPaneOwner(fp, sess, tb, generation)
848849 tb.mu.Unlock()
849850
850- d.reapFloating(sess, tb, fp, generation)
851+ d.reapInstalledFloating(fp)
851852
852853 ac.overlays.copyMu.Lock()
853854 require.Nil(t, ac.overlays.copyMode)
M internal/usecase/daemon/daemon.go +221 -58
@@ -85,6 +85,19 @@ type Daemon struct {
8585 // under the same mutex — so a Hello racing shutdown can never insert a new
8686 // session that nobody would tear down.
8787 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+
88101 // notifies holds one completion channel per in-flight async Detached
89102 // notification (guarded by mu, pruned on insert). Channels rather than a
90103 // WaitGroup: notifications are spawned from arbitrary goroutines while
@@ -112,18 +125,84 @@ type Daemon struct {
112125 // and before coordinator epoch admission. It is a deterministic regression
113126 // seam for stale resize publication.
114127 beforeSessionResizePublication func()
115- ptys ports.PTYFactory
116- clock ports.Clock
117- log *slog.Logger
118- runtimeObserver ports.RuntimeObserver
119- baseEnv []string
120- shell string
121- shellArgs []string
122- shellOverride bool
123- persistEnabled bool
124- catalogue ports.Catalogue
125- catalogueRecords []domain.CatalogueRecord
126- catalogueRecordsProvided bool
128+ // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
129+ // and immediately before a post-commit effect is published. Tests use it to
130+ // move a pane through the ordered resize fences at the former TOCTOU window.
131+ beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
132+ // afterResizeCommitSendLocked is a deterministic lock-order seam. It runs
133+ // after publishResizeCommit acquires attachment sendMu and before it acquires
134+ // owner fences. Tests must not perform external work from this callback.
135+ afterResizeCommitSendLocked func()
136+ // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
137+ // after one overlay-family mutex is released and before the next is taken.
138+ afterSnatchOverlayFamily func(string)
139+ // 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
127206 // snapshotRepository is the sole checkpoint storage contract.
128207 snapshotRepository ports.SnapshotRepository
129208 recovery *recoveryusecase.Coordinator
@@ -212,6 +291,7 @@ type Daemon struct {
212291 type parkedAttachment struct {
213292 sess *session
214293 ac *attachedClient
294+ role attachmentRole
215295 timer ports.Timer
216296 done chan struct{}
217297 doneOnce sync.Once
@@ -443,25 +523,28 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
443523 if shell == "" {
444524 shell = "/bin/sh"
445525 }
526+ paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
446527 d := &Daemon{
447- sessions: make(map[domain.SessionID]*session),
448- stopped: make(map[string]stoppedSession),
449- creating: make(map[string]struct{}),
450- parked: make(map[uint64]*parkedAttachment),
451- ptys: ptys,
452- clock: clock,
453- log: log,
454- baseEnv: os.Environ(),
455- shell: shell,
456- dirOrHome: dirOrHome,
457- done: make(chan struct{}),
458- restoreDone: make(chan struct{}),
459- animWake: make(chan struct{}, 1),
460- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
461- snapshotAdmitted: make(map[*snapshotCapture]struct{}),
462- snapshotWake: make(chan struct{}, 1),
463- notices: newNoticeCenter(),
464- resumeParkGrace: defaultResumeParkGrace,
528+ sessions: make(map[domain.SessionID]*session),
529+ stopped: make(map[string]stoppedSession),
530+ creating: make(map[string]struct{}),
531+ parked: make(map[uint64]*parkedAttachment),
532+ paneProcessCtx: paneProcessCtx,
533+ paneProcessCancel: paneProcessCancel,
534+ ptys: ptys,
535+ clock: clock,
536+ log: log,
537+ baseEnv: os.Environ(),
538+ shell: shell,
539+ dirOrHome: dirOrHome,
540+ done: make(chan struct{}),
541+ restoreDone: make(chan struct{}),
542+ animWake: make(chan struct{}, 1),
543+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
544+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
545+ snapshotWake: make(chan struct{}, 1),
546+ notices: newNoticeCenter(),
547+ resumeParkGrace: defaultResumeParkGrace,
465548 barScripts: &barScriptState{
466549 cfg: barConfigFromDomain(domain.Defaults().Bar),
467550 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -635,14 +718,14 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
635718 }
636719
637720 func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
721+ d.closeMoveLifecycles()
638722 d.mu.Lock()
639723 d.closing = true
640- for token, parked := range d.parked {
641- d.removeParkedLocked(token, parked)
642- }
724+ parkedRetirements := d.purgeAllParkedLocked()
643725 snapshot := d.sessionsSnapshotLocked()
644726 empty := len(snapshot) == 0
645727 d.mu.Unlock()
728+ finishParkedAttachmentRetirements(parkedRetirements)
646729 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
647730 if empty {
648731 d.doneOnce.Do(func() { close(d.done) })
@@ -656,7 +739,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
656739 ephemeral := s.ephemeral
657740 s.mu.Unlock()
658741 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 {
660743 checkpointIncomplete = true
661744 d.log.Error("closing session with unpersisted terminal state", "err", err)
662745 }
@@ -918,21 +1001,64 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
9181001 lease = rc.attachmentLease(ac)
9191002 }
9201003 expected := ac.transportSnapshot()
921- if lease == nil || expected.transport != tr {
922- d.clientGone(sess, ac, tr, false)
1004+ welcomeToken := sess.attachmentToken(ac, tr)
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+ }
9231017 return
9241018 }
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 {
9261034 d.clientGone(sess, ac, tr, false)
9271035 return
9281036 }
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()
9301051 // The attachment was displaced or detached while Welcome was in flight;
9311052 // never let this stale handshake emit an Output frame.
9321053 d.clientGone(sess, ac, tr, false)
9331054 return
9341055 }
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+ }
9361062 d.runConnLoop(ac)
9371063 _ = tr.Close()
9381064 }
@@ -945,24 +1071,42 @@ type protoErr struct {
9451071
9461072 func (e *protoErr) Error() string { return e.text }
9471073
948-// finishAttach completes an attachment prepared while d.mu is held. It
949-// publishes the terminal state before releasing d.mu, then queues replacement
950-// teardown so obsolete worker joins never delay the new handshake.
951-func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {
1074+// finishAttach completes an attachment prepared while d.mu is held. Every
1075+// caller must hold d.mu on entry; finishAttach transfers ownership by
1076+// unlocking d.mu before returning, including every error path. It publishes
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) {
9521080 // Session state is the sole source for future PTY children. Update it before
9531081 // publishing the attachment; existing PTYs keep their original environment.
9541082 sess.mu.Lock()
9551083 sess.env = copyEnvironment(h.Env)
9561084 sess.terminal = term
9571085 sess.mu.Unlock()
958- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
1086+ opts := attachClientOptions{
9591087 clientID: h.ClientID,
9601088 resumeCapable: true,
9611089 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
962- })
1090+ }
1091+ ac := d.prepareAttachedClientLocked(tr, sz, opts)
9631092 d.mu.Unlock()
964- d.retireReplacedClient(old, cleanup)
965- return ac
1093+ result, err := d.transitionAttachment(attachmentTransitionRequest{
1094+ 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
9661110 }
9671111
9681112 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
10211165 }
10221166 term := terminalEnv{TrueColor: h.TrueColor}
10231167
1024- if h.Intent == ports.IntentResume {
1025- if sess, ac, ok, err := d.resumeParked(h, tr, sz); ok || err != nil {
1026- return sess, ac, err
1168+ // A non-zero token is an authoritative resume credential. If it is unknown,
1169+ // expired, or raced with lifecycle teardown, fail closed instead of routing
1170+ // 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
10271190 }
10281191 }
10291192 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
10631226 return nil, nil, err
10641227 }
10651228 }
1066- d.purgeParkedForSessionLocked(sess)
1067- return sess, d.finishAttach(sess, tr, sz, term, h), nil
1229+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1230+ return sess, ac, err
10681231
10691232 case ports.IntentEphemeral:
10701233 name := d.allocEphemeralNameLocked()
@@ -1073,8 +1236,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
10731236 d.mu.Unlock()
10741237 return nil, nil, err
10751238 }
1076- d.purgeParkedForSessionLocked(sess)
1077- return sess, d.finishAttach(sess, tr, sz, term, h), nil
1239+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1240+ return sess, ac, err
10781241
10791242 case ports.IntentNew:
10801243 if h.Name == "" {
@@ -1094,8 +1257,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
10941257 d.mu.Unlock()
10951258 return nil, nil, err
10961259 }
1097- d.purgeParkedForSessionLocked(sess)
1098- return sess, d.finishAttach(sess, tr, sz, term, h), nil
1260+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1261+ return sess, ac, err
10991262
11001263 case ports.IntentAttach:
11011264 sess := d.findByNameLocked(h.Name)
@@ -1113,8 +1276,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
11131276 return nil, nil, err
11141277 }
11151278 }
1116- d.purgeParkedForSessionLocked(sess)
1117- return sess, d.finishAttach(sess, tr, sz, term, h), nil
1279+ ac, err := d.finishAttach(sess, tr, sz, term, h)
1280+ return sess, ac, err
11181281
11191282 default:
11201283 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.
381381
382382 func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
383383 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...)
385390 }
386391
387392 func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
388393 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)
390400 tr, sends := newCapturingTransport(t)
391401 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
392402 ac.initOverlays()
@@ -402,6 +412,9 @@ func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ..
402412 tabs = append(tabs, tb)
403413 }
404414 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
415+ for _, tb := range tabs {
416+ publishTiledPaneOwners(sess, tb)
417+ }
405418 ac.setSession(sess)
406419 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
407420 d.sessions[sess.id] = sess
M internal/usecase/daemon/floating.go +98 -46
@@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 {
4242 return tb.floating.generation
4343 }
4444
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+
4553 // toggleFloatingLocked applies the user action. Its result says whether a new
4654 // PTY launch is required and returns the generation associated with that launch
4755 // or current slot.
@@ -160,15 +168,33 @@ func (d *Daemon) activateTab(sess *session, tb *tab) bool {
160168 // activateTabAfterResize retains tab activation's warmup work while avoiding a
161169 // second resize when a synchronous outer resize request was already accepted.
162170 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 {
163178 if d == nil || sess == nil || tb == nil {
164179 return false
165180 }
166181 // A headless session has no actual terminal destination. Deferring warmup
167182 // until firstPaint keeps restored tabs cold and avoids launching children
168183 // merely because a tab was manipulated during teardown.
169- sess.mu.Lock()
170- ac := sess.client
171- sess.mu.Unlock()
184+ if ac == nil {
185+ sess.mu.Lock()
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+ }
172198 d.exitCopyMode(ac)
173199 if ac == nil {
174200 return false
@@ -182,6 +208,9 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep
182208 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
183209 tb.mu.Unlock()
184210 if hasFloating {
211+ if lease != nil {
212+ return d.requestTransactionalResizeForLease(sess, ac, lease, size, true)
213+ }
185214 return d.requestTransactionalResize(sess, ac, size, true)
186215 }
187216 return false
@@ -366,17 +395,22 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
366395 if err := spec.parentCtx.Err(); err != nil {
367396 return
368397 }
369- openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
370- stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
398+ lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
371399 ownedByPane := false
372400 defer func() {
373401 if !ownedByPane {
374- stopSessionCancel()
375- cancelOpen()
402+ lifetime.abort()
376403 }
377404 }()
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)
379406 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+ }
380414 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
381415 return
382416 }
@@ -384,17 +418,17 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
384418 p.rect = spec.geometry.ptyRect()
385419 p.popupGeometry = spec.geometry
386420 p.title.displayFallback = spec.fallback
387- // CommandContext owns the child through openCtx, so retain both this context
388- // and its cancellation until the installed pane is reaped or torn down.
389- p.ctx = openCtx
390- p.cancel = func() {
391- stopSessionCancel()
392- cancelOpen()
421+ if !lifetime.publish(p) {
422+ _ = pty.Close()
423+ d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, lifetime.ctx.Err())
424+ return
393425 }
394426 ownedByPane = true
395427 // The reader may run as soon as install returns; make its exit policy
396- // immutable before publishing the pane to the slot.
397- p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
428+ // immutable before publishing the pane to the slot. Installed panes resolve
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) }
398432 d.installFloating(sess, tb, p, generation)
399433 }
400434
@@ -423,8 +457,18 @@ func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, u
423457 // installFloating installs only the current generation. A stale successful
424458 // launch is cancelled and closed outside the tab lock.
425459 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+ }
426464 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)
428472 visible := installed && tb.floating.state == floatingVisible
429473 tb.mu.Unlock()
430474 if !installed {
@@ -444,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
444488 }
445489 }
446490
447-// reapFloating ignores old readers and restores the background only when the
448-// exiting popup was visible.
449-func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {
450- tb.mu.Lock()
451- visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
452- cleared := tb.clearFloatingLocked(p, generation)
453- tb.mu.Unlock()
454- if !cleared {
491+// reapInstalledFloating resolves the pane's current immutable owner on every
492+// attempt. If ownership changes before the tab lock is acquired, retrying the
493+// lookup routes exit through the destination. The owner pointer and floating
494+// slot generation are then checked together under tb.mu so an old exit can
495+// never clear a reused slot.
496+func (d *Daemon) reapInstalledFloating(p *pane) {
497+ if p == nil {
455498 return
456499 }
457- closeFloatingPane(p)
458- sess.mu.Lock()
459- ac := sess.client
460- sess.mu.Unlock()
461- if ac != nil {
462- ac.pruneCaptureFrames(p)
463- }
464- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
465- if ac != nil && (visible || copyCleared) {
466- d.invalidateRender(sess, ac, true, "floating.go")
500+ for {
501+ owner := p.ownerSnapshot()
502+ if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 {
503+ return
504+ }
505+ sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration
506+ tb.mu.Lock()
507+ if p.ownerSnapshot() != owner {
508+ tb.mu.Unlock()
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
467529 }
468530 }
469531
@@ -485,17 +547,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
485547 // closeFloatingPane releases a floating runtime at most once. It is called
486548 // only after a generation/identity check has detached the pane from its slot.
487549 func closeFloatingPane(p *pane) {
488- if p == nil {
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- })
550+ closePaneProcess(p)
499551 }
500552
501553 func floatingCommandFallback(command, shell string) string {
M internal/usecase/daemon/floating_open_test.go +175 -7
@@ -2,7 +2,9 @@ package daemon
22
33 import (
44 "context"
5+ "io"
56 "sync"
7+ "sync/atomic"
68 "testing"
79 "time"
810
@@ -71,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st
7173
7274 func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
7375 teardowns := []struct {
74- name string
75- teardown func(*Daemon, *session, *tab)
76+ name string
77+ desiredVisible bool
78+ teardown func(*Daemon, *session, *tab)
7679 }{
7780 {
78- name: "floating pane",
81+ name: "hidden floating pane",
7982 teardown: func(d *Daemon, _ *session, tb *tab) {
8083 d.teardownFloating(tb, nil)
8184 },
8285 },
8386 {
84- name: "tab",
87+ name: "visible tab",
88+ desiredVisible: true,
8589 teardown: func(_ *Daemon, _ *session, tb *tab) {
8690 tb.closeAllPanes()
8791 },
8892 },
8993 {
90- name: "session",
94+ name: "visible session",
95+ desiredVisible: true,
9196 teardown: func(d *Daemon, sess *session, _ *tab) {
9297 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
9398 },
@@ -107,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
107112 d.sessions[sess.id] = sess
108113 d.mu.Unlock()
109114 tb.mu.Lock()
110- generation := tb.beginFloatingWarmLocked(true)
115+ generation := tb.beginFloatingWarmLocked(tt.desiredVisible)
111116 tb.mu.Unlock()
112117
113118 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
@@ -127,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
127132 sess.mu.Lock()
128133 sess.client = &attachedClient{}
129134 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")
131136 sess.mu.Lock()
132137 sess.client = nil
133138 sess.mu.Unlock()
@@ -154,6 +159,169 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
154159 }
155160 }
156161
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+
157325 func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
158326 tests := []struct {
159327 name string
M internal/usecase/daemon/floating_test.go +85 -5
@@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) {
131131 }
132132 }
133133
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+
134158 func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
135159 pty := portsmocks.NewMockPTY(t)
136160 readerStarted := make(chan struct{})
@@ -337,28 +361,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
337361 second := &pane{}
338362 tb.mu.Lock()
339363 g1 := tb.beginFloatingWarmLocked(true)
364+ publishPaneOwner(first, sess, tb, g1)
340365 require.True(t, tb.installFloatingLocked(first, g1))
341- g2 := tb.clearFloatingLocked(first, g1)
342- require.True(t, g2)
366+ clearSucceeded := tb.clearFloatingLocked(first, g1)
367+ require.True(t, clearSucceeded)
343368 generation := tb.beginFloatingWarmLocked(true)
344369 require.True(t, tb.installFloatingLocked(second, generation))
345370 tb.mu.Unlock()
346- d.reapFloating(sess, tb, first, g1)
371+ d.reapInstalledFloating(first)
347372 tb.mu.Lock()
348373 require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
349374 tb.mu.Unlock()
350375 }
351376
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+
352429 func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
353430 pty, _ := newBlockingPTY(t)
354431 tb := newFloatingTestTab(t)
355432 p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
433+ sess := &session{}
356434 tb.mu.Lock()
357435 g := tb.beginFloatingWarmLocked(true)
436+ publishPaneOwner(p, sess, tb, g)
358437 require.True(t, tb.installFloatingLocked(p, g))
359438 tb.mu.Unlock()
360439 d := newTestDaemon(t, nil, stubClock{})
361- d.reapFloating(&session{}, tb, p, g)
440+ d.reapInstalledFloating(p)
362441 d.teardownFloating(tb, nil)
363442 pty.AssertNumberOfCalls(t, "Close", 1)
364443 }
@@ -423,6 +502,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
423502 }).Once()
424503 floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
425504 generation := tb.beginFloatingWarmLocked(state == floatingVisible)
505+ publishPaneOwner(floating, sess, tb, generation)
426506 require.True(t, tb.installFloatingLocked(floating, generation))
427507 if state == floatingHidden {
428508 require.Equal(t, floatingHidden, tb.floating.state)
@@ -439,7 +519,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
439519
440520 reaped := make(chan struct{})
441521 floating.onExit = func() {
442- d.reapFloating(sess, tb, floating, generation)
522+ d.reapInstalledFloating(floating)
443523 close(reaped)
444524 }
445525 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
2020 d.handleInput(sess, ac, data)
2121 }
2222
23+func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {
24+ if !token.activeEffect() {
25+ return
26+ }
27+ d.handleInputForRole(token, data)
28+}
29+
2330 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
2431 ac.initOverlays()
2532 ac.mouseScan.Scan(data,
@@ -33,6 +40,30 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3340 )
3441 }
3542
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+
3667 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
3768 frameEvent := ev
3869 ac.initOverlays()
@@ -267,16 +298,49 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
267298 }
268299
269300 type daemonKeyHandler struct {
270- d *Daemon
271- ac *attachedClient
272- actions daemonActionRunner
301+ d *Daemon
302+ ac *attachedClient
303+ actions daemonActionRunner
304+ roleToken attachmentRoleToken
273305 }
274306
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+ }
276329 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()
277338 if sess == nil {
278339 return
279340 }
341+ if owned {
342+ defer effect.End()
343+ }
280344 tb := sess.activeTab()
281345 if tb == nil {
282346 return
@@ -288,10 +352,13 @@ func (h daemonKeyHandler) Forward(data []byte) {
288352 }
289353
290354 func (h daemonKeyHandler) Action(action keys.Action) {
291- sess := h.ac.currentSession()
355+ sess, effect, owned := h.acquireRoleEffect()
292356 if sess == nil {
293357 return
294358 }
359+ if owned {
360+ defer effect.End()
361+ }
295362 runAction := func(request daemonActionRequest) {
296363 request.target = resolveDaemonActionTarget(sess)
297364 runner := h.actions
@@ -317,7 +384,16 @@ func (h daemonKeyHandler) Action(action keys.Action) {
317384 case keys.ActionOpenPalette:
318385 h.d.enterPalette(sess, h.ac)
319386 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 {
321397 h.d.reportError(sess, err)
322398 }
323399 case keys.ActionToggleFloatingPane:
@@ -326,19 +402,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
326402 h.d.reportError(sess, err)
327403 }
328404 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) {
330406 h.d.reportError(sess, err)
331407 }
332408 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) {
334410 h.d.reportError(sess, err)
335411 }
336412 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) {
338414 h.d.reportError(sess, err)
339415 }
340416 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) {
342418 h.d.reportError(sess, err)
343419 }
344420 case keys.ActionGrowPaneWidth:
M internal/usecase/daemon/layout_mutation.go +9 -9
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "slices"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/layout"
68 )
@@ -31,14 +33,7 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan
3133 return false, layout.ErrNotFound
3234 }
3335 target.session.mu.Lock()
34- foundTab := false
35- for _, tb := range target.session.tabs {
36- if tb == target.tab {
37- foundTab = true
38- break
39- }
40- }
41- if !foundTab {
36+ if !slices.Contains(target.session.tabs, target.tab) {
4237 target.session.mu.Unlock()
4338 d.mu.Unlock()
4439 return false, layout.ErrNotFound
@@ -74,7 +69,12 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan
7469 d.mu.Unlock()
7570
7671 if !d.applyTabLayout(target.session, target.tab) {
77- return false, layout.ErrNotFound
72+ target.tab.mu.Lock()
73+ applied := target.tab.tree == candidate
74+ target.tab.mu.Unlock()
75+ if !applied {
76+ return false, layout.ErrNotFound
77+ }
7878 }
7979 return true, nil
8080 }
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.
443443
444444 // dismissToast removes only the specified visible toast. In particular, a
445445 // 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 {
447447 if ac == nil || ac.overlays == nil {
448- return
448+ return false
449449 }
450450 rt := ac.overlays
451451 rt.noticeMu.Lock()
452+ defer rt.noticeMu.Unlock()
452453 i := rt.indexOfToastLocked(code, sid)
453454 if i < 0 {
454- rt.noticeMu.Unlock()
455- return
455+ return false
456456 }
457457 rt.noticeToasts[i].timer.stop()
458458 rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
459459 if len(rt.noticeToasts) == 0 {
460460 rt.noticeOverflow = 0
461461 }
462- rt.noticeMu.Unlock()
463- d.repaintForNotice(ac)
462+ return true
464463 }
465464
466465 // 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) {
7171 tr, _ := newCapturingTransport(t)
7272 replaced := make(chan *attachedClient, 1)
7373 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{})
7575 replaced <- current
7676 }()
7777
7878 // Releasing the mutation lets the already-validated notice publish before
79- // replacement can own the session. Thus no stale attachment can mutate a
80- // toast stack after sess.client changes.
79+ // replacement can own the session. Snatch cleanup then clears the displaced
80+ // attachment's presentation state without moving the notice to the new one.
8181 close(releaseMutation)
8282 <-noticeDone
8383 current := <-replaced
84+ d.attachmentCleanupWg.Wait()
8485
8586 history := d.notices.history()
8687 require.Len(t, history, 1)
8788 require.Equal(t, domain.NoticeClipboard, history[0].Code)
8889 oldToasts, _ := visibleToasts(old)
89- require.Len(t, oldToasts, 1)
90- require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code)
90+ require.Empty(t, oldToasts)
9191 newToasts, _ := visibleToasts(current)
9292 require.Empty(t, newToasts)
9393 sess.mu.Lock()
@@ -97,10 +97,9 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
9797
9898 func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
9999 for _, tt := range []struct {
100- name string
101- setup func(*Daemon, *attachedClient, domain.SessionID)
102- notice ports.ClientNotice
103- wantToast domain.NoticeCode
100+ name string
101+ setup func(*Daemon, *attachedClient, domain.SessionID)
102+ notice ports.ClientNotice
104103 }{
105104 {
106105 name: "record",
@@ -111,28 +110,23 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
111110 setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {
112111 d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))
113112 },
114- notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
115- wantToast: domain.NoticeConnection,
113+ notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
116114 },
117115 } {
118116 t.Run(tt.name, func(t *testing.T) {
119117 d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
120- tr, _ := newCapturingTransport(t)
121- current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
122118 if tt.setup != nil {
123119 tt.setup(d, old, sess.id)
124120 }
121+ tr, _ := newCapturingTransport(t)
122+ current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
123+ d.attachmentCleanupWg.Wait()
125124
126125 d.handleClientNotice(sess, old, tt.notice)
127126
128127 require.Empty(t, d.notices.history(), "a replaced client must not record notices")
129128 oldToasts, _ := visibleToasts(old)
130- if tt.setup == nil {
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- }
129+ require.Empty(t, oldToasts, "snatch cleanup must remove displaced notice presentation")
136130 currentToasts, _ := visibleToasts(current)
137131 require.Empty(t, currentToasts)
138132 })
@@ -140,7 +134,8 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
140134 }
141135
142136 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)
144139 tr, ok := ac.tr.(*portsmocks.MockTransport)
145140 require.True(t, ok, "attached client transport must be a MockTransport")
146141 frames := []ports.Frame{
@@ -757,17 +752,18 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
757752 tr, _ := newCapturingTransport(t)
758753 replaced := make(chan *attachedClient, 1)
759754 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{})
761756 replaced <- ac
762757 }()
763758 close(releaseRoute)
764759 <-globalDone
765760 current := <-replaced
761+ d.attachmentCleanupWg.Wait()
766762
767- // Replacement cannot make old a stale delivery target: it publishes only
768- // after the selected attachment has received the global toast.
763+ // Replacement publishes only after delivery, then snatch cleanup removes
764+ // the displaced attachment's presentation state.
769765 oldToasts, _ := visibleToasts(old)
770- require.Len(t, oldToasts, 1)
766+ require.Empty(t, oldToasts)
771767 newToasts, _ := visibleToasts(current)
772768 require.Empty(t, newToasts)
773769 sess.mu.Lock()
M internal/usecase/daemon/overlay_runtime.go +117 -8
@@ -18,6 +18,8 @@ type overlayRuntime struct {
1818
1919 pickerMu sync.Mutex
2020 picker *picker.Model
21+ pickerIntent pickerIntent
22+ pickerSource moveSourceLocator
2123 pickerPreview *tab
2224 pickerPreviewSession *session
2325 pickerPreviewGeneration uint64
@@ -32,10 +34,11 @@ type overlayRuntime struct {
3234 paletteFeedback string
3335 palettePending []byte
3436
35- promptMu sync.Mutex
36- prompt *promptui.Model
37- promptSubmit func(string) error
38- promptPending []byte
37+ promptMu sync.Mutex
38+ prompt *promptui.Model
39+ promptSubmit func(string) error
40+ promptTransitionSubmit func(string, attachmentRoleToken) error
41+ promptPending []byte
3942
4043 resizeMu sync.Mutex
4144 resizeActive bool
@@ -228,21 +231,127 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
228231 return active || prePublication
229232 }
230233
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 {
232337 if rt == nil || rt.ac == nil {
233338 return false
234339 }
235340 ac := rt.ac
341+ var effect *roleEffectTicket
342+ if len(effects) != 0 {
343+ effect = effects[0]
344+ }
236345 if rt.promptActive() {
237- d.handlePromptInput(ac, data)
346+ d.handlePromptInput(ac, data, effect)
238347 return true
239348 }
240349 if rt.paletteActive() {
241- d.handlePaletteInput(ac, data)
350+ d.handlePaletteInput(ac, data, effect)
242351 return true
243352 }
244353 if rt.pickerActive() {
245- d.handlePickerInput(ac, data)
354+ d.handlePickerInput(ac, data, effect)
246355 return true
247356 }
248357 if rt.noticesActive() {
M internal/usecase/daemon/palette.go +97 -9
@@ -148,7 +148,7 @@ func (d *Daemon) recordPaletteUse(code string) {
148148 d.paletteRecent = recent
149149 }
150150
151-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
151+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
152152 sess := ac.currentSession()
153153 if sess == nil {
154154 return
@@ -161,6 +161,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
161161 var generation uint64
162162 var rawQuery string
163163 changed, cancel, execute := false, false, false
164+ var effect *roleEffectTicket
165+ if len(effects) != 0 {
166+ effect = effects[0]
167+ }
164168
165169 ac.overlays.paletteMu.Lock()
166170 if ac.overlays.palette == nil {
@@ -271,7 +275,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
271275 } else {
272276 target.Stopped = true
273277 }
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 {
275288 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
276289 d.invalidateRender(sess, ac, true, "palette.go")
277290 return
@@ -289,8 +302,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
289302 d.invalidateRender(sess, ac, true, "palette.go")
290303 return
291304 }
292- exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
305+ exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect}
293306 if err := exec.JumpRecentSession(rank); err != nil {
307+ if errors.Is(err, errAttachmentTransition) {
308+ return
309+ }
294310 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
295311 d.invalidateRender(sess, ac, true, "palette.go")
296312 return
@@ -301,20 +317,45 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
301317 }
302318 return
303319 }
304- if !ac.closeExecutedPalette(generation, rawQuery) {
320+ roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
321+ if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
305322 return
306323 }
307324 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)
309326 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+ }
310344 if err != nil {
311345 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
312- d.reportError(sess, err)
346+ d.reportError(sess, paletteCommandNoticeError(cmd, err))
313347 } else {
314348 d.recordPaletteUse(cmd.Code)
315349 }
316350 }
317351
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+
318359 func paletteArgs(query string, cmd command.Command) []string {
319360 action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
320361 if !ok {
@@ -357,6 +398,7 @@ type paletteExec struct {
357398 ac *attachedClient
358399 recent []recentSession
359400 actions daemonActionRunner
401+ effect *roleEffectTicket
360402 redrawClosedPalette bool
361403 }
362404
@@ -389,8 +431,11 @@ func (e paletteExec) CreateTab() error {
389431 }
390432
391433 func (e paletteExec) CreateSession() error {
392- e.d.enterPrompt(e.sess, e.ac, " Create session ", "", func(name string) error {
393- return e.d.createSessionAndSwitch(e.sess, e.ac, name)
434+ e.d.enterTransitionPrompt(e.sess, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
435+ if token.ac == nil {
436+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
437+ }
438+ return e.d.createSessionAndSwitchForRole(token, name)
394439 })
395440 return nil
396441 }
@@ -429,6 +474,31 @@ func (e paletteExec) ClosePane() error {
429474 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
430475 }
431476
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+
432502 func (e paletteExec) focus(direction layout.Direction) error {
433503 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
434504 }
@@ -463,11 +533,20 @@ func (e paletteExec) ToggleFloatingPane() error {
463533 }
464534
465535 func (e paletteExec) BackSession() error {
536+ if e.effect != nil {
537+ return e.d.backSessionForRole(e.effect.roleToken())
538+ }
466539 e.d.backSession(e.sess, e.ac)
467540 return nil
468541 }
469542
470543 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+ }
471550 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
472551 return nil
473552 }
@@ -535,7 +614,16 @@ func (e paletteExec) JumpRecentSession(rank int) error {
535614 if e.d.beforeRecentSessionHandoff != nil {
536615 e.d.beforeRecentSessionHandoff()
537616 }
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 {
539627 return command.ErrInvalidArguments
540628 }
541629 return nil
M internal/usecase/daemon/palette_test.go +123 -6
@@ -143,6 +143,58 @@ func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
143143 require.Equal(t, domain.NoticeTabSpawn, history[0].Code)
144144 }
145145
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+
146198 func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
147199 p, release := newBlockingPTY(t)
148200 defer release()
@@ -287,12 +339,19 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
287339
288340 d.handleInput(current, ac, []byte("\x1b "))
289341 awaitFrame(t, sends, ports.MsgOutput)
342+ generation := ac.roleGeneration.Load()
290343 d.handleInput(current, ac, []byte("stopped\r"))
291344
292- require.Equal(t, "stopped", ac.currentSession().name)
293- require.Equal(t, int64(42), ac.currentSession().createdAt)
345+ resumed := ac.currentSession()
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")
294350 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")
296355 awaitFrame(t, sends, ports.MsgOutput)
297356 }
298357
@@ -394,6 +453,58 @@ func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
394453 require.Same(t, current, ac.currentSession())
395454 }
396455
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+
397508 func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
398509 p, release := newBlockingPTY(t)
399510 defer release()
@@ -538,11 +649,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
538649 require.True(t, ac.overlays.promptActive())
539650 require.Contains(t, string(promptOutput.Data), "Create session")
540651
652+ generation := ac.roleGeneration.Load()
541653 d.handleInput(sess, ac, []byte("scratch\r"))
542654 // The submit first paints the newly attached session while the prompt is
543655 // still open, then handlePromptInput closes the prompt and repaints the
544656 // 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")
546661 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
547662 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
548663 require.NoError(t, err)
@@ -555,6 +670,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
555670 require.Equal(t, "scratch", newSess.name)
556671 require.False(t, newSess.ephemeral)
557672 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")
558675 require.Contains(t, string(finalOutput.Data), "scratch")
559676 require.NotContains(t, string(finalOutput.Data), "Create session")
560677 }
@@ -625,12 +742,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
625742 for i, cmd := range commands {
626743 codes[i] = cmd.Code
627744 }
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)
629746 }
630747
631748 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
632749 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"}
634751
635752 var wg sync.WaitGroup
636753 for range 50 {
M internal/usecase/daemon/pane.go +132 -17
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "sync"
6+ "sync/atomic"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
@@ -10,6 +11,97 @@ import (
1011 "github.com/bnema/vev/pkg/vt"
1112 )
1213
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+
13105 // pane owns one PTY and its terminal screen state.
14106 // Lock order when multiple locks are held is:
15107 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
@@ -18,31 +110,54 @@ type pane struct {
18110 id layout.PaneID
19111 stableID string
20112 pty ports.PTY
21- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title
22- resizeMu sync.Mutex // serializes PTY resizes without holding mu
23- screen *vt.Screen
24- history *vt.History
25- syncGen uint64
26- rect domain.Rect
113+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, title, and owner publication
114+ owner atomic.Pointer[paneOwner]
115+ // ownerGeneration is advanced only while mu is held. Readers consume the
116+ // generation through the immutable owner pointer above.
117+ ownerGeneration uint64
118+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
119+ screen *vt.Screen
120+ history *vt.History
121+ syncGen uint64
122+ rect domain.Rect
27123 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
28124 // draining into resizePending so output is replayed against the target (or
29125 // retained old) screen only after apply resolves.
30- resizeApplying bool
31- resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.
32- resizePending []byte
33- ptyResponses []byte
34- ptyClipboards []string
35- ptyAttention bool
36- popupGeometry floatingGeometry // last geometry committed after a successful floating resize
37- title paneTitleState
38- ctx context.Context
39- cancel context.CancelFunc
40- floatingCloseOnce sync.Once // used only by floating lifecycle teardown paths
126+ resizeApplying bool
127+ resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.
128+ resizePending []byte
129+ ptyResponses []byte
130+ ptyClipboards []string
131+ ptyAttention bool
132+ popupGeometry floatingGeometry // last geometry committed after a successful floating resize
133+ title paneTitleState
134+ ctx context.Context
135+ cancel context.CancelFunc
136+ processCloseOnce sync.Once
41137 // onExit is set before the reader starts and never changed. Floating panes
42138 // use it to reap their independent slot without touching the layout tree.
43139 onExit func()
44140 }
45141
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+
46161 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
47162 return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)
48163 }
M internal/usecase/daemon/pane_actions.go +122 -14
@@ -1,9 +1,9 @@
11 package daemon
22
33 import (
4- "context"
54 "errors"
65 "fmt"
6+ "slices"
77
88 "github.com/bnema/vev/internal/domain"
99 "github.com/bnema/vev/internal/usecase/layout"
@@ -80,24 +80,35 @@ func (d *Daemon) spawnPaneOpAt(
8080 return fmt.Errorf("daemon: generating pane identity: %w", err)
8181 }
8282 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))
8485 if err != nil {
86+ lifetime.abort()
87+ if pty != nil {
88+ _ = pty.Close()
89+ }
8590 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
8691 return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
8792 }
8893
89- pctx, cancel := context.WithCancel(tb.ctx)
9094 p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
91- p.ctx, p.cancel = pctx, cancel
9295 p.rect = newRect
9396
9497 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 {
9699 tb.mu.Unlock()
97- cancel()
100+ lifetime.abort()
98101 _ = pty.Close()
99102 return layout.ErrNotFound
100103 }
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)
101112 tb.tree = candidate
102113 tb.panes[newID] = p
103114 tb.bumpLayoutGenerationLocked()
@@ -193,11 +204,110 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
193204 return d.closePane(sess, tb, id, ac, true)
194205 }
195206
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.
196210 func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
197211 if p == nil {
198212 return
199213 }
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
201311 }
202312
203313 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
239349 if rc := sess.renderCoordinator(); rc != nil {
240350 rc.noteSyncPaneRemoved(p)
241351 }
242- if p.cancel != nil {
243- p.cancel()
244- }
245- if p.pty != nil {
246- _ = p.pty.Close()
247- }
352+ closePaneProcess(p)
248353 d.log.Info("pane closed", "session", sess.name, "pane", id)
249354 if repaint {
250355 if ac != nil {
@@ -254,7 +359,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
254359 return nil
255360 }
256361
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 {
258363 target := resolveDaemonActionTarget(sess)
259364 oldFocus := layout.PaneID("")
260365 if target.pane != nil {
@@ -279,6 +384,9 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
279384 if ac == nil {
280385 return errNoNeighbor
281386 }
387+ if effect != nil {
388+ return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
389+ }
282390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
283391 }
284392
M internal/usecase/daemon/pane_actions_test.go +2 -2
@@ -625,7 +625,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
625625 ac.initOverlays()
626626 ac.overlays.copyMode = &scopy.Mode{}
627627
628- require.NoError(t, d.focusDir(sess, ac, layout.Right))
628+ require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
629629
630630 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
631631 require.Nil(t, ac.overlays.copyMode)
@@ -726,7 +726,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
726726 require.NoError(t, d.stackPane(sess, nil))
727727 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
728728
729- require.NoError(t, d.focusDir(sess, nil, layout.Up))
729+ require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
730730
731731 tb := sess.activeTab()
732732 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 (
44 "context"
55 "errors"
66 "io"
7+ "maps"
78 "sync"
89 "testing"
910 "time"
@@ -158,9 +159,7 @@ func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
158159 h.tab.mu.Lock()
159160 h.tab.tree.Focus = "pane-3"
160161 before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161- for id, p := range h.tab.panes {
162- before[id] = p
163- }
162+ maps.Copy(before, h.tab.panes)
164163 h.tab.mu.Unlock()
165164
166165 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
33
44 import (
55 "context"
6+ "errors"
67 "sort"
78
89 "github.com/bnema/vev/internal/domain"
@@ -15,18 +16,35 @@ import (
1516
1617 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
1718
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.
1822 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
19- views, curTab := d.pickerViews(sess)
20- ac.overlays.pickerMu.Lock()
21- ac.overlays.picker = picker.New(views, sess.id, curTab)
22- ac.overlays.pickerPending = nil
23- ac.overlays.pickerPreview = nil
24- ac.overlays.pickerMu.Unlock()
23+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
24+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
25+}
26+
27+func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
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)
2541 d.registerPreviewForSelection(ac)
2642 d.invalidateRender(sess, ac, true, "picker.go")
2743 }
2844
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) {
3048 d.mu.Lock()
3149 sessions := d.sessionsSnapshotLocked()
3250 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -45,33 +63,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
4563
4664 includeTerminalTitle := d.currentTabsConfig().TerminalTitle
4765 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
48- curTab := 0
66+ var current picker.SourceFilter
4967 for _, s := range sessions {
5068 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+ }
5273 if !s.ephemeral {
5374 createdAt := s.createdAt
54- view.TargetName = s.name
5575 view.ExpectedCreatedAt = &createdAt
5676 }
5777 sessionAttention := false
5878 for i, tb := range s.tabs {
5979 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,
6183 Name: name,
6284 Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
6385 Attention: tb.attention,
64- }
86+ })
6587 if tb.attention {
6688 sessionAttention = true
6789 }
90+ if s == cur && i == s.active {
91+ current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID}
92+ }
6893 }
6994 if sessionAttention {
7095 view.Name = attentionSuffix(view.Name)
7196 }
72- if s == cur {
73- curTab = s.active
74- }
7597 s.mu.Unlock()
7698 views = append(views, view)
7799 }
@@ -79,6 +101,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
79101 createdAt := s.createdAt
80102 views = append(views, picker.SessionView{
81103 ID: domain.SessionID("stopped:" + s.name),
104+ Incarnation: s.incarnation,
82105 Name: s.name,
83106 TargetName: s.name,
84107 Tabs: []picker.TabEntry{{}},
@@ -86,7 +109,21 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
86109 ExpectedCreatedAt: &createdAt,
87110 })
88111 }
89- return views, curTab
112+ 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+ })
90127 }
91128
92129 func attentionSuffix(label string) string {
@@ -106,6 +143,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
106143 active: func() bool { return rt.picker != nil },
107144 closeLocked: func() {
108145 rt.picker = nil
146+ rt.pickerIntent = pickerNavigate
147+ rt.pickerSource = moveSourceLocator{}
109148 generation = rt.pickerPreviewGeneration
110149 },
111150 afterClose: func() {
@@ -117,7 +156,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
117156 }
118157 }
119158
120-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
159+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
121160 sess := ac.currentSession()
122161 if sess == nil {
123162 return
@@ -142,17 +181,38 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
142181 })
143182 var target picker.Target
144183 var ok bool
184+ var intent pickerIntent
185+ var source moveSourceLocator
145186 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
146187 target, ok = rt.picker.Selected()
147188 }
189+ intent, source = rt.pickerIntent, rt.pickerSource
148190 rt.pickerMu.Unlock()
149191
150192 if result.action == 'x' {
193+ var effect *roleEffectTicket
194+ if len(effects) != 0 {
195+ effect = effects[0]
196+ }
151197 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 {
153206 d.reportError(sess, err)
154207 }
155208 }
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+ }
156216 d.refreshPicker(ac)
157217 d.invalidateRender(sess, ac, true, "picker.go")
158218 return
@@ -160,12 +220,43 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
160220 if result.changed {
161221 d.registerPreviewForSelection(ac)
162222 }
163- if result.exit {
223+ committing := (result.action == '\r' || result.action == '\n') && ok
224+ if result.exit && !committing {
164225 d.closePicker(ac)
165226 }
166- if (result.action == '\r' || result.action == '\n') && ok {
167- if err := d.switchToTarget(sess, ac, target); err != nil {
227+ if committing {
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 {
168258 d.reportError(sess, err)
259+ return
169260 }
170261 return
171262 }
@@ -181,6 +272,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
181272 ac.overlays.pickerMu.Lock()
182273 var target picker.Target
183274 var ok bool
275+ intent := ac.overlays.pickerIntent
184276 if ac.overlays.picker != nil {
185277 target, ok = ac.overlays.picker.Selected()
186278 }
@@ -194,15 +286,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
194286 if !ok {
195287 return
196288 }
197- next := d.tabByTarget(target)
198- targetSess := d.sessionByID(target.Session)
289+ targetSess, next := d.previewTarget(target, intent)
199290 if next == nil || targetSess == nil {
200291 return
201292 }
202293 ac.overlays.pickerMu.Lock()
203294 // Selection may have changed while the target was resolved.
204295 selected, stillSelected := ac.overlays.picker.Selected()
205- valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target
296+ valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
206297 if valid {
207298 ac.overlays.pickerPreview = next
208299 ac.overlays.pickerPreviewSession = targetSess
@@ -314,6 +405,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
314405 func (d *Daemon) closePicker(ac *attachedClient) {
315406 ac.overlays.pickerMu.Lock()
316407 ac.overlays.picker = nil
408+ ac.overlays.pickerIntent = pickerNavigate
409+ ac.overlays.pickerSource = moveSourceLocator{}
317410 ac.overlays.pickerPending = nil
318411 ac.overlays.pickerESC.stop()
319412 generation := ac.overlays.pickerPreviewGeneration
@@ -321,19 +414,15 @@ func (d *Daemon) closePicker(ac *attachedClient) {
321414 d.clearPreviewGeneration(ac, generation)
322415 }
323416
324-func (d *Daemon) tabByTarget(target picker.Target) *tab {
325- d.mu.Lock()
326- sess := d.sessions[target.Session]
327- d.mu.Unlock()
328- if sess == nil {
329- return nil
417+func pickerTargetsEqual(left, right picker.Target) bool {
418+ if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
419+ left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
420+ return false
330421 }
331- sess.mu.Lock()
332- defer sess.mu.Unlock()
333- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
334- return nil
422+ if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
423+ return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
335424 }
336- return sess.tabs[target.TabIndex]
425+ return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
337426 }
338427
339428 func (d *Daemon) sessionByID(id domain.SessionID) *session {
@@ -347,6 +436,43 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
347436 // command switching behavior.
348437 type sessionHandoffGuard struct {
349438 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
350476 }
351477
352478 // switchToTarget resolves named lifecycle targets and commits their transition
@@ -356,15 +482,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
356482 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
357483 }
358484
359-// switchToTargetGuarded is the navigation-only variant whose guard is checked
360-// as part of the source ownership transfer.
485+// switchToTargetForRole is the sole client-originated navigation entry. The
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.
361499 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 {
362504 if target.Name != "" {
363505 ctx := d.serveCtx
364506 if ctx == nil {
365507 ctx = context.Background()
366508 }
367509 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
510+ if sourceToken != nil && !sourceToken.activeCurrent() {
511+ return errAttachmentTransition
512+ }
368513 d.invalidateRender(from, ac, true, "picker.go")
369514 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
370515 }
@@ -373,8 +518,7 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
373518 d.mu.Lock()
374519 var (
375520 targetSess *session
376- old *attachedClient
377- cleanups []renderLifecycleCleanup
521+ transition attachmentTransitionResult
378522 switched bool
379523 cause error
380524 )
@@ -382,39 +526,56 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
382526 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
383527 if ok {
384528 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)
386530 } else {
387531 // The snapshot ID may describe the stopped representation. The
388532 // locked name/lifecycle resolver chose this active incarnation.
389533 resolvedTarget := target
390534 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)
392536 }
393537 }
394538 } 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)
396540 }
397541 d.mu.Unlock()
398542
399543 if !switched {
544+ if sourceToken != nil && !sourceToken.activeCurrent() {
545+ return errAttachmentTransition
546+ }
400547 if guard.expectedSource != nil {
401548 return errNoNeighbor
402549 }
403550 d.invalidateRender(from, ac, true, "picker.go")
404551 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
405552 }
406- finishRenderLifecycleCleanups(cleanups)
407- if old != nil && old != ac {
408- d.unregisterPreview(old)
409- old.clearPreviousSession()
410- old.setSession(nil)
411- d.notifyDetachedAsync(old, ports.ReasonReplaced)
553+ if guard.closePicker {
554+ if sourceToken != nil {
555+ fresh, admitted := ac.beginRoleEffect(transition.published)
556+ if admitted {
557+ d.closePicker(ac)
558+ fresh.End()
559+ }
560+ } else {
561+ d.closePicker(ac)
562+ }
412563 }
564+ d.deferAttachmentTransitionCleanups(transition)
413565 if targetSess == from {
414- d.activateTab(from, from.activeTab())
415- d.invalidateRender(from, ac, true, "picker.go")
566+ if sourceToken != nil {
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+ }
416577 } else {
417- d.firstPaint(targetSess, ac, ac.size)
578+ d.firstPaintForTransition(transition.published)
418579 }
419580 return nil
420581 }
@@ -439,121 +600,154 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
439600 return nil, stopped, true, true
440601 }
441602
442-// switchToActiveTargetLocked commits an active target handoff. Caller holds
443-// d.mu and releases it before completing render cleanup or first paint.
444-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, 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()
603+// switchToActiveTargetLocked commits an active target handoff through the
604+// centralized attachment transition. Caller holds d.mu.
605+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, bool) {
449606 if d.sessions[target.Session] != targetSess {
450- return nil, nil, nil, false
607+ return nil, attachmentTransitionResult{}, false
451608 }
452609 if targetSess == from {
453- targetSess.mu.Lock()
454- defer targetSess.mu.Unlock()
455- if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
456- return nil, nil, nil, false
610+ if sourceToken == nil {
611+ targetSess.mu.Lock()
612+ defer targetSess.mu.Unlock()
613+ 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
457618 }
458- targetSess.active = target.TabIndex
459- return targetSess, nil, nil, true
619+ d.mu.Unlock()
620+ 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
460628 }
461629
462- targetSess.mu.Lock()
463- matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)
464- if !matches {
465- targetSess.mu.Unlock()
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
630+ unlock := lockAttachmentSessions(from, targetSess)
631+ if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
632+ unlock()
633+ return nil, attachmentTransitionResult{}, false
473634 }
474- var guardedSource *tab
475635 if guard.expectedSource != nil {
476636 if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
477- from.mu.Unlock()
478- targetSess.mu.Unlock()
479- return nil, nil, nil, false
637+ unlock()
638+ return nil, attachmentTransitionResult{}, false
480639 }
481- guardedSource = guard.expectedSource
482- guardedSource.mu.Lock()
483- if guardedSource.floating.state == floatingVisible {
484- guardedSource.mu.Unlock()
485- from.mu.Unlock()
486- targetSess.mu.Unlock()
487- return nil, nil, nil, false
640+ guard.expectedSource.mu.Lock()
641+ sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
642+ guard.expectedSource.mu.Unlock()
643+ if sourceHasFloating {
644+ unlock()
645+ return nil, attachmentTransitionResult{}, false
488646 }
489647 }
490- term := from.terminal
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()
648+ unlock()
498649
499- old := targetSess.client
500- targetSess.terminal = term
501- targetSess.env = copyEnvironment(env)
502- if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
503- targetSess.active = target.TabIndex
504- }
505- targetSess.mu.Unlock()
506-
507- // Prepare source invalidation, output rebase, and destination coordinator
508- // identity before publishing the destination attachment.
509- cleanups := d.handoffCoordinator(from, targetSess, old, ac)
510- ac.setSession(targetSess)
511- targetSess.mu.Lock()
512- targetSess.client = ac
513- targetSess.mu.Unlock()
650+ // Release d.mu before the gate freeze/drain. transitionAttachment reacquires
651+ // d.mu and revalidates both registered lifecycles for publication.
652+ d.mu.Unlock()
653+ expectedTransport := ac.transportSnapshot()
654+ if sourceToken != nil {
655+ expectedTransport = sourceToken.transport
656+ }
657+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
658+ source: from,
659+ target: targetSess,
660+ next: ac,
661+ expectedRole: attachmentActive,
662+ targetRole: attachmentActive,
663+ expectedTransport: expectedTransport,
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+ }
514676 d.touchMRU(targetSess)
515677 ac.recordPreviousSession(from)
516- return targetSess, old, cleanups, true
678+ return targetSess, transition, true
517679 }
518680
519681 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
520682 // the handoff while d.mu is held. Creation failure leaves the source client
521683 // 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) {
523685 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
525721 }
526722
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()
531723 from.mu.Lock()
532724 if from.client != ac {
533725 from.mu.Unlock()
534- return nil, nil, false, nil
726+ return nil, attachmentTransitionResult{}, false, nil
535727 }
536728 term := from.terminal
537729 env := copyEnvironment(from.env)
730+ from.mu.Unlock()
538731 cwd := d.dirOrHome(stopped.cwd)
539732 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
540733 if err != nil {
541- from.mu.Unlock()
542734 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
543- return nil, nil, false, err
735+ return nil, attachmentTransitionResult{}, false, err
544736 }
545- from.client = nil
546- ac.setSession(nil)
547- from.mu.Unlock()
548737
549- cleanups := d.handoffCoordinator(from, targetSess, nil, ac)
550- ac.setSession(targetSess)
551- targetSess.mu.Lock()
552- targetSess.client = ac
553- targetSess.mu.Unlock()
738+ d.mu.Unlock()
739+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
740+ source: from, target: targetSess, next: ac,
741+ expectedRole: attachmentActive, targetRole: attachmentActive,
742+ expectedTransport: ac.transportSnapshot(), ready: true,
743+ })
744+ d.mu.Lock()
745+ if err != nil {
746+ return targetSess, attachmentTransitionResult{}, false, err
747+ }
554748 d.touchMRU(targetSess)
555749 ac.recordPreviousSession(from)
556- return targetSess, cleanups, true, nil
750+ return targetSess, transition, true, nil
557751 }
558752
559753 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -564,13 +758,13 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
564758 // targets must use switchToTarget so resolution and commit share d.mu.
565759 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
566760 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, "")
568762 d.mu.Unlock()
569763 if !switched {
570764 return nil
571765 }
572- finishRenderLifecycleCleanups(cleanups)
573- return old
766+ d.deferAttachmentTransitionCleanups(transition)
767+ return transition.displaced.ac
574768 }
575769
576770 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -579,23 +773,45 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
579773 d.mu.Lock()
580774 stopped, ok := d.stopped[target.Name]
581775 var (
582- targetSess *session
583- cleanups []renderLifecycleCleanup
776+ transition attachmentTransitionResult
584777 switched bool
585778 )
586779 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{}, "")
588781 }
589782 d.mu.Unlock()
590783 if !switched {
591784 d.invalidateRender(from, ac, true, "picker.go")
592785 return false
593786 }
594- finishRenderLifecycleCleanups(cleanups)
595- d.firstPaint(targetSess, ac, ac.size)
787+ d.deferAttachmentTransitionCleanups(transition)
788+ d.firstPaintForTransition(transition.published)
596789 return true
597790 }
598791
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+
599815 func (d *Daemon) killPickerTarget(target picker.Target) error {
600816 if target.Stopped {
601817 if err := d.retryStoppedPurge(target.Name); err != nil {
@@ -613,20 +829,6 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
613829 return nil
614830 }
615831
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-
630832 func snapshotPickerPreview(tb *tab) picker.Preview {
631833 if tb == nil {
632834 return picker.Preview{}
M internal/usecase/daemon/picker_test.go +455 -24
@@ -23,6 +23,11 @@ import (
2323 "github.com/bnema/vev/pkg/vt"
2424 )
2525
26+var (
27+ _ domain.TabStableID = moveSourceLocator{}.TabID
28+ _ domain.PaneStableID = moveSourceLocator{}.PaneID
29+)
30+
2631 // --- test doubles -----------------------------------------------------------
2732
2833 type refusingSnapshotDeleteRepository struct {
@@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
7479 d.sessions[current.id] = current
7580 d.sessions[ringing.id] = ringing
7681
77- views, curTab := d.pickerViews(current)
82+ views, currentSelection := d.pickerViews(current)
7883
79- require.Equal(t, 0, curTab)
84+ require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
8085 require.Len(t, views, 2)
8186 require.Equal(t, "alpha", views[0].Name)
8287 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
8388 require.Equal(t, "beta ", views[1].Name)
8489 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
8590
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}})
8792 model.Down()
8893 model.Down()
8994 target, ok := model.Selected()
@@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
130135 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
131136 }
132137
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+
133426 func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
134427 p, release := newBlockingPTY(t)
135428 defer release()
@@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
147440 break
148441 }
149442 }
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}})
151444 target, ok := model.Selected()
152445 require.True(t, ok)
153446
@@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
193486 views, _ := d.pickerViews(sess)
194487
195488 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)
197493 }
198494
199495 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
217513 views, _ := d.pickerViews(sess)
218514
219515 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)
221520 }
222521
223522 func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
@@ -505,7 +804,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
505804 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
506805 }
507806
508-func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
807+func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
509808 p1, releasePTY1 := newBlockingPTY(t)
510809 p2, releasePTY2 := newBlockingPTY(t)
511810 defer releasePTY1()
@@ -540,11 +839,24 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
540839 require.Same(t, ac1, sess2.client)
541840 require.Nil(t, sess1.client)
542841 require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
543- det := awaitFrame(t, sends2, ports.MsgDetached)
544- dm, err := ports.UnmarshalDetached(det.Payload)
842+
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)
545851 require.NoError(t, err)
546- require.Equal(t, ports.ReasonReplaced, dm.Reason)
547- awaitFrame(t, sends1, ports.MsgOutput)
852+ require.Contains(t, string(displacedOutput.Data), "Session snatched")
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")
548860 }
549861
550862 func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
@@ -623,7 +935,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
623935 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
624936 }
625937
626-func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
938+func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) {
627939 p1, releasePTY1 := newBlockingPTY(t)
628940 p2, releasePTY2 := newBlockingPTY(t)
629941 defer releasePTY1()
@@ -662,25 +974,41 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
662974 }()
663975 <-enteredSend
664976
665- switchDone := make(chan struct{})
977+ switchDone := make(chan attachmentTransitionResult, 1)
978+ switchErr := make(chan error, 1)
666979 go func() {
667- cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)
668- finishRenderLifecycleCleanups(cleanups)
669- close(switchDone)
980+ result, err := d.transitionAttachment(attachmentTransitionRequest{
981+ source: sess1,
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
670991 }()
671- awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
672992
673- // The handoff is immediately about to acquire sendMu, which paint still owns
674- // through its blocked transport send.
993+ var result attachmentTransitionResult
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())
6751001 select {
676- case <-switchDone:
677- t.Fatal("session switch completed while paint owned sendMu")
1002+ case <-handoffAtSendMu:
1003+ t.Fatal("output rebase ran during architecture publication")
6781004 default:
6791005 }
6801006
6811007 close(releaseSend)
6821008 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+ }
6841012 }
6851013
6861014 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
@@ -850,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
8501178 pickerModel: picker.New([]picker.SessionView{
8511179 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
8521180 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
853- }, "selected", 0),
1181+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}),
8541182 }
8551183
8561184 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
@@ -879,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
8791207 }
8801208
8811209 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})
8831211 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
8841212 cases := []struct {
8851213 name string
@@ -1131,6 +1459,92 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
11311459 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
11321460 }
11331461
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+
11341548 func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
11351549 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
11361550 defer releaseAll(releases)
@@ -1177,6 +1591,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
11771591 p, release := newBlockingPTY(t)
11781592 defer release()
11791593 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
11801600 cause := errors.New("delete failed")
11811601 store, state := newMockStore(t)
11821602 WithStore(t, store)(d)
@@ -1207,4 +1627,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
12071627 d.mu.Unlock()
12081628 require.True(t, retained, "failed deletion must retain the reserved name")
12091629 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")
12101641 }
M internal/usecase/daemon/prompt.go +29 -3
@@ -23,10 +23,21 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
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+
2636 func (d *Daemon) closePrompt(ac *attachedClient) {
2737 ac.overlays.promptMu.Lock()
2838 ac.overlays.prompt = nil
2939 ac.overlays.promptSubmit = nil
40+ ac.overlays.promptTransitionSubmit = nil
3041 ac.overlays.promptPending = nil
3142 ac.overlays.promptMu.Unlock()
3243 }
@@ -39,7 +50,7 @@ func promptValidationError(err error) bool {
3950 errors.Is(err, errSessionNameInUse)
4051 }
4152
42-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
53+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
4354 sess := ac.currentSession()
4455 if sess == nil {
4556 return
@@ -55,6 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
5566 changed := false
5667 exit := false
5768 var submit func(string) error
69+ var transitionSubmit func(string, attachmentRoleToken) error
5870 var submittedPrompt *promptui.Model
5971 var value string
6072
@@ -69,6 +81,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
6981 },
7082 enter: func() {
7183 submit = ac.overlays.promptSubmit
84+ transitionSubmit = ac.overlays.promptTransitionSubmit
7285 submittedPrompt = ac.overlays.prompt
7386 value = strings.TrimSpace(ac.overlays.prompt.Value())
7487 },
@@ -78,8 +91,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
7891 })
7992 ac.overlays.promptMu.Unlock()
8093
81- if submit != nil {
82- if err := submit(value); err != nil {
94+ if submit != nil || transitionSubmit != 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 {
83109 ac.overlays.promptMu.Lock()
84110 if ac.overlays.prompt == submittedPrompt {
85111 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 {
8282 return false
8383 }
8484
85+// ptyReader is retained as a compatibility entry point for focused tests. The
86+// production goroutine calls readPanePTY, whose closure owns only the pane.
8587 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) {
8695 defer d.sessWg.Done()
8796 if p == nil {
8897 return
@@ -98,103 +107,146 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
98107 for {
99108 n, err := p.pty.Read(buf)
100109 if n > 0 {
101- d.processPTYData(sess, tb, p, buf[:n], true)
110+ d.processPanePTYData(p, buf[:n], true)
102111 }
103112 if err != nil {
104- sess.mu.Lock()
105- sessionName := sess.name
106- sess.mu.Unlock()
107- d.log.Info("pane pty closed", "err", err, "session", sessionName)
113+ lease := p.effectLease()
114+ if sessionName, current := paneEffectSessionName(lease); current {
115+ d.log.Info("pane pty closed", "err", err, "session", sessionName)
116+ } else {
117+ d.log.Info("pane pty closed", "err", err)
118+ }
108119 if p.onExit != nil {
109120 p.onExit()
110121 } else {
111- d.reapPane(sess, tb, p)
122+ d.reapPaneOwner(p)
112123 }
113124 return
114125 }
115126 }
116127 }
117128
118-// processPTYData is the sole VT parsing path. While a resize apply owns the
129+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
119149 // pane, reader calls append exact bytes and returns immediately; it never waits
120150 // for PTY.Resize or loses a short read.
121-func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
122- rc := sess.renderCoordinator()
151+func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool) {
152+ if p == nil {
153+ return
154+ }
123155 p.mu.Lock()
124156 if bufferDuringApply && p.resizeApplying {
125157 p.resizePending = append(p.resizePending, data...)
126158 p.mu.Unlock()
127159 return
128160 }
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()
130167 p.screen.Write(data)
131168 p.refreshTerminalTitleLocked()
132- isSyncing := p.screen.SyncUpdateActive()
133- completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
134- var syncGen uint64
135- syncEnded := false
136- if wasSyncing != isSyncing {
137- if isSyncing {
138- syncGen = sess.syncGen.Add(1)
169+ effects.isSyncing = p.screen.SyncUpdateActive()
170+ effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
171+ if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
172+ if effects.isSyncing {
173+ syncGen := owner.session.syncGen.Add(1)
139174 p.syncGen = syncGen
140- if rc != nil {
141- rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
175+ if effects.renderCoordinator != nil {
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() {
142180 p.mu.Lock()
143- if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
181+ if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() {
144182 p.screen.ForceSyncEnd()
145183 }
146184 p.mu.Unlock()
147185 })
148186 }
149187 } else {
150- syncGen = p.syncGen
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"})
188+ effects.syncEnded = effects.renderCoordinator != nil && effects.renderCoordinator.removeSyncEnd(p, p.syncGen, true)
172189 }
173190 }
174-}
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
191+ effects.attention = p.ptyAttention
181192 p.ptyAttention = false
182- responses := append([]byte(nil), p.ptyResponses...)
193+ effects.responses = append([]byte(nil), p.ptyResponses...)
183194 p.ptyResponses = p.ptyResponses[:0]
184- clipboards := append([]string(nil), p.ptyClipboards...)
195+ effects.clipboards = append([]string(nil), p.ptyClipboards...)
185196 p.ptyClipboards = p.ptyClipboards[:0]
186197 p.mu.Unlock()
187- if attention {
188- d.noteAttention(sess, tb)
198+ effects.syncCleanup.finish()
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+ }
189228 }
190- for _, b64 := range clipboards {
191- d.forwardClipboardAsync(sess, b64)
229+ if effects.renderCoordinator == nil || !effects.lease.Current() {
230+ return
192231 }
193- if len(responses) > 0 {
194- if _, err := p.pty.Write(responses); err != nil {
195- d.log.Warn("pty response write failed", "err", err, "session", sess.name)
232+ renderable := d.paneRenderable(owner.session, owner.tab, p)
233+ if !effects.lease.Current() {
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"})
196243 }
197244 }
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+ }
198250 }
199251
200252 // 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
210262 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
211263 // observer I/O must therefore happen only after that ownership is released.
212264 type runtimeMarkBatch struct {
213- observer ports.RuntimeObserver
214- marks []ports.RuntimeMark
265+ observer ports.RuntimeObserver
266+ marks []ports.RuntimeMark
267+ roleEffect *roleEffectTicket
215268 }
216269
217270 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -243,6 +296,16 @@ func (b *runtimeMarkBatch) flush() {
243296
244297 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
245298 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+ }
246309 tb := sess.activeTab()
247310 if tb == nil {
248311 return
M internal/usecase/daemon/render_coordinator.go +180 -112
@@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
132132 return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)
133133 }
134134
135-// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
136-// rejects a same-object replacement whose source pointer still matches but
137-// whose lifecycle lease has been revoked.
138-func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
135+// renderInvalidationReservation is the in-memory half of an invalidation.
136+// finish performs observer, timer, and callback work after ownership fences are
137+// released.
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) {
139156 c.mu.Lock()
140- // An unbound coordinator is the manual/headless harness state; there is no
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.
157+ defer c.mu.Unlock()
144158 detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
145159 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
146160 (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
147161 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
148- c.mu.Unlock()
149- return false
162+ return nil, false
150163 }
151- onInvalidate := c.opts.onInvalidate
164+ reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
152165 c.metrics.invalidations.Add(1)
153166 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
154- queueStart := false
155- var queueCorrelation ports.RuntimeCorrelation
156167 if !wasPending {
157168 c.ackDeferred = false
158169 c.deadlineDue = false
159170 if c.opts.observer != nil {
160171 c.queueMarked = true
161172 c.queueCorrelation = ports.NewRuntimeCorrelation()
162- queueCorrelation = c.queueCorrelation
163- queueStart = true
173+ reservation.queueCorrelation = c.queueCorrelation
174+ reservation.queueStart = true
164175 }
165176 }
166177 c.pending = true
@@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
168179 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
169180 c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
170181 c.coalesced++
171- // A deadline may have expired while synchronized output still gated the
172- // pending work. Completion republishes urgently and must reserve a fresh
173- // deadline rather than leaving that already-fired timer as the only arm.
174- arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
175- var old *timerToken
176- if arm {
177- _, old = c.normalLane.replaceLocked()
182+ reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
183+ if reservation.arm {
184+ _, reservation.old = c.normalLane.replaceLocked()
178185 }
179- gen := c.normalLane.generation
180- delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
186+ reservation.generation = c.normalLane.generation
187+ reservation.delay = minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
181188 if c.pendingUrgent {
182- delay = urgentRenderDeadline
189+ reservation.delay = urgentRenderDeadline
183190 }
184- clock := c.opts.clock
185- c.armed = c.armed || arm
186- observer := c.opts.observer
187- c.mu.Unlock()
188- if queueStart && observer != nil {
189- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
191+ reservation.clock = c.opts.clock
192+ reservation.observer = c.opts.observer
193+ c.armed = c.armed || reservation.arm
194+ return reservation, true
195+}
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))
190204 }
191- stopDetachedTimer(old)
192- if !arm || clock == nil {
193- if onInvalidate != nil {
194- onInvalidate(inv)
205+ stopDetachedTimer(r.old)
206+ if !r.arm || r.clock == nil {
207+ if r.onInvalidate != nil {
208+ r.onInvalidate(r.invalidation)
195209 }
196- return true
210+ return
197211 }
198-
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)
212+ timer := r.clock.NewTimer(r.delay)
202213 timerC := timer.C()
203214 c.mu.Lock()
204- valid := !c.torndown && c.pending && c.normalLane.generation == gen
215+ valid := !c.torndown && c.pending && c.normalLane.generation == r.generation
205216 var token *timerToken
206217 if valid && timerC != nil {
207- token = c.normalLane.publishLocked(gen, timer)
218+ token = c.normalLane.publishLocked(r.generation, timer)
208219 valid = token != nil
209220 if valid {
210221 c.supervisor.startLocked(token, timerC, func() {
@@ -216,34 +227,44 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
216227 c.mu.Unlock()
217228 if !valid {
218229 timer.Stop()
219- if onInvalidate != nil {
220- onInvalidate(inv)
221- }
222- return true
223- }
224- if timerC == nil {
230+ } else if timerC == nil {
225231 timer.Stop()
226- c.fire(gen, false, true)
227- if onInvalidate != nil {
228- onInvalidate(inv)
229- }
230- return true
232+ c.fire(r.generation, false, true)
231233 }
232- // Test-visible observation follows deadline publication, so a producer
233- // callback cannot observe a successful invalidation before its timer owns
234- // a worker (and race tests cannot finish while a mock expectation remains).
235- if onInvalidate != nil {
236- onInvalidate(inv)
234+ if r.onInvalidate != nil {
235+ r.onInvalidate(r.invalidation)
237236 }
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()
239251 return true
240252 }
241253
242254 // notifyAck reports that the client acknowledged an output state, releasing
243255 // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
244256 // 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) {
246263 c.mu.Lock()
264+ if lease != nil && !c.leaseCurrentLocked(lease, true) {
265+ c.mu.Unlock()
266+ return
267+ }
247268 if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
248269 c.mu.Unlock()
249270 return
@@ -278,20 +299,47 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func())
278299 c.noteSyncBeginWithRenderability(p, gen, renderable, force...)
279300 }
280301
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+
281322 // noteSyncBeginWithRenderability records lifecycle unconditionally while the
282323 // supplied predicate decides dynamically whether this batch gates composition.
283324 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
284333 c.mu.Lock()
285334 if c.torndown {
286335 c.mu.Unlock()
287- return
336+ return cleanup
288337 }
289338 if c.syncBatches == nil {
290339 c.syncBatches = make(map[*pane]*syncBatch)
291340 }
292- var old *timerToken
293341 if previous := c.syncBatches[p]; previous != nil {
294- old = detachSyncBatchLocked(previous)
342+ cleanup.add(detachSyncBatchLocked(previous))
295343 }
296344 batch := &syncBatch{generation: gen, renderable: renderable}
297345 if len(force) != 0 {
@@ -301,9 +349,8 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
301349 c.syncRegistryVersion++
302350 clock := c.opts.clock
303351 c.mu.Unlock()
304- stopDetachedTimer(old)
305352 if clock == nil {
306- return
353+ return cleanup
307354 }
308355
309356 // Both timer operations are external. A reentrant callback may replace or
@@ -323,9 +370,25 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
323370 }
324371 c.mu.Unlock()
325372 if !valid || timerC == nil {
326- stopDetachedTimer(&timerToken{timer: timer})
327- return
373+ cleanup.add(&timerToken{timer: timer})
328374 }
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
329392 }
330393
331394 func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) {
@@ -558,29 +621,41 @@ func (cleanup renderLifecycleCleanup) finish() {
558621 // beginDetach invalidates pending work. It never waits for detached workers.
559622 func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
560623 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 {
561632 var cleanup renderLifecycleCleanup
562633 if c.lease != nil && c.lease.active && c.lease.attachment == ac {
563634 c.lease.active = false
564- c.pending = false
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
635+ cleanup = c.resetAttachmentLifecycleLocked()
582636 }
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
584659 return cleanup
585660 }
586661
@@ -593,36 +668,29 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
593668 // for detached workers.
594669 func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
595670 c.mu.Lock()
596- var cleanup renderLifecycleCleanup
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- }
671+ cleanup, _ := c.beginReplaceLocked(old, replacement, ready)
622672 c.mu.Unlock()
623673 return cleanup
624674 }
625675
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+
626694 // noteReplace is for callers that hold no outer lifecycle locks.
627695 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
628696 ready := true
M internal/usecase/daemon/render_coordinator_test.go +2 -2
@@ -1334,7 +1334,7 @@ func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
13341334 rc.mu.Unlock()
13351335 }
13361336
1337-func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) {
1337+func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) {
13381338 rc := newRenderCoordinator(renderCoordinatorOptions{})
13391339 owner := &attachedClient{}
13401340 rc.attach(owner)
@@ -1345,7 +1345,7 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing
13451345
13461346 called := false
13471347 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")
13491349 rc.mu.Lock()
13501350 retryToken := rc.retryLane.token
13511351 rc.mu.Unlock()
M internal/usecase/daemon/render_coordinator_types.go +7 -3
@@ -80,9 +80,10 @@ type resizeRequestMetadata struct {
8080 source *attachedClient
8181 // lease binds every delayed resize/retry callback to the exact attachment
8282 // incarnation that published this request.
83- lease *attachmentLease
84- epoch uint64 // latest requested epoch
85- committed uint64 // latest published epoch
83+ lease *attachmentLease
84+ epoch uint64 // latest requested epoch
85+ committed uint64 // latest published epoch
86+ retryAttempts uint8
8687 }
8788
8889 // renderCoordinatorOptions wires one coordinator instance.
@@ -105,6 +106,9 @@ type renderCoordinatorOptions struct {
105106 // afterSyncGateEvaluated is a package-private deterministic test seam. It
106107 // runs unlocked after visibility predicates and before registry validation.
107108 afterSyncGateEvaluated func()
109+ // onActivateTabAfterResize observes whether activation used a lease-backed
110+ // path; it is nil in production.
111+ onActivateTabAfterResize func(bool)
108112 }
109113
110114 // 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
437437 return false
438438 }
439439 }
440- ac.sess.mu.Lock()
441- if ac.sess.v != sess {
442- ac.sess.mu.Unlock()
443- ac.sendMu.Unlock()
444- return false
445- }
446440 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
447441 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
448442 endDiff(0, err == nil)
449443 if err != nil {
450- ac.sess.mu.Unlock()
451444 ac.sendMu.Unlock()
452445 d.log.Error("render draw failed", "err", err, "session", sess.name)
453446 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -470,7 +463,8 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
470463 var sendTr ports.Transport
471464 var sendErr error
472465 if len(data) > 0 {
473- sendTr = ac.transport()
466+ sendTransport := ac.transportSnapshot()
467+ sendTr = sendTransport.transport
474468 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
475469 if sendTr == nil {
476470 sendErr = errors.New("client transport is nil")
@@ -479,18 +473,25 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
479473 if async, ok := sendTr.(ports.AsyncTransport); ok {
480474 send = async.SendAsync
481475 }
476+ interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
482477 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+ }
483484 }
484485 endEmit(uint64(len(data)), sendErr == nil)
485486 }
486487 if sendErr == nil {
487488 // Publish only after output preparation and transport emission both
488- // succeed. Receipt acknowledgement follows after releasing the attachment
489- // session guard, while sendMu still owns this transaction.
489+ // succeed. A cross-session transition may publish concurrently, but its
490+ // mandatory first-paint rebase waits for sendMu and therefore follows this
491+ // completed output transaction.
490492 ac.pipelineScratch = ac.pipelineCache
491493 ac.pipelineCache = composed.cache
492494 }
493- ac.sess.mu.Unlock()
494495 if sendErr == nil {
495496 // A successful no-byte emission also commits: its renderer shadow still
496497 // 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
502503 }
503504 ac.sendMu.Unlock()
504505 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+ }
506520 }
507521 return true
508522 }
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -511,7 +511,7 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
511511 snap := &overlayRenderSnapshot{
512512 copySearchModel: visualsearch.New(snapshot),
513513 pickerActive: true,
514- pickerModel: picker.New(nil, "", 0),
514+ pickerModel: picker.New(nil, picker.SelectionConfig{}),
515515 noticesOverlayActive: true,
516516 noticesOverlayModel: notices.New(nil, time.Time{}),
517517 paletteActive: true,
M internal/usecase/daemon/render_resize_deadline.go +55 -14
@@ -1,6 +1,11 @@
11 package daemon
22
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
49
510 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
611 // token validation at every callback effect boundary.
@@ -17,6 +22,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
1722 _, retry := c.retryLane.replaceLocked()
1823 c.resize.epoch++
1924 c.resize.size, c.resize.source, c.resize.lease = size, source, lease
25+ c.resize.retryAttempts = 0
2026 epoch := c.resize.epoch
2127 c.mu.Unlock()
2228 stopDetachedTimer(retry)
@@ -90,40 +96,61 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached
9096 return true
9197 }
9298
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) {
94113 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 {
96- c.mu.Unlock()
97- return
114+ defer 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 {
116+ return nil, false
98117 }
118+ c.resize.retryAttempts++
99119 gen, old := c.retryLane.replaceLocked()
100- clock := c.opts.clock
101- c.mu.Unlock()
102- stopDetachedTimer(old)
103- if clock == nil {
120+ return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true
121+}
122+
123+func (r *resizeRetryReservation) finish() {
124+ if r == nil || r.coordinator == nil {
104125 return
105126 }
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)
107134 timerC := timer.C()
108135 if timerC == nil {
109136 timer.Stop()
110137 return
111138 }
112139 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 == source
140+ 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
114141 if valid {
115- token := c.retryLane.publishLocked(gen, timer)
142+ token := c.retryLane.publishLocked(r.generation, timer)
116143 valid = token != nil
117144 if valid {
118145 c.supervisor.startLocked(token, timerC, func() {
119146 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 == source
147+ 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
121148 if valid {
122149 c.retryLane.clearLocked(token)
123150 }
124151 c.mu.Unlock()
125152 if valid {
126- run()
153+ r.run()
127154 }
128155 })
129156 }
@@ -134,6 +161,20 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
134161 }
135162 }
136163
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+
137178 func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
138179 c.mu.Lock()
139180 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
33 import (
44 "crypto/rand"
55 "encoding/binary"
6+ "errors"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 )
1011
12+var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
13+
1114 func newResumeToken() uint64 {
1215 var b [8]byte
1316 if _, err := rand.Read(b[:]); err != nil {
@@ -45,6 +48,10 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
4548 }
4649
4750 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 {
4855 if !d.prepareParkAttachment(sess, ac) {
4956 return false
5057 }
@@ -54,21 +61,45 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
5461 // remain outside d.mu: sendMu is ordered before the daemon lock.
5562 ac.clearCaptureFrames()
5663 d.mu.Lock()
57- if d.closing {
64+ if d.closing || d.sessions[sess.id] != sess {
5865 d.mu.Unlock()
5966 return false
6067 }
68+ if role == attachmentActive {
69+ sess.mu.Lock()
70+ if sess.client != nil {
71+ role = attachmentSnatched
72+ }
73+ sess.mu.Unlock()
74+ }
6175 token := ac.resumeToken
76+ var (
77+ oldRetirement *parkedAttachmentRetirement
78+ oldSame *parkedAttachment
79+ )
6280 if old := d.parked[token]; old != nil {
63- old.timer.Stop()
64- old.closeDone()
81+ if old.ac == ac {
82+ oldSame = old
83+ } else {
84+ retirement := d.retireParkedAttachmentLocked(token, old)
85+ oldRetirement = &retirement
86+ }
6587 }
6688 grace := d.resumeParkGrace
6789 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{})}
6991 ac.parked = true
7092 d.parked[token] = parked
7193 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+ }
72103 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
73104
74105 go func(token uint64, parked *parkedAttachment) {
@@ -112,15 +143,66 @@ func (p *parkedAttachment) closeDone() {
112143 p.doneOnce.Do(func() { close(p.done) })
113144 }
114145
115-// purgeParkedForSessionLocked invalidates every parked token for sess. Caller
116-// holds d.mu. Use before killing or normally attaching to a session so stale
117-// resume tokens cannot resurrect or replace an active attachment.
118-func (d *Daemon) purgeParkedForSessionLocked(sess *session) {
146+// demoteParkedActiveForSessionLocked preserves resumable predecessors when a
147+// new active owner is published. Caller holds d.mu.
148+func (d *Daemon) demoteParkedActiveForSessionLocked(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
119179 for token, parked := range d.parked {
120180 if parked.sess == sess {
121- d.removeParkedLocked(token, parked)
181+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
122182 }
123183 }
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+ }
124206 }
125207
126208 // 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)
144226 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
145227 }
146228 if d.parked[h.ResumeToken] != parked {
147- return nil, nil, false, nil
229+ 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"}
148233 }
149234 return d.resumeParkedLocked(h, tr, sz)
150235 }
@@ -158,13 +243,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
158243 }
159244 sess := parked.sess
160245 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+ }
161251 sess.mu.Lock()
162252 active := sess.client != nil
163253 sess.mu.Unlock()
164- if !registered || active {
165- d.removeParkedLocked(h.ResumeToken, parked)
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"}
254+ if active && parked.role == attachmentActive {
255+ parked.role = attachmentSnatched
168256 }
169257 ac := parked.ac
170258 if ac.clientID != h.ClientID {
@@ -183,15 +271,31 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
183271 ac.size = sz
184272 ac.resumeToken = d.nextResumeTokenLocked()
185273 ac.parked = false
186- ac.setSession(sess)
187274 // The resumed session's snapshot is the sole source for future PTY children.
188275 // Existing PTYs retain the environment they were started with.
189276 sess.mu.Lock()
190277 sess.env = copyEnvironment(h.Env)
191278 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
192- sess.client = ac
193279 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)
195299 d.touchMRU(sess)
196300 d.log.Info("client resumed", "session", sess.name)
197301 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +68 -10
@@ -51,6 +51,40 @@ func (t *closeTrackingTransport) Sends() []ports.Frame {
5151 return append([]ports.Frame(nil), t.sends...)
5252 }
5353
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+
5488 func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
5589 return ports.Hello{
5690 Version: ports.ProtocolVersion,
@@ -139,6 +173,24 @@ func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) {
139173 require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport")
140174 }
141175
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+
142194 func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
143195 pty, releasePTY := newBlockingPTY(t)
144196 defer releasePTY()
@@ -308,6 +360,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
308360 token := ac.resumeToken
309361 require.True(t, sess.detachIfCurrent(ac))
310362 require.True(t, d.parkAttachment(sess, ac))
363+ generation := ac.roleGeneration.Load()
311364
312365 newTr := &closeTrackingTransport{}
313366 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.
315368 require.True(t, ok)
316369 require.Same(t, sess, resumedSess)
317370 require.Same(t, ac, resumedAC)
371+ require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
318372
319373 _ = ac.closeCapturedTransport(oldTr)
320374 require.True(t, oldTr.Closed(), "old transport is closed")
@@ -518,18 +572,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
518572 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
519573 for _, tc := range []struct {
520574 name string
521- discard func(*Daemon, uint64, *parkedAttachment)
575+ discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
522576 }{
523577 {
524578 name: "expiry",
525- discard: func(d *Daemon, token uint64, parked *parkedAttachment) {
579+ discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
526580 d.removeParkedLocked(token, parked)
581+ return nil
527582 },
528583 },
529584 {
530585 name: "session purge",
531- discard: func(d *Daemon, _ uint64, parked *parkedAttachment) {
532- d.purgeParkedForSessionLocked(parked.sess)
586+ discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
587+ return d.purgeParkedForSessionLocked(parked.sess)
533588 },
534589 },
535590 } {
@@ -547,8 +602,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
547602 d.mu.Lock()
548603 parked := d.parked[ac.resumeToken]
549604 require.NotNil(t, parked)
550- tc.discard(d, ac.resumeToken, parked)
605+ retirements := tc.discard(d, ac.resumeToken, parked)
551606 d.mu.Unlock()
607+ finishParkedAttachmentRetirements(retirements)
552608
553609 require.Nil(t, ac.previousSession.Get())
554610 })
@@ -604,7 +660,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
604660 require.False(t, ok, "killed session cannot be resumed")
605661 }
606662
607-func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
663+func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {
608664 pty, release := newBlockingPTY(t)
609665 defer release()
610666 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -620,13 +676,15 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
620676 require.NotSame(t, oldAC, activeAC)
621677
622678 d.mu.Lock()
623- _, parked := d.parked[token]
679+ parked := d.parked[token]
624680 d.mu.Unlock()
681+ require.NotNil(t, parked, "normal attach preserves the parked predecessor")
682+ require.Equal(t, attachmentSnatched, parked.role)
625683 _, 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")
627684 require.NoError(t, err)
628- require.False(t, ok)
629- require.Nil(t, resumedAC)
685+ require.True(t, ok)
686+ require.Same(t, oldAC, resumedAC)
687+ require.Equal(t, attachmentSnatched, sess.attachmentRole(resumedAC))
630688 require.Same(t, activeAC, sess.client)
631689 }
632690
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 {
4949 teardownMu sync.Mutex
5050 teardownActive bool
5151 teardownDone chan struct{}
52+ moveReservations uint
5253 teardownWaiters uint
5354 teardownChanged chan struct{}
5455 lifecycleStopOnce sync.Once
55- mu sync.Mutex // guards tabs, active, client, restoreDone, clipFiles, and clipboard queue state
56+ mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state
5657 // restoreDone remains attached to a restored session after it is published in
5758 // the live registry, so racing attaches cannot bypass restoration completion.
5859 restoreDone chan struct{}
@@ -60,6 +61,7 @@ type session struct {
6061 tabs []*tab
6162 active int
6263 client *attachedClient
64+ snatched map[*attachedClient]struct{}
6365 clipboardQueue []clipboardForward
6466 clipboardWorkerRunning bool
6567 cwd string
@@ -250,13 +252,24 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
250252 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
251253 }
252254 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)
254257 if err != nil {
258+ lifetime.abort()
259+ if pty != nil {
260+ _ = pty.Close()
261+ }
255262 closeTabs(tabs)
256263 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
257264 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
258265 }
259266 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+ }
260273 if i < len(names) {
261274 tb.name = names[i]
262275 }
@@ -296,6 +309,11 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
296309 }
297310 sess.mruAt.Store(lastUsedSeq)
298311 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+ }
299317 if !ephemeral {
300318 if d.persistEnabled {
301319 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
338356
339357 func closeTabs(tabs []*tab) {
340358 for _, tb := range tabs {
341- for _, p := range tb.panes {
342- _ = p.pty.Close()
343- }
359+ tb.closeAllPanes()
344360 }
345361 }
346362
@@ -361,55 +377,95 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
361377 d.mu.Unlock()
362378 return errSessionNameInUse
363379 }
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()
368380 from.mu.Lock()
369381 cwd := from.cwd
370382 term := from.terminal
371383 env := copyEnvironment(from.env)
372384 if from.client != ac {
373385 from.mu.Unlock()
374- d.notices.routingMu.Unlock()
375386 d.mu.Unlock()
376387 return errors.New("client detached")
377388 }
378389 from.mu.Unlock()
379390
380391 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
392+ d.mu.Unlock()
381393 if err != nil {
382- d.notices.routingMu.Unlock()
383- d.mu.Unlock()
384394 return err
385395 }
386- from.mu.Lock()
387- if from.client != ac {
388- from.mu.Unlock()
389- d.notices.routingMu.Unlock()
390- d.mu.Unlock()
396+ // The newly created target is registered before the role transition, but
397+ // remains headless. Freeze/drain must occur with no daemon/routing/session
398+ // lock held; the transition revalidates both lifecycles before publication.
399+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
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 {
391409 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
392- return errors.New("client detached")
410+ return err
393411 }
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()
405412
406413 d.touchMRU(newSess)
407414 ac.recordPreviousSession(from)
408415 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
409- d.notices.routingMu.Unlock()
410- d.mu.Unlock()
411- finishRenderLifecycleCleanups(cleanups)
412- d.firstPaint(newSess, ac, sz)
416+ d.deferAttachmentTransitionCleanups(transition)
417+ d.firstPaintForTransition(transition.published)
418+ return nil
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)
413469 return nil
414470 }
415471
@@ -427,8 +483,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
427483 return err
428484 }
429485 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)
431488 if err != nil {
489+ lifetime.abort()
490+ if pty != nil {
491+ _ = pty.Close()
492+ }
432493 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
433494 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
434495 }
@@ -447,14 +508,22 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
447508 d.mu.Lock()
448509 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
449510 d.mu.Unlock()
511+ lifetime.abort()
450512 _ = pty.Close()
451513 return errors.New("daemon: session closed")
452514 }
453515 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
454- for _, p := range tb.panes {
455- p.ctx, p.cancel = context.WithCancel(tb.ctx)
456- }
457516 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)
458527 sess.tabs = append(sess.tabs, tb)
459528 sess.active = len(sess.tabs) - 1
460529 tabIndex := sess.active
@@ -519,12 +588,7 @@ func (tb *tab) closeAllPanes() {
519588 panes = append(panes, floating)
520589 }
521590 for _, p := range panes {
522- if p.cancel != nil {
523- p.cancel()
524- }
525- if p.pty != nil {
526- _ = p.pty.Close()
527- }
591+ closePaneProcess(p)
528592 }
529593 }
530594
@@ -573,15 +637,25 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
573637 }
574638
575639 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
576- if p != nil {
577- sess.mu.Lock()
578- name := sess.name
579- sess.mu.Unlock()
580- d.log.Info("pane created", "session", name, "pane", p.id)
640+ if p == nil {
641+ return
642+ }
643+ owner := p.ownerSnapshot()
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")
581651 }
652+ sess.mu.Lock()
653+ name := sess.name
654+ sess.mu.Unlock()
655+ d.log.Info("pane created", "session", name, "pane", p.id)
582656 // Scheduler ownership was removed; this launch creates exactly one reader.
583657 d.sessWg.Add(1)
584- go d.ptyReader(sess, tb, p)
658+ go d.readPanePTY(p)
585659 }
586660
587661 // attachClient makes ac the session's current client, displacing any prior one
@@ -598,13 +672,79 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
598672 return false
599673 }
600674
601-// detachIfCurrent serializes client removal with global notice routing. The
602-// routing lock is released before the caller performs teardown, so it covers
603-// only attachment ownership and is never held across transport work.
675+// detachIfCurrent publishes terminal role invalidation through the attachment
676+// gate. Freeze/drain happens before routing or session locks are acquired.
604677 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+ }
605686 d.notices.routingMu.Lock()
606- defer d.notices.routingMu.Unlock()
607- return sess.detachIfCurrent(ac)
687+ sess.mu.Lock()
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
608748 }
609749
610750 func (s *session) activeTab() *tab {
@@ -921,7 +1061,145 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro
9211061 }
9221062
9231063 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)
9251203 }
9261204
9271205 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -933,7 +1211,9 @@ func (s *session) stopInMemoryLifecycle() {
9331211 }
9341212 s.lifecycleStopOnce.Do(func() {
9351213 s.stopFloatingLaunches()
936- s.cancel()
1214+ if s.cancel != nil {
1215+ s.cancel()
1216+ }
9371217 s.mu.Lock()
9381218 tabs := append([]*tab(nil), s.tabs...)
9391219 s.mu.Unlock()
@@ -970,7 +1250,7 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
9701250 }
9711251
9721252 s.teardownMu.Lock()
973- if !s.teardownActive {
1253+ if !s.teardownActive && s.moveReservations == 0 {
9741254 if deadline != nil {
9751255 select {
9761256 case <-deadline.Done():
@@ -985,17 +1265,17 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
9851265 s.teardownMu.Unlock()
9861266 return true
9871267 }
988- done := s.teardownDone
9891268 s.teardownWaiters++
9901269 s.signalTeardownChangedLocked()
1270+ changed := s.teardownChangeLocked()
9911271 s.teardownMu.Unlock()
9921272
9931273 timedOut := false
9941274 if deadline == nil {
995- <-done
1275+ <-changed
9961276 } else {
9971277 select {
998- case <-done:
1278+ case <-changed:
9991279 case <-deadline.Done():
10001280 timedOut = true
10011281 }
@@ -1023,10 +1303,52 @@ func (s *session) finishTeardown() {
10231303 // terminal checkpoint and coordinator join. A timed-out repository call keeps
10241304 // only immutable capture state; it never observes closed worker channels or
10251305 // 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 {
10271307 if sess == nil {
10281308 return nil
10291309 }
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+
10301352 // Pane EOF, explicit close, and daemon shutdown can all converge on the same
10311353 // session. Only one owner may create the terminal mutation and checkpoint;
10321354 // later callers observe the completed registry transition instead of
@@ -1119,21 +1441,62 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11191441 sess.snapshotMu.Unlock()
11201442 }
11211443 }
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.
11221447 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 {
11241449 d.mu.Unlock()
11251450 return nil
11261451 }
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.
11271472 delete(d.sessions, sess.id)
11281473 d.clearBarScriptsForSession(sess.id)
1129- d.purgeParkedForSessionLocked(sess)
1130- sess.mu.Lock()
1474+ parkedRetirements := d.purgeParkedForSessionLocked(sess)
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)
11311492 stoppedName := sess.name
11321493 stoppedCwd := sess.cwd
11331494 createdAt := sess.createdAt
1134- tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
1495+ tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames
11351496 ephemeral := sess.ephemeral
1136- sess.mu.Unlock()
1497+ unlockCoordinators()
1498+ unlockSessions()
1499+ d.notices.routingMu.Unlock()
11371500 if !ephemeral {
11381501 stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
11391502 d.stopped[stoppedName] = stopped
@@ -1146,18 +1509,12 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11461509 d.closing = true
11471510 }
11481511 d.mu.Unlock()
1512+ finishParkedAttachmentRetirements(parkedRetirements)
1513+ frozen.unfreeze()
1514+ frozen = frozenRoleEffectGates{}
11491515 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
11501516
11511517 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()
11611518 if rc := sess.renderCoordinator(); rc != nil {
11621519 // Terminal teardown has two phases: this session owner first prevents any
11631520 // new worker registration and detaches all tokens, then stops and waits
@@ -1165,11 +1522,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11651522 rc.beginSessionTeardown().finish()
11661523 rc.waitForTimerWorkers()
11671524 }
1168- if ac != nil {
1169- d.unregisterPreview(ac)
1170- ac.clearPreviousSession()
1171- ac.setSession(nil)
1172- ac.clearCaptureFrames()
1525+ for _, attachment := range attachments {
1526+ d.unregisterPreview(attachment.ac)
1527+ attachment.ac.clearPreviousSession()
1528+ attachment.ac.clearCaptureFrames()
11731529 }
11741530
11751531 // Prevent queued launches from entering Open, cancel the parent context, and
@@ -1183,7 +1539,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11831539 sess.mu.Unlock()
11841540 for _, tb := range tabs {
11851541 d.clearDestroyedTabPreview(tb)
1186- d.teardownFloating(tb, ac)
1542+ d.teardownFloating(tb, detachedActive)
11871543 }
11881544 for _, path := range clipFiles {
11891545 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
12121568 d.repaintAllAttachedClients()
12131569 }
12141570
1215- if ac != nil {
1216- d.notifyDetachedAsync(ac, reason)
1571+ for _, attachment := range attachments {
1572+ d.notifyDetachedSnapshotAsync(attachment, reason)
12171573 }
12181574 return errors.Join(purgeErr, terminalSnapshotErr)
12191575 }
M internal/usecase/daemon/session_back.go +12 -0
@@ -4,6 +4,18 @@ import "github.com/bnema/vev/internal/usecase/picker"
44
55 // backSession toggles this attachment between its current session and the
66 // 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+
719 func (d *Daemon) backSession(current *session, ac *attachedClient) {
820 if d == nil || current == nil || ac == nil {
921 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) {
283283
284284 // --- attach replace ---------------------------------------------------------
285285
286-func TestAttachReplaceDetachesOld(t *testing.T) {
286+func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
287287 p, releasePTY := newBlockingPTY(t)
288288 d := newTestDaemon(t, newFactory(t, p), stubClock{})
289289
@@ -295,21 +295,24 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
295295 awaitFrame(t, sendsA, ports.MsgOutput)
296296 sess := firstSession(d)
297297 require.NotNil(t, sess)
298+ sess.mu.Lock()
299+ acA := sess.client
300+ sess.mu.Unlock()
301+ require.NotNil(t, acA)
298302
299303 // Client B attaches to the same session, displacing A.
300304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
301305 hg.Go(func() { d.handleConn(trB) })
302306 awaitFrame(t, sendsB, ports.MsgWelcome)
307+ d.attachmentCleanupWg.Wait()
303308
304- // A is notified it was detached.
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.
309+ // B is now the sole active client while A remains a live snatched member.
310310 sess.mu.Lock()
311311 require.NotNil(t, sess.client)
312+ require.NotSame(t, acA, sess.client)
313+ require.Contains(t, sess.snatched, acA)
312314 sess.mu.Unlock()
315+ require.Same(t, sess, acA.currentSession())
313316
314317 releaseA()
315318 releaseB()
M internal/usecase/daemon/snapshot_restore.go +17 -4
@@ -261,16 +261,23 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
261261 }
262262 }
263263 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))
265266 if err != nil {
267+ lifetime.abort()
268+ if pty != nil {
269+ _ = pty.Close()
270+ }
266271 return nil, err
267272 }
268273 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+ }
270278 p.rect = contentRect
271279 if err := restorePaneTerminal(p, paneSnap); err != nil {
272- p.cancel()
273- _ = pty.Close()
280+ closePaneProcess(p)
274281 return nil, err
275282 }
276283 if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
@@ -284,6 +291,12 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
284291
285292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
286293 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+ }
287300 sess.snapEligible.Store(true)
288301 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
289302 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) {
528528 }
529529 tr, _ := newCapturingTransport(t)
530530
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)
532533
533534 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
534535 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
@@ -717,7 +718,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
717718 require.True(t, sess.detachIfCurrent(ac))
718719
719720 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)
721723 d.applyTheme(sess, newAC, ports.Theme{
722724 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
723725 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
M internal/usecase/daemon/theme_resolve.go +8 -0
@@ -12,6 +12,14 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1212 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
1313 }
1414
15+// applyHostThemeSendLocked applies a theme when the caller already owns
16+// ac.sendMu, preserving the usual sendMu -> themeMu lock order.
17+func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
18+ sess.themeMu.Lock()
19+ defer sess.themeMu.Unlock()
20+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
21+}
22+
1523 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
1624 // Resolve while applying, never while composing. The theme mutex only
1725 // publishes the completed value and is not held across session/tab/pane
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