Merge pull request #144 from bnema/feat/persistent-snatched-and-moves
feat: persist snatched clients and move panes or tabs
111 files · +19565 -1267
M README.md +14 -1
| @@ -38,6 +38,8 @@ vev kill <name> kill a session (--all kills everything) | |||
| 38 | 38 | ||
| 39 | 39 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes. | |
| 40 | 40 | ||
| 41 | + | Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically. | |
| 42 | + | ||
| 41 | 43 | ## Keys | |
| 42 | 44 | ||
| 43 | 45 | No prefix key; everything is Alt. | |
| @@ -50,7 +52,7 @@ No prefix key; everything is Alt. | |||
| 50 | 52 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | |
| 51 | 53 | | Alt+a | jump to a session needing attention | | |
| 52 | 54 | ||
| 53 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 55 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable for navigation, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 54 | 56 | ||
| 55 | 57 | ## Remote attach | |
| 56 | 58 | ||
| @@ -78,6 +80,17 @@ vev cmd list-panes --json | |||
| 78 | 80 | ||
| 79 | 81 | Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage. | |
| 80 | 82 | ||
| 83 | + | Move the focused pane or active tab with these exact forms: | |
| 84 | + | ||
| 85 | + | ```text | |
| 86 | + | vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destination-tab-id> | |
| 87 | + | vev cmd [-s <source-session>] [--self] move-tab <destination-session> | |
| 88 | + | ``` | |
| 89 | + | ||
| 90 | + | Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its client follows the moved pane or tab, activates the destination, and snatches any active destination client. | |
| 91 | + | ||
| 92 | + | Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots. | |
| 93 | + | ||
| 81 | 94 | ## Configuration | |
| 82 | 95 | ||
| 83 | 96 | Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, palette codes, the floating terminal, status bar scripts: see [docs/configuration.md](docs/configuration.md). Terminal color and VT compatibility notes live in [docs/terminal.md](docs/terminal.md). | |
M internal/app/cmd_test.go +70 -7
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "io" | |
| 7 | 7 | "math" | |
| 8 | 8 | "reflect" | |
| 9 | + | "strconv" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 | ||
| @@ -107,15 +108,77 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 107 | 108 | } | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 111 | + | func TestMoveCommandHelpUsesExactUsages(t *testing.T) { | |
| 112 | + | for _, tt := range []struct { | |
| 113 | + | slug, usage, desc string | |
| 114 | + | }{ | |
| 115 | + | {"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move the focused pane to another live tab"}, | |
| 116 | + | {"move-tab", "move-tab <destination-session>", "Move the active tab to another live session"}, | |
| 117 | + | } { | |
| 118 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 119 | + | got := cmdHelp(cmdInvocation{slug: tt.slug}) | |
| 120 | + | want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc | |
| 121 | + | if got != want { | |
| 122 | + | t.Fatalf("cmdHelp() = %q, want %q", got, want) | |
| 123 | + | } | |
| 124 | + | }) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |
| 129 | + | for _, tt := range []struct { | |
| 130 | + | slug string | |
| 131 | + | args []string | |
| 132 | + | }{ | |
| 133 | + | {slug: "move-pane", args: []string{"work", "t_dest"}}, | |
| 134 | + | {slug: "move-tab", args: []string{"work"}}, | |
| 135 | + | } { | |
| 136 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 137 | + | parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 140 | + | require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ | |
| 141 | + | stdout: io.Discard, | |
| 142 | + | getenv: func(string) string { return "" }, | |
| 143 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 144 | + | })) | |
| 145 | + | require.Len(t, transport.sent, 1) | |
| 146 | + | request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | require.Equal(t, tt.slug, request.Slug) | |
| 149 | + | require.Equal(t, tt.args, request.Args) | |
| 150 | + | require.Equal(t, ports.ProtocolVersion, request.Version) | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |
| 156 | + | for _, invocation := range []cmdInvocation{ | |
| 157 | + | {slug: "move-pane"}, | |
| 158 | + | {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, | |
| 159 | + | {slug: "move-tab"}, | |
| 160 | + | {slug: "move-tab", args: []string{"work", "extra"}}, | |
| 161 | + | } { | |
| 162 | + | t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) { | |
| 163 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{ | |
| 164 | + | Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments", | |
| 165 | + | })}} | |
| 166 | + | err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ | |
| 167 | + | stdout: io.Discard, | |
| 168 | + | getenv: func(string) string { return "" }, | |
| 169 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 170 | + | }) | |
| 171 | + | if ExitCode(err) != 2 { | |
| 172 | + | t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err)) | |
| 173 | + | } | |
| 174 | + | }) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 110 | 178 | func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |
| 111 | 179 | 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", | |
| 119 | 182 | } { | |
| 120 | 183 | t.Run(slug, func(t *testing.T) { | |
| 121 | 184 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | |
M internal/domain/types.go +7 -1
| @@ -92,5 +92,11 @@ type Rect struct { | |||
| 92 | 92 | // SessionID uniquely identifies a session. | |
| 93 | 93 | type SessionID string | |
| 94 | 94 | ||
| 95 | - | // TabID uniquely identifies a tab within a session. | |
| 95 | + | // TabID identifies a tab by its session-local, user-facing ID. | |
| 96 | 96 | type TabID string | |
| 97 | + | ||
| 98 | + | // TabStableID identifies a tab independently of its mutable session position. | |
| 99 | + | type TabStableID string | |
| 100 | + | ||
| 101 | + | // PaneStableID identifies a pane independently of its layout-local PaneID. | |
| 102 | + | type PaneStableID string | |
M internal/usecase/client/reconnect_toast_test.go +4 -2
| @@ -1118,14 +1118,16 @@ func TestAttachAttemptReturnsWhileSenderSendIsBlockedAfterCancellation(t *testin | |||
| 1118 | 1118 | releaseSender() | |
| 1119 | 1119 | } | |
| 1120 | 1120 | ||
| 1121 | - | func TestDetachedResultReplacementAndCleanDetach(t *testing.T) { | |
| 1121 | + | func TestDetachedResultLegacyReplacementCompatibilityAndCleanDetach(t *testing.T) { | |
| 1122 | + | // Current daemons keep healthy displaced clients connected. Continue decoding | |
| 1123 | + | // ReasonReplaced from older peers so mixed-version detach handling stays clear. | |
| 1122 | 1124 | for _, tt := range []struct { | |
| 1123 | 1125 | name string | |
| 1124 | 1126 | reason uint8 | |
| 1125 | 1127 | wantText string | |
| 1126 | 1128 | }{ | |
| 1127 | 1129 | {name: "clean detach", reason: ports.ReasonDetach}, | |
| 1128 | - | {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, | |
| 1130 | + | {name: "legacy replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, | |
| 1129 | 1131 | } { | |
| 1130 | 1132 | t.Run(tt.name, func(t *testing.T) { | |
| 1131 | 1133 | err := detachedResult(tt.reason) | |
M internal/usecase/command/command.go +15 -0
| @@ -50,6 +50,8 @@ type Context interface { | |||
| 50 | 50 | ToggleStack() error | |
| 51 | 51 | ToggleFloatingPane() error | |
| 52 | 52 | ClosePane() error | |
| 53 | + | OpenMovePanePicker() error | |
| 54 | + | OpenMoveTabPicker() error | |
| 53 | 55 | FocusPaneLeft() error | |
| 54 | 56 | FocusPaneRight() error | |
| 55 | 57 | FocusPaneUp() error | |
| @@ -99,6 +101,16 @@ const ( | |||
| 99 | 101 | TargetPane | |
| 100 | 102 | ) | |
| 101 | 103 | ||
| 104 | + | // CommandScope declares the serialization boundary needed by a command. | |
| 105 | + | type CommandScope uint8 | |
| 106 | + | ||
| 107 | + | const ( | |
| 108 | + | CommandScopeSession CommandScope = iota | |
| 109 | + | // CommandScopeCrossSession requires dispatch to defer locking to the | |
| 110 | + | // mutation, which resolves and locks both source and destination sessions. | |
| 111 | + | CommandScopeCrossSession | |
| 112 | + | ) | |
| 113 | + | ||
| 102 | 114 | // ControlOptions carries per-invocation output preferences. | |
| 103 | 115 | type ControlOptions struct { | |
| 104 | 116 | JSON bool | |
| @@ -115,6 +127,8 @@ type ControlContext interface { | |||
| 115 | 127 | CreateSessionNamed(name string) error | |
| 116 | 128 | CloseTab() error | |
| 117 | 129 | ClosePane() error | |
| 130 | + | MovePane(destinationSession, destinationTabID string) error | |
| 131 | + | MoveTab(destinationSession string) error | |
| 118 | 132 | SplitRight() error | |
| 119 | 133 | SplitLeft() error | |
| 120 | 134 | SplitUp() error | |
| @@ -152,6 +166,7 @@ type Command struct { | |||
| 152 | 166 | Scriptable bool | |
| 153 | 167 | PaletteVisible bool | |
| 154 | 168 | Target TargetKind | |
| 169 | + | Scope CommandScope | |
| 155 | 170 | Run func(Context, []string) error | |
| 156 | 171 | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | |
| 157 | 172 | } | |
M internal/usecase/command/context_mock_test.go +88 -0
| @@ -878,6 +878,94 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_ | |||
| 878 | 878 | return _c | |
| 879 | 879 | } | |
| 880 | 880 | ||
| 881 | + | // OpenMovePanePicker provides a mock function for the type MockContext | |
| 882 | + | func (_mock *MockContext) OpenMovePanePicker() error { | |
| 883 | + | ret := _mock.Called() | |
| 884 | + | ||
| 885 | + | if len(ret) == 0 { | |
| 886 | + | panic("no return value specified for OpenMovePanePicker") | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | var r0 error | |
| 890 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 891 | + | r0 = returnFunc() | |
| 892 | + | } else { | |
| 893 | + | r0 = ret.Error(0) | |
| 894 | + | } | |
| 895 | + | return r0 | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | // MockContext_OpenMovePanePicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMovePanePicker' | |
| 899 | + | type MockContext_OpenMovePanePicker_Call struct { | |
| 900 | + | *mock.Call | |
| 901 | + | } | |
| 902 | + | ||
| 903 | + | // OpenMovePanePicker is a helper method to define mock.On call | |
| 904 | + | func (_e *MockContext_Expecter) OpenMovePanePicker() *MockContext_OpenMovePanePicker_Call { | |
| 905 | + | return &MockContext_OpenMovePanePicker_Call{Call: _e.mock.On("OpenMovePanePicker")} | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | func (_c *MockContext_OpenMovePanePicker_Call) Run(run func()) *MockContext_OpenMovePanePicker_Call { | |
| 909 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 910 | + | run() | |
| 911 | + | }) | |
| 912 | + | return _c | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | func (_c *MockContext_OpenMovePanePicker_Call) Return(err error) *MockContext_OpenMovePanePicker_Call { | |
| 916 | + | _c.Call.Return(err) | |
| 917 | + | return _c | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | func (_c *MockContext_OpenMovePanePicker_Call) RunAndReturn(run func() error) *MockContext_OpenMovePanePicker_Call { | |
| 921 | + | _c.Call.Return(run) | |
| 922 | + | return _c | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | // OpenMoveTabPicker provides a mock function for the type MockContext | |
| 926 | + | func (_mock *MockContext) OpenMoveTabPicker() error { | |
| 927 | + | ret := _mock.Called() | |
| 928 | + | ||
| 929 | + | if len(ret) == 0 { | |
| 930 | + | panic("no return value specified for OpenMoveTabPicker") | |
| 931 | + | } | |
| 932 | + | ||
| 933 | + | var r0 error | |
| 934 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 935 | + | r0 = returnFunc() | |
| 936 | + | } else { | |
| 937 | + | r0 = ret.Error(0) | |
| 938 | + | } | |
| 939 | + | return r0 | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | // MockContext_OpenMoveTabPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMoveTabPicker' | |
| 943 | + | type MockContext_OpenMoveTabPicker_Call struct { | |
| 944 | + | *mock.Call | |
| 945 | + | } | |
| 946 | + | ||
| 947 | + | // OpenMoveTabPicker is a helper method to define mock.On call | |
| 948 | + | func (_e *MockContext_Expecter) OpenMoveTabPicker() *MockContext_OpenMoveTabPicker_Call { | |
| 949 | + | return &MockContext_OpenMoveTabPicker_Call{Call: _e.mock.On("OpenMoveTabPicker")} | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | func (_c *MockContext_OpenMoveTabPicker_Call) Run(run func()) *MockContext_OpenMoveTabPicker_Call { | |
| 953 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 954 | + | run() | |
| 955 | + | }) | |
| 956 | + | return _c | |
| 957 | + | } | |
| 958 | + | ||
| 959 | + | func (_c *MockContext_OpenMoveTabPicker_Call) Return(err error) *MockContext_OpenMoveTabPicker_Call { | |
| 960 | + | _c.Call.Return(err) | |
| 961 | + | return _c | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | func (_c *MockContext_OpenMoveTabPicker_Call) RunAndReturn(run func() error) *MockContext_OpenMoveTabPicker_Call { | |
| 965 | + | _c.Call.Return(run) | |
| 966 | + | return _c | |
| 967 | + | } | |
| 968 | + | ||
| 881 | 969 | // OpenNotifications provides a mock function for the type MockContext | |
| 882 | 970 | func (_mock *MockContext) OpenNotifications() error { | |
| 883 | 971 | ret := _mock.Called() | |
M internal/usecase/command/registry.go +32 -0
| @@ -18,6 +18,8 @@ func Registry() []Command { | |||
| 18 | 18 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | |
| 19 | 19 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | |
| 20 | 20 | paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }), | |
| 21 | + | movePaneCommand(), | |
| 22 | + | moveTabCommand(), | |
| 21 | 23 | paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }), | |
| 22 | 24 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | |
| 23 | 25 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | |
| @@ -78,6 +80,36 @@ func paletteControlOne(slug, code, name, desc string, target TargetKind, run fun | |||
| 78 | 80 | return cmd | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 83 | + | func movePaneCommand() Command { | |
| 84 | + | return Command{ | |
| 85 | + | Slug: "move-pane", Code: "MPN", Name: "Move pane to tab", | |
| 86 | + | Desc: "Move the focused pane to another live tab", Usage: "move-pane <destination-session> <destination-tab-id>", | |
| 87 | + | PaletteVisible: true, Scriptable: true, Target: TargetPane, Scope: CommandScopeCrossSession, | |
| 88 | + | Run: func(ctx Context, _ []string) error { return ctx.OpenMovePanePicker() }, | |
| 89 | + | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 90 | + | if len(args) != 2 || args[0] == "" || args[1] == "" { | |
| 91 | + | return ControlResult{}, ErrInvalidArguments | |
| 92 | + | } | |
| 93 | + | return ControlResult{}, ctx.MovePane(args[0], args[1]) | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func moveTabCommand() Command { | |
| 99 | + | return Command{ | |
| 100 | + | Slug: "move-tab", Code: "MTB", Name: "Move tab to session", | |
| 101 | + | Desc: "Move the active tab to another live session", Usage: "move-tab <destination-session>", | |
| 102 | + | PaletteVisible: true, Scriptable: true, Target: TargetTab, Scope: CommandScopeCrossSession, | |
| 103 | + | Run: func(ctx Context, _ []string) error { return ctx.OpenMoveTabPicker() }, | |
| 104 | + | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 105 | + | if len(args) != 1 || args[0] == "" { | |
| 106 | + | return ControlResult{}, ErrInvalidArguments | |
| 107 | + | } | |
| 108 | + | return ControlResult{}, ctx.MoveTab(args[0]) | |
| 109 | + | }, | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 81 | 113 | func toastCommand() Command { | |
| 82 | 114 | return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 83 | 115 | severity := "info" | |
M internal/usecase/command/registry_test.go +99 -22
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 11 | 11 | commands := PaletteRegistry() | |
| 12 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 13 | 13 | ||
| 14 | 14 | if len(commands) != len(wantCodes) { | |
| 15 | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -39,6 +39,76 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | func TestMoveCommandsExposeExactRegistryContracts(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | code, slug, name, desc, usage string | |
| 45 | + | target TargetKind | |
| 46 | + | args []string | |
| 47 | + | wantCall string | |
| 48 | + | }{ | |
| 49 | + | { | |
| 50 | + | code: "MPN", slug: "move-pane", name: "Move pane to tab", | |
| 51 | + | desc: "Move the focused pane to another live tab", usage: "move-pane <destination-session> <destination-tab-id>", | |
| 52 | + | target: TargetPane, args: []string{"work", "t_dest"}, wantCall: "move-pane:work:t_dest", | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | code: "MTB", slug: "move-tab", name: "Move tab to session", | |
| 56 | + | desc: "Move the active tab to another live session", usage: "move-tab <destination-session>", | |
| 57 | + | target: TargetTab, args: []string{"work"}, wantCall: "move-tab:work", | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 62 | + | cmd, ok := BySlug(tt.slug) | |
| 63 | + | if !ok { | |
| 64 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |
| 65 | + | } | |
| 66 | + | if cmd.Code != tt.code || cmd.Name != tt.name || cmd.Desc != tt.desc || cmd.Usage != tt.usage { | |
| 67 | + | t.Fatalf("command metadata = %#v", cmd) | |
| 68 | + | } | |
| 69 | + | if !cmd.PaletteVisible || !cmd.Scriptable || cmd.Target != tt.target || cmd.Scope != CommandScopeCrossSession { | |
| 70 | + | t.Fatalf("command visibility/target/scope = palette:%v scriptable:%v target:%v scope:%v", cmd.PaletteVisible, cmd.Scriptable, cmd.Target, cmd.Scope) | |
| 71 | + | } | |
| 72 | + | ctx := &controlSpy{} | |
| 73 | + | if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); err != nil { | |
| 74 | + | t.Fatalf("Control(%v): %v", tt.args, err) | |
| 75 | + | } | |
| 76 | + | if ctx.call != tt.wantCall { | |
| 77 | + | t.Fatalf("Control(%v) call = %q, want %q", tt.args, ctx.call, tt.wantCall) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestMoveControlHandlersRequireExactArguments(t *testing.T) { | |
| 84 | + | for _, tt := range []struct { | |
| 85 | + | slug string | |
| 86 | + | args []string | |
| 87 | + | }{ | |
| 88 | + | {slug: "move-pane"}, | |
| 89 | + | {slug: "move-pane", args: []string{"work"}}, | |
| 90 | + | {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, | |
| 91 | + | {slug: "move-pane", args: []string{"", "t_dest"}}, | |
| 92 | + | {slug: "move-tab"}, | |
| 93 | + | {slug: "move-tab", args: []string{"work", "extra"}}, | |
| 94 | + | {slug: "move-tab", args: []string{""}}, | |
| 95 | + | } { | |
| 96 | + | t.Run(tt.slug+"/"+strconv.Itoa(len(tt.args)), func(t *testing.T) { | |
| 97 | + | cmd, ok := BySlug(tt.slug) | |
| 98 | + | if !ok { | |
| 99 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |
| 100 | + | } | |
| 101 | + | ctx := &controlSpy{} | |
| 102 | + | if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); !errors.Is(err, ErrInvalidArguments) { | |
| 103 | + | t.Fatalf("Control(%v) error = %v, want ErrInvalidArguments", tt.args, err) | |
| 104 | + | } | |
| 105 | + | if ctx.call != "" { | |
| 106 | + | t.Fatalf("invalid arguments delegated: %q", ctx.call) | |
| 107 | + | } | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 42 | 112 | func TestBySlugExactMatch(t *testing.T) { | |
| 43 | 113 | tests := []struct { | |
| 44 | 114 | slug string | |
| @@ -73,6 +143,7 @@ func TestRegistryControlMetadata(t *testing.T) { | |||
| 73 | 143 | "new-session": TargetSession, "toast": TargetSession, | |
| 74 | 144 | "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane, | |
| 75 | 145 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | |
| 146 | + | "move-pane": TargetPane, "move-tab": TargetTab, | |
| 76 | 147 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 77 | 148 | } | |
| 78 | 149 | notScriptable := []string{ | |
| @@ -193,27 +264,31 @@ func (s *controlSpy) CreateTab() error { return s.record("new-t | |||
| 193 | 264 | func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) } | |
| 194 | 265 | func (s *controlSpy) CloseTab() error { return s.record("close-tab") } | |
| 195 | 266 | 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) } | |
| 217 | 292 | func (s *controlSpy) Toast(level, message string) error { | |
| 218 | 293 | return s.record("toast:" + level + ":" + message) | |
| 219 | 294 | } | |
| @@ -298,6 +373,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 298 | 373 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | |
| 299 | 374 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | |
| 300 | 375 | {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }}, | |
| 376 | + | {code: "MPN", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMovePanePicker().Return(nil).Once() }}, | |
| 377 | + | {code: "MTB", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMoveTabPicker().Return(nil).Once() }}, | |
| 301 | 378 | {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }}, | |
| 302 | 379 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | |
| 303 | 380 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | |
A internal/usecase/daemon/attachment_role.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |
| 4 | + | ||
| 5 | + | type attachmentRole uint8 | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | attachmentDetached attachmentRole = iota | |
| 9 | + | attachmentActive | |
| 10 | + | attachmentSnatched | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // attachmentRole derives the attachment's role from the session-owned | |
| 14 | + | // registries. There is deliberately no second role field that could drift. | |
| 15 | + | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 16 | + | if s == nil || ac == nil { | |
| 17 | + | return attachmentDetached | |
| 18 | + | } | |
| 19 | + | s.mu.Lock() | |
| 20 | + | defer s.mu.Unlock() | |
| 21 | + | return s.attachmentRoleLocked(ac) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | |
| 25 | + | // intermediate state contains the same attachment in both registries. | |
| 26 | + | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |
| 27 | + | if ac == nil { | |
| 28 | + | return attachmentDetached | |
| 29 | + | } | |
| 30 | + | if s.client == ac { | |
| 31 | + | return attachmentActive | |
| 32 | + | } | |
| 33 | + | if _, ok := s.snatched[ac]; ok { | |
| 34 | + | return attachmentSnatched | |
| 35 | + | } | |
| 36 | + | return attachmentDetached | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (s *session) addSnatchedLocked(ac *attachedClient) { | |
| 40 | + | if ac == nil { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | if s.snatched == nil { | |
| 44 | + | s.snatched = make(map[*attachedClient]struct{}) | |
| 45 | + | } | |
| 46 | + | s.snatched[ac] = struct{}{} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type attachmentRoleToken struct { | |
| 50 | + | sess *session | |
| 51 | + | ac *attachedClient | |
| 52 | + | role attachmentRole | |
| 53 | + | generation uint64 | |
| 54 | + | transport transportSnapshot | |
| 55 | + | lease *attachmentLease | |
| 56 | + | effect *roleEffectTicket | |
| 57 | + | // rebase marks a cross-session ownership move whose first paint must start a | |
| 58 | + | // dependency-free output chain. The rebase is deliberately deferred until | |
| 59 | + | // after transition publication releases all architecture locks. | |
| 60 | + | rebase bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // attachmentTokenLocked captures the exact role, transport incarnation, and | |
| 64 | + | // active coordinator lease at an architecture commit point. The caller must | |
| 65 | + | // hold s.mu; coordinator acquisition follows the canonical session -> | |
| 66 | + | // coordinator order. | |
| 67 | + | func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken { | |
| 68 | + | if s == nil || ac == nil { | |
| 69 | + | return attachmentRoleToken{} | |
| 70 | + | } | |
| 71 | + | transport := ac.transportSnapshot() | |
| 72 | + | if transport.transport == nil { | |
| 73 | + | return attachmentRoleToken{} | |
| 74 | + | } | |
| 75 | + | token := attachmentRoleToken{ | |
| 76 | + | sess: s, | |
| 77 | + | ac: ac, | |
| 78 | + | role: s.attachmentRoleLocked(ac), | |
| 79 | + | generation: ac.roleGeneration.Load(), | |
| 80 | + | transport: transport, | |
| 81 | + | } | |
| 82 | + | if token.role == attachmentActive { | |
| 83 | + | if rc := s.renderCoordinator(); rc != nil { | |
| 84 | + | token.lease = rc.attachmentLease(ac) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return token | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 91 | + | if s == nil || ac == nil || tr == nil { | |
| 92 | + | return attachmentRoleToken{} | |
| 93 | + | } | |
| 94 | + | s.mu.Lock() | |
| 95 | + | transport := ac.transportSnapshot() | |
| 96 | + | if transport.transport != tr { | |
| 97 | + | s.mu.Unlock() | |
| 98 | + | return attachmentRoleToken{} | |
| 99 | + | } | |
| 100 | + | token := attachmentRoleToken{ | |
| 101 | + | sess: s, | |
| 102 | + | ac: ac, | |
| 103 | + | role: s.attachmentRoleLocked(ac), | |
| 104 | + | generation: ac.roleGeneration.Load(), | |
| 105 | + | transport: transport, | |
| 106 | + | } | |
| 107 | + | s.mu.Unlock() | |
| 108 | + | if token.role == attachmentActive { | |
| 109 | + | if rc := s.renderCoordinator(); rc != nil { | |
| 110 | + | token.lease = rc.attachmentLease(ac) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ac.bootstrapRoleCapability(token) | |
| 114 | + | return token | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (t attachmentRoleToken) current() bool { | |
| 118 | + | return t.sess != nil && t.ac != nil && | |
| 119 | + | t.ac.roleGeneration.Load() == t.generation && | |
| 120 | + | t.ac.transportSnapshotCurrent(t.transport) && | |
| 121 | + | t.sess.attachmentRole(t.ac) == t.role | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // activeCurrent admits post-transition effects only for the exact published | |
| 125 | + | // role, transport incarnation, current-session link, and coordinator lease. | |
| 126 | + | func (t attachmentRoleToken) activeCurrent() bool { | |
| 127 | + | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | rc := t.sess.renderCoordinator() | |
| 131 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // activeEffect is the canonical admission check for a client-frame mutation. | |
| 135 | + | // Tokens bind role generation, transport incarnation, session ownership, and | |
| 136 | + | // coordinator lease, so handlers never rediscover authority from mutable state. | |
| 137 | + | func (t attachmentRoleToken) activeEffect() bool { | |
| 138 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 139 | + | return true | |
| 140 | + | } | |
| 141 | + | return t.activeCurrent() | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 145 | + | if sess == nil || ac == nil || lease == nil { | |
| 146 | + | return nil, false | |
| 147 | + | } | |
| 148 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 149 | + | token.lease = lease | |
| 150 | + | return ac.beginRoleEffect(token) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (t *attachmentRoleToken) endRoleEffect() { | |
| 154 | + | if t == nil || t.effect == nil { | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | t.effect.End() | |
| 158 | + | t.effect = nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |
| 162 | + | // already held at a session-owned mutation boundary. | |
| 163 | + | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |
| 164 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 165 | + | return true | |
| 166 | + | } | |
| 167 | + | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 168 | + | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 169 | + | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |
| 170 | + | return false | |
| 171 | + | } | |
| 172 | + | rc := t.sess.renderCoordinator() | |
| 173 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |
| 177 | + | if t.ac == nil { | |
| 178 | + | return errAttachmentTransition | |
| 179 | + | } | |
| 180 | + | t.ac.sendMu.Lock() | |
| 181 | + | defer t.ac.sendMu.Unlock() | |
| 182 | + | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 183 | + | !t.effect.beginTransportSend(t.transport) { | |
| 184 | + | return errAttachmentTransition | |
| 185 | + | } | |
| 186 | + | err := t.transport.transport.Send(frame) | |
| 187 | + | if err != nil { | |
| 188 | + | t.effect.reportTransportFailure(t.transport) | |
| 189 | + | } | |
| 190 | + | t.effect.endTransportSend() | |
| 191 | + | return err | |
| 192 | + | } | |
A internal/usecase/daemon/attachment_transition.go +227 -0
| @@ -0,0 +1,227 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "errors" | |
| 4 | + | ||
| 5 | + | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |
| 6 | + | ||
| 7 | + | type attachmentTransitionRequest struct { | |
| 8 | + | source *session | |
| 9 | + | target *session | |
| 10 | + | next *attachedClient | |
| 11 | + | expectedRole attachmentRole | |
| 12 | + | targetRole attachmentRole | |
| 13 | + | expectedTransport transportSnapshot | |
| 14 | + | sourceToken *attachmentRoleToken | |
| 15 | + | action string | |
| 16 | + | activateTargetTab bool | |
| 17 | + | targetTabIndex int | |
| 18 | + | copySourceEnvironment bool | |
| 19 | + | // preserveRole commits navigation state without replacing attachment | |
| 20 | + | // ownership. It is used for same-session picker selections after the exact | |
| 21 | + | // initiating capability has been frozen and revalidated. | |
| 22 | + | preserveRole bool | |
| 23 | + | expectedSourceTab *tab | |
| 24 | + | // transferExpectedSourceTab permits an installed visible floating pane only | |
| 25 | + | // when the caller atomically transfers this exact tab under its own tab and | |
| 26 | + | // floating-generation fences. Ordinary navigation still rejects visibility. | |
| 27 | + | transferExpectedSourceTab bool | |
| 28 | + | createTargetLocked func() (*session, error) | |
| 29 | + | ready bool | |
| 30 | + | expectedTargetCurrent *attachedClient | |
| 31 | + | expectedTargetTransport transportSnapshot | |
| 32 | + | preflighted bool | |
| 33 | + | roleEffectsFrozen bool | |
| 34 | + | expectedTargetTransportInterrupted bool | |
| 35 | + | // activationBarrier is used when promoting an already connected snatched | |
| 36 | + | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |
| 37 | + | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |
| 38 | + | activationBarrier bool | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |
| 42 | + | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |
| 43 | + | token.transport.transport == req.expectedTransport.transport && | |
| 44 | + | token.transport.incarnation == req.expectedTransport.incarnation | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |
| 48 | + | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |
| 49 | + | // client-originated navigation, delete, or create intent is bound to one exact | |
| 50 | + | // role generation, transport incarnation, and coordinator lease. | |
| 51 | + | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 52 | + | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |
| 53 | + | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |
| 54 | + | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | if token.role != attachmentActive { | |
| 58 | + | return true | |
| 59 | + | } | |
| 60 | + | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |
| 61 | + | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type attachmentTransitionResult struct { | |
| 65 | + | published attachmentRoleToken | |
| 66 | + | displaced attachmentRoleToken | |
| 67 | + | displacedInterrupted bool | |
| 68 | + | cleanups []renderLifecycleCleanup | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type attachmentTransitionParticipants struct { | |
| 72 | + | clients []*attachedClient | |
| 73 | + | interrupts []roleTransportInterrupt | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // snapshotAttachmentTransition discovers the exact participants while d.mu is | |
| 77 | + | // held. It releases every architecture lock before any role effect is ended or | |
| 78 | + | // any gate is frozen and drained. | |
| 79 | + | func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) { | |
| 80 | + | d.mu.Lock() | |
| 81 | + | defer d.mu.Unlock() | |
| 82 | + | ||
| 83 | + | source := req.source | |
| 84 | + | if source == nil { | |
| 85 | + | source = req.target | |
| 86 | + | } | |
| 87 | + | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |
| 88 | + | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | |
| 89 | + | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |
| 90 | + | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |
| 91 | + | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 92 | + | } | |
| 93 | + | if req.target != nil { | |
| 94 | + | req.target.mu.Lock() | |
| 95 | + | req.expectedTargetCurrent = req.target.client | |
| 96 | + | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |
| 97 | + | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |
| 98 | + | } | |
| 99 | + | req.target.mu.Unlock() | |
| 100 | + | } | |
| 101 | + | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 102 | + | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 103 | + | } | |
| 104 | + | req.preflighted = true | |
| 105 | + | return req, attachmentTransitionParticipants{ | |
| 106 | + | clients: []*attachedClient{req.next, req.expectedTargetCurrent}, | |
| 107 | + | interrupts: []roleTransportInterrupt{{ | |
| 108 | + | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |
| 109 | + | }}, | |
| 110 | + | }, nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // freezeAttachmentTransition drains the snapshotted participants without any | |
| 114 | + | // architecture lock held. The returned guard remains frozen through atomic | |
| 115 | + | // publication and must be released by the caller. | |
| 116 | + | func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) { | |
| 117 | + | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 118 | + | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | |
| 119 | + | } | |
| 120 | + | var drainDeadline *roleEffectDrainDeadline | |
| 121 | + | var drainDone func() <-chan struct{} | |
| 122 | + | if req.activationBarrier { | |
| 123 | + | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 124 | + | drainDone = drainDeadline.Done | |
| 125 | + | } | |
| 126 | + | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { | |
| 127 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 128 | + | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 129 | + | } | |
| 130 | + | }}, participants.clients...) | |
| 131 | + | if drainDeadline != nil { | |
| 132 | + | drainDeadline.stop() | |
| 133 | + | } | |
| 134 | + | if !frozen.acquired || !frozen.drained { | |
| 135 | + | if req.activationBarrier { | |
| 136 | + | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |
| 137 | + | return frozen, false, errSendTimedOut | |
| 138 | + | } | |
| 139 | + | return frozen, false, errAttachmentTransition | |
| 140 | + | } | |
| 141 | + | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |
| 142 | + | if d.afterRoleEffectsFrozen != nil { | |
| 143 | + | d.afterRoleEffectsFrozen() | |
| 144 | + | } | |
| 145 | + | return frozen, interrupted, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // publishAttachmentTransition revalidates the frozen source, creates an | |
| 149 | + | // optional target, and performs publication while d.mu remains held. | |
| 150 | + | func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 151 | + | d.mu.Lock() | |
| 152 | + | defer d.mu.Unlock() | |
| 153 | + | ||
| 154 | + | req.roleEffectsFrozen = true | |
| 155 | + | if req.target == nil { | |
| 156 | + | if !d.transitionSourcePreflightLocked(req) { | |
| 157 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 158 | + | } | |
| 159 | + | var err error | |
| 160 | + | req.target, err = req.createTargetLocked() | |
| 161 | + | if err != nil { | |
| 162 | + | return attachmentTransitionResult{}, err | |
| 163 | + | } | |
| 164 | + | req.expectedTargetCurrent = nil | |
| 165 | + | } | |
| 166 | + | return d.transitionAttachmentLocked(req) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // prepareActivatedAttachment rebases the first active paint while the caller | |
| 170 | + | // still owns the activation barrier (and therefore sendMu). | |
| 171 | + | func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) { | |
| 172 | + | ac := result.published.ac | |
| 173 | + | if ac.output != nil { | |
| 174 | + | ac.output.rebase() | |
| 175 | + | } | |
| 176 | + | ac.captureFrames = nil | |
| 177 | + | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | |
| 178 | + | // The first paint still requests a reset, but the old panel dependency chain | |
| 179 | + | // has already been rebased atomically under the activation barrier. | |
| 180 | + | result.published.rebase = false | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 184 | + | req, participants, err := d.snapshotAttachmentTransition(req) | |
| 185 | + | if err != nil { | |
| 186 | + | return attachmentTransitionResult{}, err | |
| 187 | + | } | |
| 188 | + | if req.sourceToken != nil { | |
| 189 | + | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | frozen, interrupted, err := d.freezeAttachmentTransition(req, participants) | |
| 193 | + | defer frozen.unfreeze() | |
| 194 | + | if err != nil { | |
| 195 | + | return attachmentTransitionResult{}, err | |
| 196 | + | } | |
| 197 | + | req.expectedTargetTransportInterrupted = interrupted | |
| 198 | + | ||
| 199 | + | var releaseActivation func() | |
| 200 | + | if req.activationBarrier { | |
| 201 | + | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |
| 202 | + | if err != nil { | |
| 203 | + | return attachmentTransitionResult{}, err | |
| 204 | + | } | |
| 205 | + | defer func() { | |
| 206 | + | if releaseActivation != nil { | |
| 207 | + | releaseActivation() | |
| 208 | + | } | |
| 209 | + | }() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | result, err := d.publishAttachmentTransition(req) | |
| 213 | + | if err != nil { | |
| 214 | + | return result, err | |
| 215 | + | } | |
| 216 | + | if req.activationBarrier && result.published.role == attachmentActive { | |
| 217 | + | d.prepareActivatedAttachment(&result) | |
| 218 | + | } | |
| 219 | + | if releaseActivation != nil { | |
| 220 | + | releaseActivation() | |
| 221 | + | releaseActivation = nil | |
| 222 | + | } | |
| 223 | + | if result.published.role == attachmentActive { | |
| 224 | + | result.published.ac.clearSnatchedInput() | |
| 225 | + | } | |
| 226 | + | return result, nil | |
| 227 | + | } | |
A internal/usecase/daemon/attachment_transition_prelocked_test.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) { | |
| 13 | + | t.Parallel() | |
| 14 | + | ||
| 15 | + | type transitionObservation struct { | |
| 16 | + | result attachmentTransitionResult | |
| 17 | + | activeRole attachmentRole | |
| 18 | + | displacedRole attachmentRole | |
| 19 | + | activeGeneration uint64 | |
| 20 | + | displacedGeneration uint64 | |
| 21 | + | activeSession *session | |
| 22 | + | displacedSession *session | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) { | |
| 26 | + | t.Helper() | |
| 27 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 28 | + | sess := &session{id: domain.SessionID("work")} | |
| 29 | + | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 30 | + | old.setSession(sess) | |
| 31 | + | sess.client = old | |
| 32 | + | d.sessions[sess.id] = sess | |
| 33 | + | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 34 | + | return d, sess, old, next | |
| 35 | + | } | |
| 36 | + | observe := func(sess *session, old, next *attachedClient, result attachmentTransitionResult) transitionObservation { | |
| 37 | + | return transitionObservation{ | |
| 38 | + | result: result, | |
| 39 | + | activeRole: sess.attachmentRole(next), | |
| 40 | + | displacedRole: sess.attachmentRole(old), | |
| 41 | + | activeGeneration: next.roleGeneration.Load(), | |
| 42 | + | displacedGeneration: old.roleGeneration.Load(), | |
| 43 | + | activeSession: next.currentSession(), | |
| 44 | + | displacedSession: old.currentSession(), | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | wrapperDaemon, wrapperSession, wrapperOld, wrapperNext := setup(t) | |
| 49 | + | wrapperResult, err := wrapperDaemon.transitionAttachment(attachmentTransitionRequest{ | |
| 50 | + | target: wrapperSession, | |
| 51 | + | next: wrapperNext, | |
| 52 | + | expectedRole: attachmentDetached, | |
| 53 | + | targetRole: attachmentActive, | |
| 54 | + | expectedTransport: wrapperNext.transportSnapshot(), | |
| 55 | + | ready: true, | |
| 56 | + | }) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | wrapper := observe(wrapperSession, wrapperOld, wrapperNext, wrapperResult) | |
| 59 | + | ||
| 60 | + | prelockedDaemon, prelockedSession, prelockedOld, prelockedNext := setup(t) | |
| 61 | + | frozen := freezeRoleEffectGates(prelockedNext, prelockedOld) | |
| 62 | + | require.True(t, frozen.acquired) | |
| 63 | + | require.True(t, frozen.drained) | |
| 64 | + | defer frozen.unfreeze() | |
| 65 | + | ||
| 66 | + | req := attachmentTransitionRequest{ | |
| 67 | + | target: prelockedSession, | |
| 68 | + | next: prelockedNext, | |
| 69 | + | expectedRole: attachmentDetached, | |
| 70 | + | targetRole: attachmentActive, | |
| 71 | + | expectedTransport: prelockedNext.transportSnapshot(), | |
| 72 | + | expectedTargetCurrent: prelockedOld, | |
| 73 | + | preflighted: true, | |
| 74 | + | roleEffectsFrozen: true, | |
| 75 | + | ready: true, | |
| 76 | + | } | |
| 77 | + | type prelockedOutcome struct { | |
| 78 | + | result attachmentTransitionResult | |
| 79 | + | err error | |
| 80 | + | } | |
| 81 | + | outcome := make(chan prelockedOutcome, 1) | |
| 82 | + | go func() { | |
| 83 | + | prelockedDaemon.mu.Lock() | |
| 84 | + | prelockedDaemon.notices.routingMu.Lock() | |
| 85 | + | unlockSessions := lockAttachmentSessions(prelockedSession, prelockedSession) | |
| 86 | + | publication, validateErr := prelockedDaemon.validateAttachmentTransitionPrelocked(req) | |
| 87 | + | var result attachmentTransitionResult | |
| 88 | + | if validateErr == nil { | |
| 89 | + | result = prelockedDaemon.publishAttachmentTransitionPrelocked(publication) | |
| 90 | + | publication.unlockCoordinators() | |
| 91 | + | } | |
| 92 | + | unlockSessions() | |
| 93 | + | prelockedDaemon.notices.routingMu.Unlock() | |
| 94 | + | prelockedDaemon.mu.Unlock() | |
| 95 | + | outcome <- prelockedOutcome{result: result, err: validateErr} | |
| 96 | + | }() | |
| 97 | + | ||
| 98 | + | var prelockedResult attachmentTransitionResult | |
| 99 | + | select { | |
| 100 | + | case got := <-outcome: | |
| 101 | + | require.NoError(t, got.err) | |
| 102 | + | prelockedResult = got.result | |
| 103 | + | case <-time.After(time.Second): | |
| 104 | + | t.Fatal("prelocked attachment transition recursively acquired an already-held lock") | |
| 105 | + | } | |
| 106 | + | prelocked := observe(prelockedSession, prelockedOld, prelockedNext, prelockedResult) | |
| 107 | + | ||
| 108 | + | require.Equal(t, wrapper.activeRole, prelocked.activeRole) | |
| 109 | + | require.Equal(t, wrapper.displacedRole, prelocked.displacedRole) | |
| 110 | + | require.Equal(t, wrapper.activeGeneration, prelocked.activeGeneration) | |
| 111 | + | require.Equal(t, wrapper.displacedGeneration, prelocked.displacedGeneration) | |
| 112 | + | require.Same(t, prelockedSession, prelocked.activeSession) | |
| 113 | + | require.Same(t, prelockedSession, prelocked.displacedSession) | |
| 114 | + | require.Equal(t, wrapper.result.published.role, prelocked.result.published.role) | |
| 115 | + | require.Equal(t, wrapper.result.displaced.role, prelocked.result.displaced.role) | |
| 116 | + | require.NotNil(t, prelocked.result.published.lease) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) { | |
| 120 | + | t.Parallel() | |
| 121 | + | ||
| 122 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | + | source := &session{id: domain.SessionID("source")} | |
| 124 | + | target := &session{id: domain.SessionID("target"), active: 0} | |
| 125 | + | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 126 | + | next.setSession(source) | |
| 127 | + | source.addSnatchedLocked(next) | |
| 128 | + | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 129 | + | old.setSession(target) | |
| 130 | + | target.client = old | |
| 131 | + | d.sessions[source.id] = source | |
| 132 | + | d.sessions[target.id] = target | |
| 133 | + | ||
| 134 | + | frozen := freezeRoleEffectGates(next, old) | |
| 135 | + | require.True(t, frozen.acquired) | |
| 136 | + | require.True(t, frozen.drained) | |
| 137 | + | defer frozen.unfreeze() | |
| 138 | + | ||
| 139 | + | req := attachmentTransitionRequest{ | |
| 140 | + | source: source, | |
| 141 | + | target: target, | |
| 142 | + | next: next, | |
| 143 | + | expectedRole: attachmentSnatched, | |
| 144 | + | targetRole: attachmentActive, | |
| 145 | + | expectedTransport: next.transportSnapshot(), | |
| 146 | + | expectedTargetCurrent: old, | |
| 147 | + | preflighted: true, | |
| 148 | + | roleEffectsFrozen: true, | |
| 149 | + | activateTargetTab: true, | |
| 150 | + | targetTabIndex: 1, | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | d.mu.Lock() | |
| 154 | + | d.notices.routingMu.Lock() | |
| 155 | + | unlockSessions := lockAttachmentSessions(source, target) | |
| 156 | + | publication, err := d.validateAttachmentTransitionPrelocked(req) | |
| 157 | + | unlockSessions() | |
| 158 | + | d.notices.routingMu.Unlock() | |
| 159 | + | d.mu.Unlock() | |
| 160 | + | ||
| 161 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 162 | + | require.Nil(t, publication) | |
| 163 | + | require.Equal(t, attachmentSnatched, source.attachmentRole(next)) | |
| 164 | + | require.Equal(t, attachmentDetached, target.attachmentRole(next)) | |
| 165 | + | require.Equal(t, attachmentActive, target.attachmentRole(old)) | |
| 166 | + | require.Same(t, source, next.currentSession()) | |
| 167 | + | require.Equal(t, uint64(0), next.roleGeneration.Load()) | |
| 168 | + | require.Equal(t, uint64(0), old.roleGeneration.Load()) | |
| 169 | + | require.Equal(t, 0, target.active) | |
| 170 | + | } | |
A internal/usecase/daemon/attachment_transition_publish.go +328 -0
| @@ -0,0 +1,328 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // transitionSourcePreflightLocked validates an initiating role while its gate | |
| 4 | + | // is frozen and d.mu is held. No caller may create or delete a target before | |
| 5 | + | // this succeeds. | |
| 6 | + | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |
| 7 | + | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |
| 8 | + | return false | |
| 9 | + | } | |
| 10 | + | d.notices.routingMu.Lock() | |
| 11 | + | defer d.notices.routingMu.Unlock() | |
| 12 | + | req.source.mu.Lock() | |
| 13 | + | defer req.source.mu.Unlock() | |
| 14 | + | coordinator := req.source.renderCoordinator() | |
| 15 | + | if coordinator == nil { | |
| 16 | + | return false | |
| 17 | + | } | |
| 18 | + | coordinator.mu.Lock() | |
| 19 | + | defer coordinator.mu.Unlock() | |
| 20 | + | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |
| 27 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 28 | + | return false | |
| 29 | + | } | |
| 30 | + | d.mu.Lock() | |
| 31 | + | defer d.mu.Unlock() | |
| 32 | + | if d.closing || d.sessions[token.sess.id] != token.sess { | |
| 33 | + | return false | |
| 34 | + | } | |
| 35 | + | d.notices.routingMu.Lock() | |
| 36 | + | defer d.notices.routingMu.Unlock() | |
| 37 | + | token.sess.mu.Lock() | |
| 38 | + | defer token.sess.mu.Unlock() | |
| 39 | + | coordinator := token.sess.renderCoordinator() | |
| 40 | + | if coordinator == nil { | |
| 41 | + | return false | |
| 42 | + | } | |
| 43 | + | coordinator.mu.Lock() | |
| 44 | + | defer coordinator.mu.Unlock() | |
| 45 | + | req := attachmentTransitionRequest{ | |
| 46 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 47 | + | expectedTransport: token.transport, | |
| 48 | + | } | |
| 49 | + | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 53 | + | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool { | |
| 57 | + | if expected == nil { | |
| 58 | + | return true | |
| 59 | + | } | |
| 60 | + | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | expected.mu.Lock() | |
| 64 | + | defer expected.mu.Unlock() | |
| 65 | + | return transferExpected || expected.floating.state != floatingVisible | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // transitionAttachmentLocked is the publication half of transitionAttachment. | |
| 69 | + | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |
| 70 | + | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 71 | + | d.notices.routingMu.Lock() | |
| 72 | + | defer d.notices.routingMu.Unlock() | |
| 73 | + | return d.transitionAttachmentRoutedLocked(req) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // attachmentPublication is the validated, coordinator-locked input to the | |
| 77 | + | // non-failing ownership and lease publication step. Its caller continues to | |
| 78 | + | // own d.mu, notices.routingMu, and the ordered source/target session locks. | |
| 79 | + | type attachmentPublication struct { | |
| 80 | + | req attachmentTransitionRequest | |
| 81 | + | source *session | |
| 82 | + | old *attachedClient | |
| 83 | + | sourceCoordinator *renderCoordinator | |
| 84 | + | targetCoordinator *renderCoordinator | |
| 85 | + | releaseCoordinators func() | |
| 86 | + | displacedTransport transportSnapshot | |
| 87 | + | nextGeneration uint64 | |
| 88 | + | displacedGeneration uint64 | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (p *attachmentPublication) unlockCoordinators() { | |
| 92 | + | p.releaseCoordinators() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // validateAttachmentTransitionPrelocked performs every fallible transition | |
| 96 | + | // check without acquiring d.mu, notices.routingMu, or either session lock. | |
| 97 | + | // The caller holds those locks in that order, with session locks ordered by ID. | |
| 98 | + | // On success the returned validation retains the ordered coordinator locks | |
| 99 | + | // through publishAttachmentTransitionPrelocked. | |
| 100 | + | func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) { | |
| 101 | + | source := req.source | |
| 102 | + | if source == nil { | |
| 103 | + | source = req.target | |
| 104 | + | } | |
| 105 | + | if !req.preflighted || !req.roleEffectsFrozen || | |
| 106 | + | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 107 | + | source == nil || req.target == nil || req.next == nil || d.closing || | |
| 108 | + | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |
| 109 | + | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | |
| 110 | + | req.preserveRole && req.sourceToken == nil { | |
| 111 | + | return nil, errAttachmentTransition | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | var targetCoordinator *renderCoordinator | |
| 115 | + | if req.targetRole == attachmentActive { | |
| 116 | + | targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target) | |
| 117 | + | } | |
| 118 | + | sourceCoordinator := source.renderCoordinator() | |
| 119 | + | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |
| 120 | + | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 121 | + | return nil, errAttachmentTransition | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | old := req.target.client | |
| 125 | + | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |
| 126 | + | return nil, errAttachmentTransition | |
| 127 | + | } | |
| 128 | + | publication := &attachmentPublication{ | |
| 129 | + | req: req, source: source, old: old, | |
| 130 | + | sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator, | |
| 131 | + | releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | |
| 132 | + | } | |
| 133 | + | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 134 | + | !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab)) | |
| 135 | + | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 136 | + | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 137 | + | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| 138 | + | invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) | |
| 139 | + | if invalid { | |
| 140 | + | publication.unlockCoordinators() | |
| 141 | + | return nil, errAttachmentTransition | |
| 142 | + | } | |
| 143 | + | return publication, nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // applyTargetStateLocked commits navigation metadata before role ownership, as | |
| 147 | + | // part of the same locked publication. It reports role-preserving transitions. | |
| 148 | + | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 149 | + | req := publication.req | |
| 150 | + | if req.targetRole == attachmentActive { | |
| 151 | + | d.demoteParkedActiveForSessionLocked(req.target) | |
| 152 | + | } | |
| 153 | + | if req.copySourceEnvironment { | |
| 154 | + | req.target.terminal = publication.source.terminal | |
| 155 | + | req.target.env = copyEnvironment(publication.source.env) | |
| 156 | + | } | |
| 157 | + | if req.activateTargetTab { | |
| 158 | + | req.target.active = req.targetTabIndex | |
| 159 | + | } | |
| 160 | + | return req.preserveRole | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // publishAttachmentOwnershipLocked atomically changes session membership, | |
| 164 | + | // generations, and currentSession while all role gates remain frozen. | |
| 165 | + | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |
| 166 | + | req := publication.req | |
| 167 | + | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | |
| 168 | + | publication.displacedTransport = publication.old.transportSnapshot() | |
| 169 | + | } | |
| 170 | + | if publication.source != req.target { | |
| 171 | + | if publication.source.client == req.next { | |
| 172 | + | publication.source.client = nil | |
| 173 | + | } | |
| 174 | + | delete(publication.source.snatched, req.next) | |
| 175 | + | } | |
| 176 | + | if req.targetRole == attachmentActive { | |
| 177 | + | delete(req.target.snatched, req.next) | |
| 178 | + | if publication.old != nil && publication.old != req.next { | |
| 179 | + | req.target.addSnatchedLocked(publication.old) | |
| 180 | + | } | |
| 181 | + | req.target.client = req.next | |
| 182 | + | } else { | |
| 183 | + | req.target.addSnatchedLocked(req.next) | |
| 184 | + | } | |
| 185 | + | publication.nextGeneration = req.next.roleGeneration.Add(1) | |
| 186 | + | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | |
| 187 | + | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | |
| 188 | + | } | |
| 189 | + | req.next.setSession(req.target) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and | |
| 193 | + | // constructs the exact published tokens after ownership has committed. | |
| 194 | + | func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 195 | + | req := publication.req | |
| 196 | + | result := attachmentTransitionResult{} | |
| 197 | + | if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil { | |
| 198 | + | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | |
| 199 | + | } | |
| 200 | + | var lease *attachmentLease | |
| 201 | + | if publication.targetCoordinator != nil { | |
| 202 | + | var cleanup renderLifecycleCleanup | |
| 203 | + | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | |
| 204 | + | result.cleanups = append(result.cleanups, cleanup) | |
| 205 | + | } | |
| 206 | + | result.published = attachmentRoleToken{ | |
| 207 | + | sess: req.target, | |
| 208 | + | ac: req.next, | |
| 209 | + | role: req.targetRole, | |
| 210 | + | generation: publication.nextGeneration, | |
| 211 | + | transport: req.expectedTransport, | |
| 212 | + | lease: lease, | |
| 213 | + | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | |
| 214 | + | } | |
| 215 | + | if publication.displacedGeneration != 0 { | |
| 216 | + | result.displaced = attachmentRoleToken{ | |
| 217 | + | sess: req.target, | |
| 218 | + | ac: publication.old, | |
| 219 | + | role: attachmentSnatched, | |
| 220 | + | generation: publication.displacedGeneration, | |
| 221 | + | transport: publication.displacedTransport, | |
| 222 | + | } | |
| 223 | + | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |
| 224 | + | } | |
| 225 | + | // Membership, currentSession, lease, generation, and both exact admission | |
| 226 | + | // capabilities become visible as one frozen publication. The gates remain | |
| 227 | + | // frozen until every architecture lock has been released. | |
| 228 | + | if req.roleEffectsFrozen { | |
| 229 | + | req.next.publishFrozenRoleCapability(result.published) | |
| 230 | + | if result.displaced.ac != nil { | |
| 231 | + | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | return result | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // publishAttachmentTransitionPrelocked performs only non-failing metadata, | |
| 238 | + | // ownership, generation, and coordinator-lease publication. The caller retains | |
| 239 | + | // every lock required by validateAttachmentTransitionPrelocked. | |
| 240 | + | func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 241 | + | if d.applyTargetStateLocked(publication) { | |
| 242 | + | return attachmentTransitionResult{published: *publication.req.sourceToken} | |
| 243 | + | } | |
| 244 | + | publishAttachmentOwnershipLocked(publication) | |
| 245 | + | return buildAttachmentPostcommitPlanLocked(publication) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for | |
| 249 | + | // the composable prelocked validation/publication seam. Caller holds d.mu and | |
| 250 | + | // notices.routingMu; this function acquires ordered source/target session locks. | |
| 251 | + | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 252 | + | source := req.source | |
| 253 | + | if source == nil { | |
| 254 | + | source = req.target | |
| 255 | + | } | |
| 256 | + | if source == nil || req.target == nil { | |
| 257 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 258 | + | } | |
| 259 | + | unlockSessions := lockAttachmentSessions(source, req.target) | |
| 260 | + | defer unlockSessions() | |
| 261 | + | ||
| 262 | + | publication, err := d.validateAttachmentTransitionPrelocked(req) | |
| 263 | + | if err != nil { | |
| 264 | + | return attachmentTransitionResult{}, err | |
| 265 | + | } | |
| 266 | + | defer publication.unlockCoordinators() | |
| 267 | + | return d.publishAttachmentTransitionPrelocked(publication), nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |
| 271 | + | if role == attachmentActive || role == attachmentSnatched { | |
| 272 | + | return true | |
| 273 | + | } | |
| 274 | + | return expected && role == attachmentDetached | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 278 | + | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 279 | + | func lockAttachmentSessions(a, b *session) func() { | |
| 280 | + | if a == b { | |
| 281 | + | a.mu.Lock() | |
| 282 | + | return a.mu.Unlock | |
| 283 | + | } | |
| 284 | + | first, second := a, b | |
| 285 | + | if first.id > second.id { | |
| 286 | + | first, second = second, first | |
| 287 | + | } | |
| 288 | + | first.mu.Lock() | |
| 289 | + | second.mu.Lock() | |
| 290 | + | return func() { | |
| 291 | + | second.mu.Unlock() | |
| 292 | + | first.mu.Unlock() | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 297 | + | // lockAttachmentSessions. Callers already hold the ordered session locks. | |
| 298 | + | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |
| 299 | + | if aCoordinator == nil && bCoordinator == nil { | |
| 300 | + | return func() {} | |
| 301 | + | } | |
| 302 | + | if a == b || aCoordinator == bCoordinator { | |
| 303 | + | coordinator := aCoordinator | |
| 304 | + | if coordinator == nil { | |
| 305 | + | coordinator = bCoordinator | |
| 306 | + | } | |
| 307 | + | coordinator.mu.Lock() | |
| 308 | + | return coordinator.mu.Unlock | |
| 309 | + | } | |
| 310 | + | first, second := aCoordinator, bCoordinator | |
| 311 | + | if a.id > b.id { | |
| 312 | + | first, second = second, first | |
| 313 | + | } | |
| 314 | + | if first != nil { | |
| 315 | + | first.mu.Lock() | |
| 316 | + | } | |
| 317 | + | if second != nil { | |
| 318 | + | second.mu.Lock() | |
| 319 | + | } | |
| 320 | + | return func() { | |
| 321 | + | if second != nil { | |
| 322 | + | second.mu.Unlock() | |
| 323 | + | } | |
| 324 | + | if first != nil { | |
| 325 | + | first.mu.Unlock() | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | } | |
M internal/usecase/daemon/attention.go +9 -1
| @@ -47,6 +47,10 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 47 | 47 | // happened — it also covers "no target exists", which is routine and not an | |
| 48 | 48 | // error. Only a failure to reach a target that does exist is a genuine error. | |
| 49 | 49 | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |
| 50 | + | return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{}) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error { | |
| 50 | 54 | if sess == nil || ac == nil { | |
| 51 | 55 | return nil | |
| 52 | 56 | } | |
| @@ -62,7 +66,11 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |||
| 62 | 66 | if !ok { | |
| 63 | 67 | return nil | |
| 64 | 68 | } | |
| 65 | - | return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | |
| 69 | + | pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex} | |
| 70 | + | if token.ac == nil { | |
| 71 | + | return d.switchToTarget(sess, ac, pickerTarget) | |
| 72 | + | } | |
| 73 | + | return d.switchActiveTargetForRole(token, pickerTarget) | |
| 66 | 74 | } | |
| 67 | 75 | ||
| 68 | 76 | func oldestAttentionTab(sess *session) (int, bool) { | |
M internal/usecase/daemon/client.go +274 -306
| @@ -39,10 +39,16 @@ type attachedClient struct { | |||
| 39 | 39 | overlays *overlayRuntime | |
| 40 | 40 | overlayOnce sync.Once | |
| 41 | 41 | 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 | |
| 46 | 52 | // prepareFailureFallback prevents a direct fallback paint from recursively | |
| 47 | 53 | // reporting the same failed prepare through its notice repaint. It is only | |
| 48 | 54 | // needed while no render coordinator is installed. | |
| @@ -56,20 +62,33 @@ type attachedClient struct { | |||
| 56 | 62 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 57 | 63 | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 58 | 64 | 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 | |
| 68 | 80 | // previousSession is guarded independently. It is retained through temporary | |
| 69 | 81 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 70 | 82 | 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 | |
| 73 | 92 | } | |
| 74 | 93 | ||
| 75 | 94 | type cursorOut struct { | |
| @@ -209,12 +228,14 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |||
| 209 | 228 | return nil | |
| 210 | 229 | } | |
| 211 | 230 | ac.linkMu.Lock() | |
| 212 | - | defer ac.linkMu.Unlock() | |
| 213 | 231 | if ac.tr != tr { | |
| 232 | + | ac.linkMu.Unlock() | |
| 214 | 233 | return nil | |
| 215 | 234 | } | |
| 216 | 235 | ac.tr = nil | |
| 217 | 236 | ac.transportIncarnation++ | |
| 237 | + | ac.linkMu.Unlock() | |
| 238 | + | ac.clearSnatchedInput() | |
| 218 | 239 | return tr | |
| 219 | 240 | } | |
| 220 | 241 | ||
| @@ -232,14 +253,6 @@ func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) b | |||
| 232 | 253 | return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation | |
| 233 | 254 | } | |
| 234 | 255 | ||
| 235 | - | func (ac *attachedClient) closeTransport() error { | |
| 236 | - | tr := ac.transport() | |
| 237 | - | if tr == nil { | |
| 238 | - | return nil | |
| 239 | - | } | |
| 240 | - | return tr.Close() | |
| 241 | - | } | |
| 242 | - | ||
| 243 | 256 | func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error { | |
| 244 | 257 | if tr == nil { | |
| 245 | 258 | return nil | |
| @@ -263,22 +276,6 @@ func (ac *attachedClient) ackOutputState(state uint64) { | |||
| 263 | 276 | ac.output.ack(state) | |
| 264 | 277 | } | |
| 265 | 278 | ||
| 266 | - | // send serialises a frame onto the client's transport. | |
| 267 | - | func (ac *attachedClient) send(f ports.Frame) error { | |
| 268 | - | _, err := ac.sendTransport(f) | |
| 269 | - | return err | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) { | |
| 273 | - | ac.sendMu.Lock() | |
| 274 | - | defer ac.sendMu.Unlock() | |
| 275 | - | tr := ac.transport() | |
| 276 | - | if tr == nil { | |
| 277 | - | return nil, errors.New("client transport is nil") | |
| 278 | - | } | |
| 279 | - | return tr, tr.Send(f) | |
| 280 | - | } | |
| 281 | - | ||
| 282 | 279 | var errTransportReplaced = errors.New("client transport was replaced") | |
| 283 | 280 | ||
| 284 | 281 | // sendExpectedTransport writes only when expected is still the attachment's | |
| @@ -295,6 +292,21 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po | |||
| 295 | 292 | return expected.transport.Send(f) | |
| 296 | 293 | } | |
| 297 | 294 | ||
| 295 | + | func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error { | |
| 296 | + | ac.sendMu.Lock() | |
| 297 | + | defer ac.sendMu.Unlock() | |
| 298 | + | if ticket == nil || ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) || | |
| 299 | + | expected.transport == nil || !ticket.beginTransportSend(expected) { | |
| 300 | + | return errAttachmentTransition | |
| 301 | + | } | |
| 302 | + | err := expected.transport.Send(f) | |
| 303 | + | if err != nil { | |
| 304 | + | ticket.reportTransportFailure(expected) | |
| 305 | + | } | |
| 306 | + | ticket.endTransportSend() | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | ||
| 298 | 310 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | |
| 299 | 311 | // waiting on sendMu behind a wedged paint) does not complete within | |
| 300 | 312 | // detachNotifyTimeout, the transport is force-closed, failing the in-flight | |
| @@ -372,12 +384,18 @@ func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transpor | |||
| 372 | 384 | } | |
| 373 | 385 | } | |
| 374 | 386 | ||
| 375 | - | // notifyDetachedAsync delivers a best-effort Detached notice off the caller'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 | + | } | |
| 381 | 399 | done := make(chan struct{}) | |
| 382 | 400 | d.mu.Lock() | |
| 383 | 401 | // Prune completed entries so the slice stays bounded by the number of | |
| @@ -395,8 +413,14 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |||
| 395 | 413 | ||
| 396 | 414 | go func() { | |
| 397 | 415 | 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) | |
| 400 | 424 | d.log.Info("client detached", "reason", reason) | |
| 401 | 425 | }() | |
| 402 | 426 | } | |
| @@ -424,13 +448,39 @@ type attachClientOptions struct { | |||
| 424 | 448 | maxOutputInFlight uint8 | |
| 425 | 449 | } | |
| 426 | 450 | ||
| 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) | |
| 431 | 478 | } | |
| 432 | 479 | ||
| 433 | - | func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) { | |
| 480 | + | // prepareAttachedClientLocked allocates one detached attachment. Caller holds | |
| 481 | + | // d.mu only to allocate its resume token; role publication happens later after | |
| 482 | + | // the caller releases every architecture lock. | |
| 483 | + | func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient { | |
| 434 | 484 | resumeToken := uint64(0) | |
| 435 | 485 | if opts.resumeCapable { | |
| 436 | 486 | resumeToken = d.nextResumeTokenLocked() | |
| @@ -444,54 +494,8 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 444 | 494 | resumeToken: resumeToken, | |
| 445 | 495 | } | |
| 446 | 496 | ac.initOverlays() | |
| 447 | - | ac.setSession(sess) | |
| 448 | 497 | 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 | |
| 495 | 499 | } | |
| 496 | 500 | ||
| 497 | 501 | // attachCoordinator is the sole direct attachment handoff. It creates at | |
| @@ -504,37 +508,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, | |||
| 504 | 508 | } | |
| 505 | 509 | ||
| 506 | 510 | 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) | |
| 538 | 512 | var cleanup renderLifecycleCleanup | |
| 539 | 513 | if old != nil { | |
| 540 | 514 | cleanup = rc.beginReplace(old, current, ready) | |
| @@ -544,6 +518,51 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached | |||
| 544 | 518 | return rc, cleanup | |
| 545 | 519 | } | |
| 546 | 520 | ||
| 521 | + | // ensureRenderCoordinator publishes the session's single coordinator without | |
| 522 | + | // binding an attachment lease. Centralized attachment transitions bind the | |
| 523 | + | // lease later while their ordered session locks are still held. | |
| 524 | + | func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator { | |
| 525 | + | sess.mu.Lock() | |
| 526 | + | defer sess.mu.Unlock() | |
| 527 | + | return d.ensureRenderCoordinatorPrelocked(sess) | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | // ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam | |
| 531 | + | // for transactions that already hold sess.mu. | |
| 532 | + | func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator { | |
| 533 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 534 | + | return rc | |
| 535 | + | } | |
| 536 | + | var rc *renderCoordinator | |
| 537 | + | rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 538 | + | clock: d.clock, | |
| 539 | + | observer: d.runtimeObserver, | |
| 540 | + | wake: func(w renderWake) { | |
| 541 | + | // Never reread sess.client here: a wake is bound to both its | |
| 542 | + | // attachment and its coordinator incarnation. The second check in | |
| 543 | + | // paint occurs under sendMu, closing a park/resume race after this | |
| 544 | + | // unlocked coordinator validation. | |
| 545 | + | if w.lease != nil && rc.wakeCurrent(w) { | |
| 546 | + | d.paint(sess, w.lease.attachment, w.reset, w.lease) | |
| 547 | + | } | |
| 548 | + | }, | |
| 549 | + | ackReady: func() bool { | |
| 550 | + | sess.mu.Lock() | |
| 551 | + | attached := sess.client | |
| 552 | + | sess.mu.Unlock() | |
| 553 | + | if attached == nil { | |
| 554 | + | return false | |
| 555 | + | } | |
| 556 | + | attached.sendMu.Lock() | |
| 557 | + | ready := attached.output == nil || !attached.output.atCapacity() | |
| 558 | + | attached.sendMu.Unlock() | |
| 559 | + | return ready | |
| 560 | + | }, | |
| 561 | + | }) | |
| 562 | + | sess.installRenderCoordinator(rc) | |
| 563 | + | return rc | |
| 564 | + | } | |
| 565 | + | ||
| 547 | 566 | // resetScreenDefaultColors clears the known default foreground/background | |
| 548 | 567 | // colors on every tab in sess. Called once a client has been detached, this | |
| 549 | 568 | // makes child OSC 10/11 queries go back to being swallowed (Known=false) | |
| @@ -559,43 +578,51 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 559 | 578 | d.applyHostTheme(sess, nil, themeui.Theme{}, true) | |
| 560 | 579 | } | |
| 561 | 580 | ||
| 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 | |
| 570 | 587 | } | |
| 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 | |
| 575 | 591 | } | |
| 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) | |
| 582 | 600 | } | |
| 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 | |
| 592 | 615 | } | |
| 593 | 616 | ||
| 594 | 617 | // firstPaint guarantees the freshly attached client sees the full screen: if | |
| 595 | 618 | // 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. | |
| 598 | 621 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 622 | + | d.firstPaintWithLease(sess, ac, clientSize, nil) | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) { | |
| 599 | 626 | // Global notices raised while nothing was attached surface on this client. | |
| 600 | 627 | // Drained before the early return below so a session without an active tab | |
| 601 | 628 | // cannot swallow the queue. | |
| @@ -610,134 +637,30 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 610 | 637 | ||
| 611 | 638 | outerResizeAccepted := false | |
| 612 | 639 | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 613 | - | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 640 | + | if lease == nil { | |
| 641 | + | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 642 | + | } else { | |
| 643 | + | outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true) | |
| 644 | + | } | |
| 614 | 645 | } | |
| 615 | 646 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 616 | 647 | // Activation can synchronously resize a retained floating pane. An accepted | |
| 617 | 648 | // synchronous outer request already includes that pane and emits the reset, | |
| 618 | 649 | // but activation still performs its warmup work. | |
| 619 | - | activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted) | |
| 650 | + | activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease) | |
| 620 | 651 | 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) | |
| 690 | 656 | } | |
| 691 | 657 | } | |
| 692 | 658 | } | |
| 693 | 659 | ||
| 694 | - | // clientGone detaches ac if it is still the session's current client. The | |
| 695 | - | // session remains registered and headless after the client is gone. | |
| 696 | - | func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 697 | - | if failed != nil && !ac.currentTransportIs(failed) { | |
| 698 | - | return // stale connection loop; a newer transport owns this client | |
| 699 | - | } | |
| 700 | - | if !d.detachIfCurrent(sess, ac) { | |
| 701 | - | return // already displaced by a newer client; nothing to do | |
| 702 | - | } | |
| 703 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 704 | - | rc.noteDetach(ac) | |
| 705 | - | } | |
| 706 | - | d.unregisterPreview(ac) | |
| 707 | - | sess.mu.Lock() | |
| 708 | - | ephemeral := sess.ephemeral | |
| 709 | - | sess.mu.Unlock() | |
| 710 | - | if !ephemeral { | |
| 711 | - | d.refreshSessionCwd(sess) | |
| 712 | - | } | |
| 713 | - | d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral) | |
| 714 | - | oldTr := failed | |
| 715 | - | if oldTr == nil { | |
| 716 | - | oldTr = ac.transport() | |
| 717 | - | } | |
| 718 | - | if !explicit && d.parkAttachment(sess, ac) { | |
| 719 | - | _ = ac.closeCapturedTransport(oldTr) | |
| 720 | - | d.log.Info("client parked", "session", sess.name) | |
| 721 | - | return | |
| 722 | - | } | |
| 723 | - | ||
| 724 | - | d.resetScreenDefaultColors(sess) | |
| 725 | - | ac.clearPreviousSession() | |
| 726 | - | if explicit { | |
| 727 | - | // Synchronous so the ack is delivered before the transport closes | |
| 728 | - | // (the client is actively awaiting it), but deadline-bounded so a | |
| 729 | - | // wedged client cannot pin this conn handler and hang Serve's | |
| 730 | - | // connWg.Wait. | |
| 731 | - | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 732 | - | } | |
| 733 | - | _ = ac.closeCapturedTransport(oldTr) | |
| 734 | - | d.log.Info("client detached", "session", sess.name, "explicit", explicit) | |
| 735 | - | } | |
| 736 | - | ||
| 737 | 660 | // detachIfCurrent clears the client iff ac is the current one, reporting | |
| 738 | 661 | ||
| 739 | - | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 740 | - | clientTheme := themeui.Theme{ | |
| 662 | + | func themeFromMessage(msg ports.Theme) themeui.Theme { | |
| 663 | + | return themeui.Theme{ | |
| 741 | 664 | Foreground: msg.Foreground, | |
| 742 | 665 | Background: msg.Background, | |
| 743 | 666 | Palette: msg.Palette, | |
| @@ -749,6 +672,10 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 749 | 672 | SchemeKnown: msg.SchemeKnown, | |
| 750 | 673 | Light: msg.Light, | |
| 751 | 674 | } | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 678 | + | clientTheme := themeFromMessage(msg) | |
| 752 | 679 | ac.setClientTheme(clientTheme) | |
| 753 | 680 | ||
| 754 | 681 | if !d.applyHostTheme(sess, ac, clientTheme, false) { | |
| @@ -757,6 +684,24 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 757 | 684 | d.invalidateRender(sess, ac, true, "client.go") | |
| 758 | 685 | } | |
| 759 | 686 | ||
| 687 | + | func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool { | |
| 688 | + | if !token.activeEffect() { | |
| 689 | + | return false | |
| 690 | + | } | |
| 691 | + | clientTheme := themeFromMessage(msg) | |
| 692 | + | token.ac.setClientTheme(clientTheme) | |
| 693 | + | if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) { | |
| 694 | + | return false | |
| 695 | + | } | |
| 696 | + | if !token.activeEffect() { | |
| 697 | + | return false | |
| 698 | + | } | |
| 699 | + | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 700 | + | return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) | |
| 701 | + | } | |
| 702 | + | return false | |
| 703 | + | } | |
| 704 | + | ||
| 760 | 705 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |
| 761 | 706 | d.requestTransactionalResize(sess, ac, sz, false) | |
| 762 | 707 | } | |
| @@ -767,32 +712,78 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 767 | 712 | // publishing sess.client. Never retain sess.mu while touching notice or overlay | |
| 768 | 713 | // state, and retain the routingMu -> sess.mu order used by attachment paths. | |
| 769 | 714 | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 715 | + | if sess == nil || ac == nil { | |
| 716 | + | return | |
| 717 | + | } | |
| 718 | + | tr := ac.transport() | |
| 719 | + | token := sess.attachmentToken(ac, tr) | |
| 720 | + | if token.lease == nil { | |
| 721 | + | // Direct test/headless callers retain pointer-based routing. Production | |
| 722 | + | // client frames always carry the exact lease through the role path below. | |
| 723 | + | d.handleClientNoticeDirect(sess, ac, notice) | |
| 724 | + | return | |
| 725 | + | } | |
| 726 | + | d.handleClientNoticeForRole(token, notice) | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) { | |
| 770 | 730 | d.notices.routingMu.Lock() | |
| 771 | - | defer d.notices.routingMu.Unlock() | |
| 731 | + | if !token.activeEffect() { | |
| 732 | + | d.notices.routingMu.Unlock() | |
| 733 | + | return | |
| 734 | + | } | |
| 735 | + | if d.notices.beforeClientNoticeMutation != nil { | |
| 736 | + | d.notices.beforeClientNoticeMutation() | |
| 737 | + | } | |
| 738 | + | if !token.activeEffect() { | |
| 739 | + | d.notices.routingMu.Unlock() | |
| 740 | + | return | |
| 741 | + | } | |
| 742 | + | repaint := d.mutateClientNotice(token.sess, token.ac, notice) | |
| 743 | + | d.notices.routingMu.Unlock() | |
| 744 | + | if repaint && token.activeEffect() { | |
| 745 | + | d.repaintForNotice(token.ac) | |
| 746 | + | } | |
| 747 | + | } | |
| 772 | 748 | ||
| 749 | + | func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 750 | + | d.notices.routingMu.Lock() | |
| 773 | 751 | sess.mu.Lock() | |
| 774 | 752 | current := sess.client == ac | |
| 775 | 753 | sess.mu.Unlock() | |
| 776 | 754 | if !current { | |
| 755 | + | d.notices.routingMu.Unlock() | |
| 777 | 756 | return | |
| 778 | 757 | } | |
| 779 | 758 | if d.notices.beforeClientNoticeMutation != nil { | |
| 780 | 759 | d.notices.beforeClientNoticeMutation() | |
| 781 | 760 | } | |
| 761 | + | repaint := d.mutateClientNotice(sess, ac, notice) | |
| 762 | + | d.notices.routingMu.Unlock() | |
| 763 | + | if repaint { | |
| 764 | + | d.repaintForNotice(ac) | |
| 765 | + | } | |
| 766 | + | } | |
| 782 | 767 | ||
| 768 | + | func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool { | |
| 783 | 769 | switch notice.Action { | |
| 784 | 770 | case ports.ClientNoticeClipboardFallback: | |
| 785 | - | d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |
| 771 | + | return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |
| 786 | 772 | case ports.ClientNoticeClipboardTooLarge: | |
| 787 | - | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |
| 773 | + | return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |
| 788 | 774 | case ports.ClientNoticeLinkDegraded: | |
| 789 | - | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |
| 775 | + | return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |
| 790 | 776 | case ports.ClientNoticeLinkConnected: | |
| 791 | - | d.dismissToast(ac, domain.NoticeConnection, sess.id) | |
| 777 | + | return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id) | |
| 778 | + | default: | |
| 779 | + | return false | |
| 792 | 780 | } | |
| 793 | 781 | } | |
| 794 | 782 | ||
| 795 | - | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) { | |
| 783 | + | // recordClientNotice mutates notice history and the selected attachment while | |
| 784 | + | // routingMu keeps replacement from changing that selection. Rendering is left | |
| 785 | + | // to the caller after it releases routingMu. | |
| 786 | + | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool { | |
| 796 | 787 | n := d.notices.record(domain.Notification{ | |
| 797 | 788 | Code: code, | |
| 798 | 789 | Severity: sev, | |
| @@ -800,7 +791,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai | |||
| 800 | 791 | Time: d.clock.Now(), | |
| 801 | 792 | SessionID: sess.id, | |
| 802 | 793 | }) | |
| 803 | - | d.showToast(ac, n) | |
| 794 | + | return d.publishToast(ac, n) | |
| 804 | 795 | } | |
| 805 | 796 | ||
| 806 | 797 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| @@ -809,29 +800,6 @@ func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domai | |||
| 809 | 800 | return d.requestTransactionalResize(sess, ac, sz, true) | |
| 810 | 801 | } | |
| 811 | 802 | ||
| 812 | - | // detachOnSendError drops a client whose transport failed, leaving the session | |
| 813 | - | // registered and headless. | |
| 814 | - | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 815 | - | if failed != nil && !ac.currentTransportIs(failed) { | |
| 816 | - | return | |
| 817 | - | } | |
| 818 | - | if d.detachIfCurrent(sess, ac) { | |
| 819 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 820 | - | rc.noteDetach(ac) | |
| 821 | - | } | |
| 822 | - | d.unregisterPreview(ac) | |
| 823 | - | if d.parkAttachment(sess, ac) { | |
| 824 | - | _ = ac.closeCapturedTransport(failed) | |
| 825 | - | d.log.Warn("parked client after send error", "session", sess.name) | |
| 826 | - | return | |
| 827 | - | } | |
| 828 | - | d.resetScreenDefaultColors(sess) | |
| 829 | - | ac.clearPreviousSession() | |
| 830 | - | _ = ac.closeCapturedTransport(failed) | |
| 831 | - | d.log.Warn("detached client after send error", "session", sess.name) | |
| 832 | - | } | |
| 833 | - | } | |
| 834 | - | ||
| 835 | 803 | // killSession removes a session and tears down its resources. It is | |
| 836 | 804 | // idempotent: only the caller that wins the registry delete acts. When the | |
| 837 | 805 | // registry empties it marks the daemon closing (atomically with the | |
A internal/usecase/daemon/client_frame_routing.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |
| 4 | + | ||
| 5 | + | // runConnLoop is the per-connection input router: it pumps client messages | |
| 6 | + | // until detach, EOF, or a transport error. Role is resolved after every Recv so | |
| 7 | + | // a transport displaced while blocked in Recv immediately adopts its restricted | |
| 8 | + | // snatched routing rather than executing one stale active frame. | |
| 9 | + | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 10 | + | tr := ac.transport() | |
| 11 | + | if tr == nil { | |
| 12 | + | return | |
| 13 | + | } | |
| 14 | + | for { | |
| 15 | + | if !ac.currentTransportIs(tr) { | |
| 16 | + | return | |
| 17 | + | } | |
| 18 | + | sess := ac.currentSession() | |
| 19 | + | if sess == nil { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | f, err := tr.Recv() | |
| 23 | + | if err != nil { | |
| 24 | + | token := sess.attachmentToken(ac, tr) | |
| 25 | + | if token.role == attachmentSnatched { | |
| 26 | + | d.parkOrDropSnatchedAttachment(token) | |
| 27 | + | } else { | |
| 28 | + | d.clientGone(sess, ac, tr, false) | |
| 29 | + | } | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | if !ac.currentTransportIs(tr) { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | sess = ac.currentSession() | |
| 36 | + | if sess == nil { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | token := sess.attachmentToken(ac, tr) | |
| 40 | + | switch token.role { | |
| 41 | + | case attachmentActive: | |
| 42 | + | if d.handleActiveClientFrame(token, f) { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | case attachmentSnatched: | |
| 46 | + | if d.handleSnatchedClientFrame(token, f) { | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | default: | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // handleActiveClientFrame owns the ordinary attached-client protocol. Keeping | |
| 56 | + | // it separate makes the restricted snatched protocol an explicit allow-list. | |
| 57 | + | func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 58 | + | if !token.activeEffect() { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | if d.afterActiveFrameDispatch != nil { | |
| 62 | + | d.afterActiveFrameDispatch(token) | |
| 63 | + | } | |
| 64 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 65 | + | if !admitted { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | token.effect = ticket | |
| 69 | + | defer token.endRoleEffect() | |
| 70 | + | if d.afterRoleEffectAdmitted != nil { | |
| 71 | + | d.afterRoleEffectAdmitted(token) | |
| 72 | + | } | |
| 73 | + | switch f.Type { | |
| 74 | + | case ports.MsgInput: | |
| 75 | + | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | |
| 76 | + | d.handleSequencedInputForRole(token, in.InputSeq, in.Data) | |
| 77 | + | } | |
| 78 | + | case ports.MsgResize: | |
| 79 | + | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() { | |
| 80 | + | d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false) | |
| 81 | + | } | |
| 82 | + | case ports.MsgTheme: | |
| 83 | + | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 84 | + | d.applyThemeForRole(token, th) | |
| 85 | + | } | |
| 86 | + | case ports.MsgImagePush: | |
| 87 | + | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 88 | + | d.handleSequencedImagePushForRole(token, ip.InputSeq, ip) | |
| 89 | + | } | |
| 90 | + | case ports.MsgClientNotice: | |
| 91 | + | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |
| 92 | + | d.handleClientNoticeForRole(token, notice) | |
| 93 | + | } else { | |
| 94 | + | d.log.Warn("malformed client notice", "err", derr) | |
| 95 | + | } | |
| 96 | + | case ports.MsgDetach: | |
| 97 | + | if token.activeEffect() { | |
| 98 | + | d.clientGoneForRole(token, true) | |
| 99 | + | return true | |
| 100 | + | } | |
| 101 | + | case ports.MsgAck: | |
| 102 | + | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 103 | + | d.ackActiveOutput(token, ack.AckedStateNum) | |
| 104 | + | } | |
| 105 | + | case ports.MsgPing: | |
| 106 | + | if err := token.sendActiveControl(framePong()); err != nil { | |
| 107 | + | token.endRoleEffect() | |
| 108 | + | d.detachOnRoleSendError(token, token.transport.transport) | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | default: | |
| 112 | + | // Unknown/out-of-band client messages are ignored so a newer | |
| 113 | + | // client can add message types without breaking an older daemon. | |
| 114 | + | } | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |
| 119 | + | ac := token.ac | |
| 120 | + | if token.effect == nil || token.effect.ended.Load() { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | ac.sendMu.Lock() | |
| 124 | + | ac.output.ack(state) | |
| 125 | + | ac.sendMu.Unlock() | |
| 126 | + | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 127 | + | rc.notifyAckForLease(token.lease) | |
| 128 | + | } | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // handleSnatchedClientFrame is intentionally an allow-list. Strict resume and | |
| 133 | + | // quit actions are introduced separately; until then ordinary input is ignored. | |
| 134 | + | func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 135 | + | if !token.current() { | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 139 | + | if !admitted { | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | token.effect = ticket | |
| 143 | + | defer token.endRoleEffect() | |
| 144 | + | if d.afterRoleEffectAdmitted != nil { | |
| 145 | + | d.afterRoleEffectAdmitted(token) | |
| 146 | + | } | |
| 147 | + | switch f.Type { | |
| 148 | + | case ports.MsgInput: | |
| 149 | + | if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) { | |
| 150 | + | return true | |
| 151 | + | } | |
| 152 | + | case ports.MsgResize: | |
| 153 | + | if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() { | |
| 154 | + | ac := token.ac | |
| 155 | + | ac.sendMu.Lock() | |
| 156 | + | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 157 | + | ac.sendMu.Unlock() | |
| 158 | + | return false | |
| 159 | + | } | |
| 160 | + | ac.size = resize.Size | |
| 161 | + | ac.sendMu.Unlock() | |
| 162 | + | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 163 | + | token.endRoleEffect() | |
| 164 | + | d.parkOrDropSnatchedAttachment(token) | |
| 165 | + | return true | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | case ports.MsgTheme: | |
| 169 | + | if theme, err := ports.UnmarshalTheme(f.Payload); err == nil { | |
| 170 | + | ac := token.ac | |
| 171 | + | ac.sendMu.Lock() | |
| 172 | + | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 173 | + | ac.sendMu.Unlock() | |
| 174 | + | return false | |
| 175 | + | } | |
| 176 | + | clientTheme := themeFromMessage(theme) | |
| 177 | + | ac.setClientTheme(clientTheme) | |
| 178 | + | ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme)) | |
| 179 | + | ac.sendMu.Unlock() | |
| 180 | + | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 181 | + | token.endRoleEffect() | |
| 182 | + | d.parkOrDropSnatchedAttachment(token) | |
| 183 | + | return true | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | case ports.MsgAck: | |
| 187 | + | if ack, err := ports.UnmarshalAck(f.Payload); err == nil { | |
| 188 | + | token.ac.ackOutputState(ack.AckedStateNum) | |
| 189 | + | } | |
| 190 | + | case ports.MsgPing: | |
| 191 | + | if err := d.sendSnatchedControl(token, framePong()); err != nil { | |
| 192 | + | token.endRoleEffect() | |
| 193 | + | d.parkOrDropSnatchedAttachment(token) | |
| 194 | + | return true | |
| 195 | + | } | |
| 196 | + | case ports.MsgDetach: | |
| 197 | + | _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 198 | + | token.endRoleEffect() | |
| 199 | + | d.dropSnatchedAttachment(token) | |
| 200 | + | return true | |
| 201 | + | } | |
| 202 | + | return false | |
| 203 | + | } | |
A internal/usecase/daemon/client_transport_errors.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |
| 4 | + | ||
| 5 | + | // clientGone detaches ac if it is still the session's current client. The | |
| 6 | + | // session remains registered and headless after the client is gone. | |
| 7 | + | func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 8 | + | if failed != nil && !ac.currentTransportIs(failed) { | |
| 9 | + | return // stale connection loop; a newer transport owns this client | |
| 10 | + | } | |
| 11 | + | if !d.detachIfCurrent(sess, ac) { | |
| 12 | + | return // already displaced by a newer client; nothing to do | |
| 13 | + | } | |
| 14 | + | d.finishClientGone(sess, ac, failed, explicit) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | |
| 18 | + | if token.effect == nil { | |
| 19 | + | return false | |
| 20 | + | } | |
| 21 | + | token.effect.bindActionEnd(d, "detach") | |
| 22 | + | token.effect.End() | |
| 23 | + | if !d.detachIfRoleCurrent(token) { | |
| 24 | + | return false | |
| 25 | + | } | |
| 26 | + | d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit) | |
| 27 | + | return true | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 31 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 32 | + | rc.noteDetach(ac) | |
| 33 | + | } | |
| 34 | + | d.unregisterPreview(ac) | |
| 35 | + | sess.mu.Lock() | |
| 36 | + | ephemeral := sess.ephemeral | |
| 37 | + | sess.mu.Unlock() | |
| 38 | + | if !ephemeral { | |
| 39 | + | d.refreshSessionCwd(sess) | |
| 40 | + | } | |
| 41 | + | d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral) | |
| 42 | + | oldTr := failed | |
| 43 | + | if oldTr == nil { | |
| 44 | + | oldTr = ac.transport() | |
| 45 | + | } | |
| 46 | + | if !explicit && d.parkAttachment(sess, ac) { | |
| 47 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 48 | + | d.log.Info("client parked", "session", sess.name) | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | d.resetScreenDefaultColors(sess) | |
| 53 | + | ac.clearPreviousSession() | |
| 54 | + | if explicit { | |
| 55 | + | // Synchronous so the ack is delivered before the transport closes | |
| 56 | + | // (the client is actively awaiting it), but deadline-bounded so a | |
| 57 | + | // wedged client cannot pin this conn handler and hang Serve's | |
| 58 | + | // connWg.Wait. | |
| 59 | + | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 60 | + | } | |
| 61 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 62 | + | d.log.Info("client detached", "session", sess.name, "explicit", explicit) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // detachOnSendError drops a client whose transport failed, leaving the session | |
| 66 | + | // registered and headless. | |
| 67 | + | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 68 | + | if failed != nil && !ac.currentTransportIs(failed) { | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | if d.detachIfCurrent(sess, ac) { | |
| 72 | + | d.finishSendErrorDetach(sess, ac, failed) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) { | |
| 77 | + | d.detachOnRoleSendErrorUntil(token, failed, nil) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | |
| 81 | + | if failed != token.transport.transport { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | if d.detachIfRoleCurrentUntil(token, done) { | |
| 85 | + | d.finishSendErrorDetach(token.sess, token.ac, failed) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // reserveRoleSendErrorCleanup accounts for cleanup before End releases the | |
| 90 | + | // role gate. This closes the WaitGroup Add/Wait race with terminal teardown; | |
| 91 | + | // the returned launch function must be invoked immediately after ticket End. | |
| 92 | + | func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() { | |
| 93 | + | d.attachmentCleanupWg.Add(1) | |
| 94 | + | return func() { | |
| 95 | + | go func() { | |
| 96 | + | defer d.attachmentCleanupWg.Done() | |
| 97 | + | if d.afterRoleSendErrorCleanup != nil { | |
| 98 | + | defer d.afterRoleSendErrorCleanup() | |
| 99 | + | } | |
| 100 | + | if d.beforeRoleSendErrorCleanup != nil { | |
| 101 | + | d.beforeRoleSendErrorCleanup(token) | |
| 102 | + | } | |
| 103 | + | deadline := newRoleEffectDrainDeadline(d.clock) | |
| 104 | + | defer deadline.stop() | |
| 105 | + | d.detachOnRoleSendErrorUntil(token, failed, deadline.Done) | |
| 106 | + | }() | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 111 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 112 | + | rc.noteDetach(ac) | |
| 113 | + | } | |
| 114 | + | d.unregisterPreview(ac) | |
| 115 | + | if d.parkAttachment(sess, ac) { | |
| 116 | + | _ = ac.closeCapturedTransport(failed) | |
| 117 | + | d.log.Warn("parked client after send error", "session", sess.name) | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | d.resetScreenDefaultColors(sess) | |
| 121 | + | ac.clearPreviousSession() | |
| 122 | + | _ = ac.closeCapturedTransport(failed) | |
| 123 | + | d.log.Warn("detached client after send error", "session", sess.name) | |
| 124 | + | } | |
M internal/usecase/daemon/clipboard.go +166 -17
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | package daemon | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| 7 | + | "errors" | |
| 7 | 8 | "os" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| @@ -25,12 +26,27 @@ var ( | |||
| 25 | 26 | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 26 | 27 | ) | |
| 27 | 28 | ||
| 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) | |
| 34 | 50 | } | |
| 35 | 51 | ||
| 36 | 52 | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |
| @@ -58,6 +74,33 @@ func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) { | |||
| 58 | 74 | // writeClipboardImage writes ip's bytes to a new 0600 temp file and records | |
| 59 | 75 | // the path on sess for best-effort cleanup at session end (killSession). | |
| 60 | 76 | func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) { | |
| 77 | + | path, err := d.createClipboardImage(ip) | |
| 78 | + | if err != nil { | |
| 79 | + | return "", err | |
| 80 | + | } | |
| 81 | + | sess.mu.Lock() | |
| 82 | + | sess.clipFiles = append(sess.clipFiles, path) | |
| 83 | + | sess.mu.Unlock() | |
| 84 | + | return path, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) { | |
| 88 | + | path, err := d.createClipboardImage(ip) | |
| 89 | + | if err != nil { | |
| 90 | + | return "", err | |
| 91 | + | } | |
| 92 | + | token.sess.mu.Lock() | |
| 93 | + | if !token.activeEffectSessionLocked() { | |
| 94 | + | token.sess.mu.Unlock() | |
| 95 | + | _ = os.Remove(path) | |
| 96 | + | return "", errAttachmentTransition | |
| 97 | + | } | |
| 98 | + | token.sess.clipFiles = append(token.sess.clipFiles, path) | |
| 99 | + | token.sess.mu.Unlock() | |
| 100 | + | return path, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (d *Daemon) createClipboardImage(ip ports.ImagePush) (string, error) { | |
| 61 | 104 | dir := d.tempDir | |
| 62 | 105 | if dir == "" { | |
| 63 | 106 | dir = os.TempDir() | |
| @@ -76,9 +119,6 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||
| 76 | 119 | _ = os.Remove(path) | |
| 77 | 120 | return "", err | |
| 78 | 121 | } | |
| 79 | - | sess.mu.Lock() | |
| 80 | - | sess.clipFiles = append(sess.clipFiles, path) | |
| 81 | - | sess.mu.Unlock() | |
| 82 | 122 | return path, nil | |
| 83 | 123 | } | |
| 84 | 124 | ||
| @@ -86,6 +126,17 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||
| 86 | 126 | // the same PTY write path as ordinary input, wrapped in bracketed-paste | |
| 87 | 127 | // markers iff the pane's Screen reports bracketed-paste mode enabled. | |
| 88 | 128 | func (d *Daemon) injectClipboardPath(sess *session, path string) { | |
| 129 | + | d.injectClipboardPathToTarget(sess, path, nil) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) { | |
| 133 | + | if !token.activeEffect() { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | d.injectClipboardPathToTarget(token.sess, path, &token) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | |
| 89 | 140 | tb := sess.activeTab() | |
| 90 | 141 | if tb == nil { | |
| 91 | 142 | return | |
| @@ -109,6 +160,9 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 109 | 160 | wrapped = append(wrapped, clipPasteCloseMarker...) | |
| 110 | 161 | data = wrapped | |
| 111 | 162 | } | |
| 163 | + | if token != nil && !token.activeEffect() { | |
| 164 | + | return | |
| 165 | + | } | |
| 112 | 166 | d.writeToPane(sess, p, data) | |
| 113 | 167 | } | |
| 114 | 168 | ||
| @@ -132,8 +186,9 @@ func clipboardExt(mime string) string { | |||
| 132 | 186 | } | |
| 133 | 187 | ||
| 134 | 188 | type clipboardForward struct { | |
| 135 | - | ac *attachedClient | |
| 136 | - | seq []byte | |
| 189 | + | owner paneEffectLease | |
| 190 | + | token attachmentRoleToken | |
| 191 | + | seq []byte | |
| 137 | 192 | } | |
| 138 | 193 | ||
| 139 | 194 | // forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set | |
| @@ -145,11 +200,12 @@ type clipboardForward struct { | |||
| 145 | 200 | // | |
| 146 | 201 | // Requests are queued on the session and drained by one worker so clipboard | |
| 147 | 202 | // writes keep arrival order without making the PTY reader wait on client I/O. | |
| 148 | - | func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) { | |
| 203 | + | func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) { | |
| 149 | 204 | seq := scopy.OSC52FromBase64(b64) | |
| 150 | - | if seq == nil { | |
| 205 | + | if seq == nil || !owner.Current() { | |
| 151 | 206 | return | |
| 152 | 207 | } | |
| 208 | + | sess := owner.owner.session | |
| 153 | 209 | ||
| 154 | 210 | sess.mu.Lock() | |
| 155 | 211 | if sess.ctx != nil { | |
| @@ -161,11 +217,31 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) { | |||
| 161 | 217 | } | |
| 162 | 218 | } | |
| 163 | 219 | ac := sess.client | |
| 220 | + | sess.mu.Unlock() | |
| 164 | 221 | if ac == nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | transport := ac.transport() | |
| 225 | + | token := sess.attachmentToken(ac, transport) | |
| 226 | + | if !token.activeCurrent() { | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | sess.mu.Lock() | |
| 231 | + | if sess.ctx != nil { | |
| 232 | + | select { | |
| 233 | + | case <-sess.ctx.Done(): | |
| 234 | + | sess.mu.Unlock() | |
| 235 | + | return | |
| 236 | + | default: | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | if sess.client != ac || ac.roleGeneration.Load() != token.generation || | |
| 240 | + | !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess { | |
| 165 | 241 | sess.mu.Unlock() | |
| 166 | 242 | return | |
| 167 | 243 | } | |
| 168 | - | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq}) | |
| 244 | + | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq}) | |
| 169 | 245 | if !sess.clipboardWorkerRunning { | |
| 170 | 246 | sess.clipboardWorkerRunning = true | |
| 171 | 247 | go d.clipboardWorker(sess) | |
| @@ -173,15 +249,88 @@ func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) { | |||
| 173 | 249 | sess.mu.Unlock() | |
| 174 | 250 | } | |
| 175 | 251 | ||
| 252 | + | func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) { | |
| 253 | + | token := item.token | |
| 254 | + | if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil { | |
| 255 | + | return token.transport.transport, errAttachmentTransition | |
| 256 | + | } | |
| 257 | + | expected := token.transport | |
| 258 | + | ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport) | |
| 259 | + | send := func() error { | |
| 260 | + | ac := token.ac | |
| 261 | + | ac.sendMu.Lock() | |
| 262 | + | defer ac.sendMu.Unlock() | |
| 263 | + | if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) { | |
| 264 | + | return errAttachmentTransition | |
| 265 | + | } | |
| 266 | + | if !beginClipboardOwnerSend(item.owner, ticket, expected) { | |
| 267 | + | return errAttachmentTransition | |
| 268 | + | } | |
| 269 | + | frame := ac.output.sideEffect(item.seq, ac.echoAck.Load()) | |
| 270 | + | var err error | |
| 271 | + | if owned { | |
| 272 | + | err = ownedTransport.SendSynchronous(frame) | |
| 273 | + | } else { | |
| 274 | + | err = expected.transport.Send(frame) | |
| 275 | + | } | |
| 276 | + | if err != nil { | |
| 277 | + | ticket.reportTransportFailure(expected) | |
| 278 | + | } | |
| 279 | + | ticket.endTransportSend() | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | if owned { | |
| 283 | + | return expected.transport, send() | |
| 284 | + | } | |
| 285 | + | return d.boundedSendWith(expected.transport, send) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // beginClipboardOwnerSend validates the source pane generation after sendMu | |
| 289 | + | // admission and marks the transport interval while ownership publication is | |
| 290 | + | // excluded. Publication therefore orders either before this check (the send is | |
| 291 | + | // dropped) or after transport admission (the send belongs to the old owner). | |
| 292 | + | func beginClipboardOwnerSend(lease paneEffectLease, ticket *roleEffectTicket, expected transportSnapshot) bool { | |
| 293 | + | if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil { | |
| 294 | + | return false | |
| 295 | + | } | |
| 296 | + | owner := lease.owner | |
| 297 | + | if owner.floatingSlotGeneration != 0 { | |
| 298 | + | owner.tab.mu.Lock() | |
| 299 | + | defer owner.tab.mu.Unlock() | |
| 300 | + | } | |
| 301 | + | lease.pane.mu.Lock() | |
| 302 | + | defer lease.pane.mu.Unlock() | |
| 303 | + | if lease.pane.owner.Load() != owner { | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | if owner.floatingSlotGeneration != 0 && | |
| 307 | + | (owner.tab.floating.pane != lease.pane || owner.tab.floating.generation != owner.floatingSlotGeneration || | |
| 308 | + | (owner.tab.floating.state != floatingHidden && owner.tab.floating.state != floatingVisible)) { | |
| 309 | + | return false | |
| 310 | + | } | |
| 311 | + | return ticket.beginTransportSend(expected) | |
| 312 | + | } | |
| 313 | + | ||
| 176 | 314 | func (d *Daemon) clipboardWorker(sess *session) { | |
| 177 | 315 | for { | |
| 178 | 316 | item, ok := nextClipboardForward(sess) | |
| 179 | 317 | if !ok { | |
| 180 | 318 | return | |
| 181 | 319 | } | |
| 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) | |
| 185 | 334 | } | |
| 186 | 335 | } | |
| 187 | 336 | } | |
M internal/usecase/daemon/clipboard_test.go +220 -13
| @@ -3,10 +3,12 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| 11 | + | "sync" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| @@ -200,6 +202,25 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY { | |||
| 200 | 202 | return p | |
| 201 | 203 | } | |
| 202 | 204 | ||
| 205 | + | func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) { | |
| 206 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 207 | + | token := sess.attachmentToken(ac, tr) | |
| 208 | + | token.lease = rc.attachmentLease(ac) | |
| 209 | + | ac.publishRoleCapability(token) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease { | |
| 213 | + | t.Helper() | |
| 214 | + | tb := sess.activeTab() | |
| 215 | + | tb.mu.Lock() | |
| 216 | + | p := tb.terminalTargetLocked() | |
| 217 | + | tb.mu.Unlock() | |
| 218 | + | if p == nil { | |
| 219 | + | t.Fatal("clipboard owner has no terminal target") | |
| 220 | + | } | |
| 221 | + | return p.effectLease() | |
| 222 | + | } | |
| 223 | + | ||
| 203 | 224 | func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |
| 204 | 225 | d := newTestDaemon(t, nil, stubClock{}) | |
| 205 | 226 | p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07")) | |
| @@ -211,16 +232,25 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 211 | 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 212 | 233 | ac.initOverlays() | |
| 213 | 234 | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 235 | + | publishTiledPaneOwners(sess, win) | |
| 214 | 236 | ac.setSession(sess) | |
| 215 | 237 | d.sessions[sess.id] = sess | |
| 238 | + | publishActiveClipboardCapability(d, sess, ac, tr) | |
| 216 | 239 | ||
| 217 | 240 | d.sessWg.Add(1) | |
| 218 | 241 | d.ptyReader(sess, win, win.focusedPane()) | |
| 219 | 242 | ||
| 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") | |
| 224 | 254 | } | |
| 225 | 255 | ||
| 226 | 256 | func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |
| @@ -236,6 +266,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||
| 236 | 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 237 | 267 | ac.initOverlays() | |
| 238 | 268 | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 269 | + | publishTiledPaneOwners(sess, win) | |
| 239 | 270 | ac.setSession(sess) | |
| 240 | 271 | d.sessions[sess.id] = sess | |
| 241 | 272 | ||
| @@ -260,6 +291,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||
| 260 | 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 261 | 292 | ac.initOverlays() | |
| 262 | 293 | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 294 | + | publishTiledPaneOwners(sess, win) | |
| 263 | 295 | ac.setSession(sess) | |
| 264 | 296 | d.sessions[sess.id] = sess | |
| 265 | 297 | ||
| @@ -281,6 +313,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||
| 281 | 313 | defer cancel() | |
| 282 | 314 | win := newTestTabWithContext(p, sctx, cancel) | |
| 283 | 315 | sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 316 | + | publishTiledPaneOwners(sess, win) | |
| 284 | 317 | d.sessions[sess.id] = sess | |
| 285 | 318 | ||
| 286 | 319 | d.sessWg.Add(1) | |
| @@ -289,22 +322,196 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||
| 289 | 322 | }) | |
| 290 | 323 | } | |
| 291 | 324 | ||
| 325 | + | type staleClipboardErrorTransport struct { | |
| 326 | + | mu sync.Mutex | |
| 327 | + | sends int | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func (t *staleClipboardErrorTransport) Send(ports.Frame) error { | |
| 331 | + | t.mu.Lock() | |
| 332 | + | t.sends++ | |
| 333 | + | t.mu.Unlock() | |
| 334 | + | return errors.New("stale clipboard send") | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (*staleClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 338 | + | func (*staleClipboardErrorTransport) Close() error { return nil } | |
| 339 | + | ||
| 340 | + | func (t *staleClipboardErrorTransport) sendCount() int { | |
| 341 | + | t.mu.Lock() | |
| 342 | + | defer t.mu.Unlock() | |
| 343 | + | return t.sends | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |
| 347 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 348 | + | oldTransport := &staleClipboardErrorTransport{} | |
| 349 | + | ac.replaceTransport(oldTransport) | |
| 350 | + | publishActiveClipboardCapability(d, sess, ac, oldTransport) | |
| 351 | + | ||
| 352 | + | sess.mu.Lock() | |
| 353 | + | sess.clipboardWorkerRunning = true | |
| 354 | + | sess.mu.Unlock() | |
| 355 | + | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 356 | + | ||
| 357 | + | sourceTab := sess.activeTab() | |
| 358 | + | p := sourceTab.focusedPane() | |
| 359 | + | destination := &session{id: "destination", name: "destination"} | |
| 360 | + | publishPaneOwner(p, destination, &tab{}, 0) | |
| 361 | + | publishPaneOwner(p, sess, sourceTab, 0) | |
| 362 | + | ||
| 363 | + | d.clipboardWorker(sess) | |
| 364 | + | ||
| 365 | + | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | |
| 366 | + | require.Same(t, ac, sess.client, "stale clipboard send handling detached the former owner's client") | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | type movingClipboardErrorTransport struct { | |
| 370 | + | started chan struct{} | |
| 371 | + | release chan struct{} | |
| 372 | + | closed chan struct{} | |
| 373 | + | startedOnce sync.Once | |
| 374 | + | closeOnce sync.Once | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func newMovingClipboardErrorTransport() *movingClipboardErrorTransport { | |
| 378 | + | return &movingClipboardErrorTransport{ | |
| 379 | + | started: make(chan struct{}), | |
| 380 | + | release: make(chan struct{}), | |
| 381 | + | closed: make(chan struct{}), | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (t *movingClipboardErrorTransport) Send(ports.Frame) error { | |
| 386 | + | t.startedOnce.Do(func() { close(t.started) }) | |
| 387 | + | <-t.release | |
| 388 | + | return errors.New("clipboard send failed after pane move") | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func (*movingClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 392 | + | ||
| 393 | + | func (t *movingClipboardErrorTransport) Close() error { | |
| 394 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 395 | + | return nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing.T) { | |
| 399 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 400 | + | oldTransport := &staleClipboardErrorTransport{} | |
| 401 | + | ac.replaceTransport(oldTransport) | |
| 402 | + | publishActiveClipboardCapability(d, sess, ac, oldTransport) | |
| 403 | + | ||
| 404 | + | sess.mu.Lock() | |
| 405 | + | sess.clipboardWorkerRunning = true | |
| 406 | + | sess.mu.Unlock() | |
| 407 | + | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 408 | + | ||
| 409 | + | ac.sendMu.Lock() | |
| 410 | + | sendMuLocked := true | |
| 411 | + | defer func() { | |
| 412 | + | if sendMuLocked { | |
| 413 | + | ac.sendMu.Unlock() | |
| 414 | + | } | |
| 415 | + | }() | |
| 416 | + | workerDone := make(chan struct{}) | |
| 417 | + | go func() { | |
| 418 | + | d.clipboardWorker(sess) | |
| 419 | + | close(workerDone) | |
| 420 | + | }() | |
| 421 | + | require.Eventually(t, func() bool { | |
| 422 | + | return ac.roleEffects.inFlightCount() == 1 | |
| 423 | + | }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu") | |
| 424 | + | ||
| 425 | + | sourceTab := sess.activeTab() | |
| 426 | + | p := sourceTab.focusedPane() | |
| 427 | + | destination := &session{id: "destination", name: "destination"} | |
| 428 | + | publishPaneOwner(p, destination, &tab{}, 0) | |
| 429 | + | publishPaneOwner(p, sess, sourceTab, 0) | |
| 430 | + | ac.sendMu.Unlock() | |
| 431 | + | sendMuLocked = false | |
| 432 | + | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 433 | + | ||
| 434 | + | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | |
| 435 | + | require.Same(t, ac, sess.client) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |
| 439 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 440 | + | oldTransport := newMovingClipboardErrorTransport() | |
| 441 | + | ac.replaceTransport(oldTransport) | |
| 442 | + | publishActiveClipboardCapability(d, sess, ac, oldTransport) | |
| 443 | + | ||
| 444 | + | sess.mu.Lock() | |
| 445 | + | sess.clipboardWorkerRunning = true | |
| 446 | + | sess.mu.Unlock() | |
| 447 | + | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 448 | + | ||
| 449 | + | workerDone := make(chan struct{}) | |
| 450 | + | go func() { | |
| 451 | + | d.clipboardWorker(sess) | |
| 452 | + | close(workerDone) | |
| 453 | + | }() | |
| 454 | + | awaitTestCompletion(t, oldTransport.started, "clipboard send did not start") | |
| 455 | + | ||
| 456 | + | sourceTab := sess.activeTab() | |
| 457 | + | p := sourceTab.focusedPane() | |
| 458 | + | destination := &session{id: "destination", name: "destination"} | |
| 459 | + | publishPaneOwner(p, destination, &tab{}, 0) | |
| 460 | + | publishPaneOwner(p, sess, sourceTab, 0) | |
| 461 | + | close(oldTransport.release) | |
| 462 | + | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 463 | + | ||
| 464 | + | require.Same(t, ac, sess.client, "a send error from the pane's retired owner detached its client") | |
| 465 | + | select { | |
| 466 | + | case <-oldTransport.closed: | |
| 467 | + | t.Fatal("a send error from the pane's retired owner closed its client transport") | |
| 468 | + | default: | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) { | |
| 473 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 474 | + | oldTransport := &staleClipboardErrorTransport{} | |
| 475 | + | old.replaceTransport(oldTransport) | |
| 476 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 477 | + | token := sess.attachmentToken(old, oldTransport) | |
| 478 | + | token.lease = rc.attachmentLease(old) | |
| 479 | + | old.publishRoleCapability(token) | |
| 480 | + | ||
| 481 | + | sess.mu.Lock() | |
| 482 | + | sess.clipboardWorkerRunning = true | |
| 483 | + | sess.mu.Unlock() | |
| 484 | + | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 485 | + | ||
| 486 | + | newTransport := &closeTrackingTransport{} | |
| 487 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 488 | + | next.initOverlays() | |
| 489 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 490 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 491 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 492 | + | }) | |
| 493 | + | require.NoError(t, err) | |
| 494 | + | ||
| 495 | + | d.clipboardWorker(sess) | |
| 496 | + | require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport") | |
| 497 | + | require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement") | |
| 498 | + | require.Same(t, next, sess.client, "stale clipboard send handling detached the current client") | |
| 499 | + | require.False(t, newTransport.Closed()) | |
| 500 | + | } | |
| 501 | + | ||
| 292 | 502 | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |
| 293 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 503 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 294 | 504 | 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) | |
| 301 | 508 | ||
| 302 | 509 | first := base64.StdEncoding.EncodeToString([]byte("first")) | |
| 303 | 510 | second := base64.StdEncoding.EncodeToString([]byte("second")) | |
| 304 | - | d.forwardClipboardAsync(sess, first) | |
| 511 | + | d.forwardClipboardAsync(owner, first) | |
| 305 | 512 | require.Equal(t, "first", <-tr.started) | |
| 306 | 513 | ||
| 307 | - | d.forwardClipboardAsync(sess, second) | |
| 514 | + | d.forwardClipboardAsync(owner, second) | |
| 308 | 515 | select { | |
| 309 | 516 | case got := <-tr.started: | |
| 310 | 517 | t.Fatalf("second clipboard send started before first completed: %q", got) | |
M internal/usecase/daemon/control.go +72 -7
| @@ -52,6 +52,19 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque | |||
| 52 | 52 | if sess == nil { | |
| 53 | 53 | return commandFailure(code, text) | |
| 54 | 54 | } | |
| 55 | + | if cmd.Scope == command.CommandScopeCrossSession { | |
| 56 | + | // Cross-session moves intentionally skip sess.dispatchMu: movePane and | |
| 57 | + | // moveTab acquire both session dispatch locks through lockMoveDispatch in | |
| 58 | + | // global order. Pre-locking the source here could recreate the | |
| 59 | + | // opposite-direction deadlock covered by | |
| 60 | + | // TestHandleCommandOppositeMoveCommandsDoNotDeadlock. | |
| 61 | + | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request) | |
| 62 | + | if code != 0 { | |
| 63 | + | return commandFailure(code, text) | |
| 64 | + | } | |
| 65 | + | return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request) | |
| 66 | + | } | |
| 67 | + | ||
| 55 | 68 | sess.dispatchMu.Lock() | |
| 56 | 69 | defer sess.dispatchMu.Unlock() | |
| 57 | 70 | ||
| @@ -72,6 +85,8 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports | |||
| 72 | 85 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | |
| 73 | 86 | case errors.Is(err, errSessionNameInUse): | |
| 74 | 87 | return commandFailure(ports.ErrNameTaken, err.Error()) | |
| 88 | + | case isMoveCommandError(err): | |
| 89 | + | return moveCommandFailure(err) | |
| 75 | 90 | case errors.Is(err, layout.ErrNotInSplit): | |
| 76 | 91 | return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split") | |
| 77 | 92 | case errors.Is(err, layout.ErrTooSmall): | |
| @@ -86,8 +101,8 @@ func commandFailure(code uint16, text string) ports.CommandResult { | |||
| 86 | 101 | } | |
| 87 | 102 | ||
| 88 | 103 | // 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. | |
| 91 | 106 | func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) { | |
| 92 | 107 | d.mu.Lock() | |
| 93 | 108 | defer d.mu.Unlock() | |
| @@ -106,13 +121,18 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 106 | 121 | if request.TargetTab == "" || request.TargetPane == "" { | |
| 107 | 122 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | |
| 108 | 123 | } | |
| 124 | + | var match *session | |
| 109 | 125 | 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" | |
| 115 | 131 | } | |
| 132 | + | match = sess | |
| 133 | + | } | |
| 134 | + | if match != nil { | |
| 135 | + | return match, 0, "" | |
| 116 | 136 | } | |
| 117 | 137 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | |
| 118 | 138 | } | |
| @@ -402,6 +422,51 @@ func (e controlExec) CloseTab() error { | |||
| 402 | 422 | func (e controlExec) ClosePane() error { | |
| 403 | 423 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |
| 404 | 424 | } | |
| 425 | + | ||
| 426 | + | func (e controlExec) MovePane(destinationSession, destinationTabID string) error { | |
| 427 | + | destination := e.d.liveSessionByName(destinationSession) | |
| 428 | + | if destination == nil { | |
| 429 | + | return errMoveStaleTarget | |
| 430 | + | } | |
| 431 | + | if e.sess == nil || e.tab == nil || e.target.pane == nil { | |
| 432 | + | return errMovePaneInvalid | |
| 433 | + | } | |
| 434 | + | return e.d.movePane(movePaneRequest{ | |
| 435 | + | Source: sessionMoveLocator(e.sess), | |
| 436 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 437 | + | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| 438 | + | Destination: sessionMoveLocator(destination), | |
| 439 | + | DestinationTabID: domain.TabStableID(destinationTabID), | |
| 440 | + | }) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func (e controlExec) MoveTab(destinationSession string) error { | |
| 444 | + | destination := e.d.liveSessionByName(destinationSession) | |
| 445 | + | if destination == nil { | |
| 446 | + | return errMoveStaleTarget | |
| 447 | + | } | |
| 448 | + | if e.sess == nil || e.tab == nil { | |
| 449 | + | return errMovePaneInvalid | |
| 450 | + | } | |
| 451 | + | return e.d.moveTab(moveTabRequest{ | |
| 452 | + | Source: sessionMoveLocator(e.sess), | |
| 453 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 454 | + | Destination: sessionMoveLocator(destination), | |
| 455 | + | }) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func (d *Daemon) liveSessionByName(name string) *session { | |
| 459 | + | d.mu.Lock() | |
| 460 | + | defer d.mu.Unlock() | |
| 461 | + | return d.findByNameLocked(name) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func sessionMoveLocator(sess *session) moveSessionLocator { | |
| 465 | + | sess.mu.Lock() | |
| 466 | + | name := sess.name | |
| 467 | + | sess.mu.Unlock() | |
| 468 | + | return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: name} | |
| 469 | + | } | |
| 405 | 470 | func (e controlExec) split(direction layout.Direction) error { | |
| 406 | 471 | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | |
| 407 | 472 | } | |
M internal/usecase/daemon/control_test.go +373 -4
| @@ -169,10 +169,10 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||
| 169 | 169 | {name: "missing stable IDs", arrange: func(d *Daemon) { | |
| 170 | 170 | addControlSession(d, "work", "t_work", "p_work") | |
| 171 | 171 | }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget}, | |
| 172 | - | {name: "cross-session IDs rejected", arrange: func(d *Daemon) { | |
| 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}, | |
| 176 | 176 | {name: "self requires both IDs", arrange: func(d *Daemon) { | |
| 177 | 177 | addControlSession(d, "work", "t_work", "p_work") | |
| 178 | 178 | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | |
| @@ -707,6 +707,375 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 707 | 707 | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |
| 708 | 708 | } | |
| 709 | 709 | ||
| 710 | + | func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) { | |
| 711 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 712 | + | source := addControlSession(d, "work", "t_active", "p_active") | |
| 713 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 714 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |
| 715 | + | publishTiledPaneOwners(source, inactive) | |
| 716 | + | source.mu.Lock() | |
| 717 | + | source.tabs = append(source.tabs, inactive) | |
| 718 | + | source.active = 0 | |
| 719 | + | source.mu.Unlock() | |
| 720 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 721 | + | ||
| 722 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 723 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | |
| 724 | + | }) | |
| 725 | + | require.True(t, result.OK, result.Text) | |
| 726 | + | ||
| 727 | + | source.mu.Lock() | |
| 728 | + | require.Len(t, source.tabs, 1) | |
| 729 | + | require.Equal(t, "t_inactive", source.tabs[0].stableID) | |
| 730 | + | source.mu.Unlock() | |
| 731 | + | destination.mu.Lock() | |
| 732 | + | destTab := destination.tabs[0] | |
| 733 | + | destination.mu.Unlock() | |
| 734 | + | destTab.mu.Lock() | |
| 735 | + | var moved *pane | |
| 736 | + | for _, candidate := range destTab.panes { | |
| 737 | + | if candidate.stableID == "p_active" { | |
| 738 | + | moved = candidate | |
| 739 | + | } | |
| 740 | + | } | |
| 741 | + | require.NotNil(t, moved) | |
| 742 | + | destTab.mu.Unlock() | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |
| 746 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 747 | + | source := addControlSession(d, "work", "t_active", "p_active") | |
| 748 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 749 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |
| 750 | + | publishTiledPaneOwners(source, inactive) | |
| 751 | + | source.mu.Lock() | |
| 752 | + | source.tabs = append(source.tabs, inactive) | |
| 753 | + | source.active = 0 | |
| 754 | + | source.mu.Unlock() | |
| 755 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 756 | + | ||
| 757 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 758 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 759 | + | TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 760 | + | }) | |
| 761 | + | require.True(t, result.OK, result.Text) | |
| 762 | + | ||
| 763 | + | active := source.tabs[0] | |
| 764 | + | active.mu.Lock() | |
| 765 | + | require.Len(t, active.panes, 1) | |
| 766 | + | require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID) | |
| 767 | + | active.mu.Unlock() | |
| 768 | + | destination.mu.Lock() | |
| 769 | + | destTab := destination.tabs[0] | |
| 770 | + | destination.mu.Unlock() | |
| 771 | + | destTab.mu.Lock() | |
| 772 | + | var moved *pane | |
| 773 | + | for _, candidate := range destTab.panes { | |
| 774 | + | if candidate.stableID == "p_inactive" { | |
| 775 | + | moved = candidate | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | require.NotNil(t, moved) | |
| 779 | + | destTab.mu.Unlock() | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |
| 783 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 784 | + | source := addControlSession(d, "work", "t_first", "p_first") | |
| 785 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 786 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 787 | + | publishTiledPaneOwners(source, second) | |
| 788 | + | source.mu.Lock() | |
| 789 | + | source.tabs = append(source.tabs, second) | |
| 790 | + | source.active = 1 | |
| 791 | + | source.mu.Unlock() | |
| 792 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 793 | + | ||
| 794 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 795 | + | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", | |
| 796 | + | }) | |
| 797 | + | require.True(t, result.OK, result.Text) | |
| 798 | + | ||
| 799 | + | source.mu.Lock() | |
| 800 | + | require.Len(t, source.tabs, 1) | |
| 801 | + | require.Equal(t, "t_first", source.tabs[0].stableID) | |
| 802 | + | source.mu.Unlock() | |
| 803 | + | destination.mu.Lock() | |
| 804 | + | require.Len(t, destination.tabs, 2) | |
| 805 | + | require.Equal(t, "t_second", destination.tabs[1].stableID) | |
| 806 | + | destination.mu.Unlock() | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) { | |
| 810 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 811 | + | source := addControlSession(d, "work", "t_first", "p_first") | |
| 812 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 813 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 814 | + | publishTiledPaneOwners(source, second) | |
| 815 | + | source.mu.Lock() | |
| 816 | + | source.tabs = append(source.tabs, second) | |
| 817 | + | source.active = 0 | |
| 818 | + | source.mu.Unlock() | |
| 819 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 820 | + | ||
| 821 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 822 | + | Slug: "move-tab", Args: []string{"dest"}, Self: true, | |
| 823 | + | TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second", | |
| 824 | + | }) | |
| 825 | + | require.True(t, result.OK, result.Text) | |
| 826 | + | ||
| 827 | + | source.mu.Lock() | |
| 828 | + | require.Len(t, source.tabs, 1) | |
| 829 | + | require.Equal(t, "t_first", source.tabs[0].stableID) | |
| 830 | + | source.mu.Unlock() | |
| 831 | + | destination.mu.Lock() | |
| 832 | + | require.Len(t, destination.tabs, 2) | |
| 833 | + | require.Equal(t, "t_second", destination.tabs[1].stableID) | |
| 834 | + | destination.mu.Unlock() | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) { | |
| 838 | + | tests := []struct { | |
| 839 | + | name string | |
| 840 | + | arrange func(*Daemon) ports.CommandRequest | |
| 841 | + | code uint16 | |
| 842 | + | text string | |
| 843 | + | }{ | |
| 844 | + | { | |
| 845 | + | name: "move-pane missing destination session", | |
| 846 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 847 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 848 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"} | |
| 849 | + | }, | |
| 850 | + | code: ports.ErrNoSuchTarget, | |
| 851 | + | text: "move request is invalid", | |
| 852 | + | }, | |
| 853 | + | { | |
| 854 | + | name: "move-pane unknown destination session", | |
| 855 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 856 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 857 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"} | |
| 858 | + | }, | |
| 859 | + | code: ports.ErrNoSuchTarget, | |
| 860 | + | text: "move target is no longer available", | |
| 861 | + | }, | |
| 862 | + | { | |
| 863 | + | name: "move-pane destination tab not in session", | |
| 864 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 865 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 866 | + | addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 867 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"} | |
| 868 | + | }, | |
| 869 | + | code: ports.ErrNoSuchTarget, | |
| 870 | + | text: "move target is no longer available", | |
| 871 | + | }, | |
| 872 | + | { | |
| 873 | + | name: "move-pane same source tab", | |
| 874 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 875 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 876 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"} | |
| 877 | + | }, | |
| 878 | + | code: ports.ErrNoSuchTarget, | |
| 879 | + | text: "move request is invalid", | |
| 880 | + | }, | |
| 881 | + | { | |
| 882 | + | name: "move-tab same session", | |
| 883 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 884 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 885 | + | return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"} | |
| 886 | + | }, | |
| 887 | + | code: ports.ErrNoSuchTarget, | |
| 888 | + | text: "move request is invalid", | |
| 889 | + | }, | |
| 890 | + | { | |
| 891 | + | name: "move-tab stopping destination", | |
| 892 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 893 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 894 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 895 | + | destination.teardownMu.Lock() | |
| 896 | + | destination.teardownActive = true | |
| 897 | + | destination.teardownMu.Unlock() | |
| 898 | + | return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"} | |
| 899 | + | }, | |
| 900 | + | code: ports.ErrNoSuchTarget, | |
| 901 | + | text: "move target is no longer available", | |
| 902 | + | }, | |
| 903 | + | { | |
| 904 | + | name: "move-pane too few args", | |
| 905 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 906 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 907 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"} | |
| 908 | + | }, | |
| 909 | + | code: ports.ErrInvalidCommandArgs, | |
| 910 | + | }, | |
| 911 | + | { | |
| 912 | + | name: "move-pane too many args", | |
| 913 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 914 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 915 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"} | |
| 916 | + | }, | |
| 917 | + | code: ports.ErrInvalidCommandArgs, | |
| 918 | + | }, | |
| 919 | + | { | |
| 920 | + | name: "move-tab missing destination", | |
| 921 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 922 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 923 | + | return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"} | |
| 924 | + | }, | |
| 925 | + | code: ports.ErrInvalidCommandArgs, | |
| 926 | + | }, | |
| 927 | + | { | |
| 928 | + | name: "explicit session name without IDs remains authoritative", | |
| 929 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 930 | + | addControlSession(d, "renamed", "t_work", "p_work") | |
| 931 | + | addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 932 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"} | |
| 933 | + | }, | |
| 934 | + | code: ports.ErrNoSuchTarget, | |
| 935 | + | text: "no such session: old-name", | |
| 936 | + | }, | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | for _, tt := range tests { | |
| 940 | + | t.Run(tt.name, func(t *testing.T) { | |
| 941 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 942 | + | request := tt.arrange(d) | |
| 943 | + | result := sendCommand(t, d, request) | |
| 944 | + | require.False(t, result.OK) | |
| 945 | + | require.Equal(t, tt.code, result.Code) | |
| 946 | + | if tt.text != "" { | |
| 947 | + | require.Equal(t, tt.text, result.Text) | |
| 948 | + | } | |
| 949 | + | }) | |
| 950 | + | } | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) { | |
| 954 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 955 | + | advisory := addControlSession(d, "old-name", "t_other", "p_other") | |
| 956 | + | source := addControlSession(d, "renamed", "t_work", "p_work") | |
| 957 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 958 | + | ||
| 959 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 960 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 961 | + | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |
| 962 | + | }) | |
| 963 | + | require.True(t, result.OK, result.Text) | |
| 964 | + | ||
| 965 | + | source.mu.Lock() | |
| 966 | + | require.Len(t, source.tabs, 0) | |
| 967 | + | source.mu.Unlock() | |
| 968 | + | advisory.mu.Lock() | |
| 969 | + | require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched") | |
| 970 | + | advisory.mu.Unlock() | |
| 971 | + | destination.mu.Lock() | |
| 972 | + | destTab := destination.tabs[0] | |
| 973 | + | destination.mu.Unlock() | |
| 974 | + | destTab.mu.Lock() | |
| 975 | + | require.Len(t, destTab.panes, 2) | |
| 976 | + | destTab.mu.Unlock() | |
| 977 | + | } | |
| 978 | + | ||
| 979 | + | func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) { | |
| 980 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 981 | + | source := addControlSession(d, "renamed", "t_active", "p_active") | |
| 982 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 983 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |
| 984 | + | publishTiledPaneOwners(source, inactive) | |
| 985 | + | source.mu.Lock() | |
| 986 | + | source.tabs = append(source.tabs, inactive) | |
| 987 | + | source.active = 1 | |
| 988 | + | source.mu.Unlock() | |
| 989 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 990 | + | ||
| 991 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 992 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, | |
| 993 | + | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 994 | + | }) | |
| 995 | + | require.True(t, result.OK, result.Text) | |
| 996 | + | ||
| 997 | + | source.mu.Lock() | |
| 998 | + | require.Len(t, source.tabs, 1) | |
| 999 | + | require.Equal(t, "t_active", source.tabs[0].stableID) | |
| 1000 | + | source.mu.Unlock() | |
| 1001 | + | destination.mu.Lock() | |
| 1002 | + | destTab := destination.tabs[0] | |
| 1003 | + | destination.mu.Unlock() | |
| 1004 | + | destTab.mu.Lock() | |
| 1005 | + | var moved *pane | |
| 1006 | + | for _, candidate := range destTab.panes { | |
| 1007 | + | if candidate.stableID == "p_inactive" { | |
| 1008 | + | moved = candidate | |
| 1009 | + | } | |
| 1010 | + | } | |
| 1011 | + | require.NotNil(t, moved) | |
| 1012 | + | destTab.mu.Unlock() | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |
| 1016 | + | d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 1017 | + | left.mu.Lock() | |
| 1018 | + | left.name = "left" | |
| 1019 | + | left.tabs[0].stableID = "left-moved" | |
| 1020 | + | left.active = 0 | |
| 1021 | + | left.mu.Unlock() | |
| 1022 | + | ||
| 1023 | + | right := addMoveTabTestSession(d, "right", "right-stays") | |
| 1024 | + | rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 1025 | + | rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx) | |
| 1026 | + | publishTiledPaneOwners(right, rightMoved) | |
| 1027 | + | right.mu.Lock() | |
| 1028 | + | right.tabs = append(right.tabs, rightMoved) | |
| 1029 | + | right.active = 1 | |
| 1030 | + | right.mu.Unlock() | |
| 1031 | + | ||
| 1032 | + | leftFrame := commandFrame(t, ports.CommandRequest{ | |
| 1033 | + | Slug: "move-tab", Args: []string{"right"}, TargetSession: "left", | |
| 1034 | + | }) | |
| 1035 | + | rightFrame := commandFrame(t, ports.CommandRequest{ | |
| 1036 | + | Slug: "move-tab", Args: []string{"left"}, TargetSession: "right", | |
| 1037 | + | }) | |
| 1038 | + | leftTransport, leftSends, _ := newConn(t, leftFrame) | |
| 1039 | + | rightTransport, rightSends, _ := newConn(t, rightFrame) | |
| 1040 | + | start := make(chan struct{}) | |
| 1041 | + | done := make(chan struct{}, 2) | |
| 1042 | + | go func() { | |
| 1043 | + | <-start | |
| 1044 | + | d.handleCommand(leftTransport, leftFrame) | |
| 1045 | + | done <- struct{}{} | |
| 1046 | + | }() | |
| 1047 | + | go func() { | |
| 1048 | + | <-start | |
| 1049 | + | d.handleCommand(rightTransport, rightFrame) | |
| 1050 | + | done <- struct{}{} | |
| 1051 | + | }() | |
| 1052 | + | close(start) | |
| 1053 | + | for _, sends := range []chan ports.Frame{leftSends, rightSends} { | |
| 1054 | + | select { | |
| 1055 | + | case <-done: | |
| 1056 | + | result := awaitCommandResult(t, sends) | |
| 1057 | + | require.True(t, result.OK, result.Text) | |
| 1058 | + | case <-time.After(5 * time.Second): | |
| 1059 | + | t.Fatal("opposite-direction move commands deadlocked") | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | } | |
| 1063 | + | ||
| 1064 | + | func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session { | |
| 1065 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 1066 | + | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 1067 | + | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 1068 | + | sess := &session{ | |
| 1069 | + | id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | |
| 1070 | + | ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |
| 1071 | + | } | |
| 1072 | + | publishTiledPaneOwners(sess, tb) | |
| 1073 | + | d.mu.Lock() | |
| 1074 | + | d.sessions[sess.id] = sess | |
| 1075 | + | d.mu.Unlock() | |
| 1076 | + | return sess | |
| 1077 | + | } | |
| 1078 | + | ||
| 710 | 1079 | func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame { | |
| 711 | 1080 | t.Helper() | |
| 712 | 1081 | if request.Version == 0 { | |
M internal/usecase/daemon/copymode_lifecycle_test.go +33 -0
| @@ -55,6 +55,39 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |||
| 55 | 55 | ac.overlays.copyMu.Unlock() | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) { | |
| 59 | + | for _, tt := range []struct { | |
| 60 | + | name string | |
| 61 | + | leaseBacked bool | |
| 62 | + | }{ | |
| 63 | + | {name: "direct", leaseBacked: false}, | |
| 64 | + | {name: "lease-backed", leaseBacked: true}, | |
| 65 | + | } { | |
| 66 | + | t.Run(tt.name, func(t *testing.T) { | |
| 67 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 68 | + | defer releaseAll(releases) | |
| 69 | + | rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock}) | |
| 70 | + | rc.attach(ac) | |
| 71 | + | sess.installRenderCoordinator(rc) | |
| 72 | + | defer func() { rc.beginSessionTeardown().finish() }() | |
| 73 | + | path := make(chan bool, 1) | |
| 74 | + | rc.opts.onActivateTabAfterResize = func(leaseBacked bool) { path <- leaseBacked } | |
| 75 | + | var lease *attachmentLease | |
| 76 | + | if tt.leaseBacked { | |
| 77 | + | lease = rc.attachmentLease(ac) | |
| 78 | + | require.NotNil(t, lease) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | d.enterCopyMode(sess, ac) | |
| 82 | + | require.True(t, ac.overlays.copyActive()) | |
| 83 | + | d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease) | |
| 84 | + | ||
| 85 | + | require.False(t, ac.overlays.copyActive()) | |
| 86 | + | require.Equal(t, tt.leaseBacked, <-path) | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 58 | 91 | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |
| 59 | 92 | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 60 | 93 | tb := sess.activeTab() | |
M internal/usecase/daemon/copymode_test.go +2 -1
| @@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||
| 845 | 845 | awaitFrame(t, sends, ports.MsgOutput) | |
| 846 | 846 | tb.mu.Lock() | |
| 847 | 847 | generation := tb.floating.generation | |
| 848 | + | publishPaneOwner(fp, sess, tb, generation) | |
| 848 | 849 | tb.mu.Unlock() | |
| 849 | 850 | ||
| 850 | - | d.reapFloating(sess, tb, fp, generation) | |
| 851 | + | d.reapInstalledFloating(fp) | |
| 851 | 852 | ||
| 852 | 853 | ac.overlays.copyMu.Lock() | |
| 853 | 854 | require.Nil(t, ac.overlays.copyMode) | |
M internal/usecase/daemon/daemon.go +221 -58
| @@ -85,6 +85,19 @@ type Daemon struct { | |||
| 85 | 85 | // under the same mutex — so a Hello racing shutdown can never insert a new | |
| 86 | 86 | // session that nobody would tear down. | |
| 87 | 87 | closing bool | |
| 88 | + | ||
| 89 | + | // moveLifecycleMu is the daemon-level admission gate for transferable | |
| 90 | + | // ownership changes. It is acquired before any session teardownMu, and is | |
| 91 | + | // never held while a move, teardown, or external operation runs. | |
| 92 | + | moveLifecycleMu sync.Mutex | |
| 93 | + | moveLifecycleClosing bool | |
| 94 | + | moveLifecycleActive uint | |
| 95 | + | moveLifecycleChanged chan struct{} | |
| 96 | + | // paneProcessCtx roots transferable pane processes outside any one session. | |
| 97 | + | // Shutdown cancels it only after the global move gate drains. | |
| 98 | + | paneProcessCtx context.Context | |
| 99 | + | paneProcessCancel context.CancelFunc | |
| 100 | + | ||
| 88 | 101 | // notifies holds one completion channel per in-flight async Detached | |
| 89 | 102 | // notification (guarded by mu, pruned on insert). Channels rather than a | |
| 90 | 103 | // WaitGroup: notifications are spawned from arbitrary goroutines while | |
| @@ -112,18 +125,84 @@ type Daemon struct { | |||
| 112 | 125 | // and before coordinator epoch admission. It is a deterministic regression | |
| 113 | 126 | // seam for stale resize publication. | |
| 114 | 127 | 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 | |
| 127 | 206 | // snapshotRepository is the sole checkpoint storage contract. | |
| 128 | 207 | snapshotRepository ports.SnapshotRepository | |
| 129 | 208 | recovery *recoveryusecase.Coordinator | |
| @@ -212,6 +291,7 @@ type Daemon struct { | |||
| 212 | 291 | type parkedAttachment struct { | |
| 213 | 292 | sess *session | |
| 214 | 293 | ac *attachedClient | |
| 294 | + | role attachmentRole | |
| 215 | 295 | timer ports.Timer | |
| 216 | 296 | done chan struct{} | |
| 217 | 297 | doneOnce sync.Once | |
| @@ -443,25 +523,28 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 443 | 523 | if shell == "" { | |
| 444 | 524 | shell = "/bin/sh" | |
| 445 | 525 | } | |
| 526 | + | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | |
| 446 | 527 | 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, | |
| 465 | 548 | barScripts: &barScriptState{ | |
| 466 | 549 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 467 | 550 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| @@ -635,14 +718,14 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) { | |||
| 635 | 718 | } | |
| 636 | 719 | ||
| 637 | 720 | func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | |
| 721 | + | d.closeMoveLifecycles() | |
| 638 | 722 | d.mu.Lock() | |
| 639 | 723 | d.closing = true | |
| 640 | - | for token, parked := range d.parked { | |
| 641 | - | d.removeParkedLocked(token, parked) | |
| 642 | - | } | |
| 724 | + | parkedRetirements := d.purgeAllParkedLocked() | |
| 643 | 725 | snapshot := d.sessionsSnapshotLocked() | |
| 644 | 726 | empty := len(snapshot) == 0 | |
| 645 | 727 | d.mu.Unlock() | |
| 728 | + | finishParkedAttachmentRetirements(parkedRetirements) | |
| 646 | 729 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | |
| 647 | 730 | if empty { | |
| 648 | 731 | d.doneOnce.Do(func() { close(d.done) }) | |
| @@ -656,7 +739,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 656 | 739 | ephemeral := s.ephemeral | |
| 657 | 740 | s.mu.Unlock() | |
| 658 | 741 | s.stopInMemoryLifecycle() | |
| 659 | - | if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil { | |
| 742 | + | if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil { | |
| 660 | 743 | checkpointIncomplete = true | |
| 661 | 744 | d.log.Error("closing session with unpersisted terminal state", "err", err) | |
| 662 | 745 | } | |
| @@ -918,21 +1001,64 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 918 | 1001 | lease = rc.attachmentLease(ac) | |
| 919 | 1002 | } | |
| 920 | 1003 | 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 | + | } | |
| 923 | 1017 | return | |
| 924 | 1018 | } | |
| 925 | - | if err := ac.sendExpectedTransport(expected, frameWelcome(sess, ac)); err != nil { | |
| 1019 | + | if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | |
| 1020 | + | welcomeTicket.End() | |
| 1021 | + | if welcomeToken.role == attachmentSnatched { | |
| 1022 | + | d.parkOrDropSnatchedAttachment(welcomeToken) | |
| 1023 | + | } else { | |
| 1024 | + | d.clientGone(sess, ac, tr, false) | |
| 1025 | + | } | |
| 1026 | + | return | |
| 1027 | + | } | |
| 1028 | + | // Welcome is the only effect allowed to retain the route-time role. Release | |
| 1029 | + | // it before discovering post-handshake authority so a replacement blocked | |
| 1030 | + | // behind the send can publish its generation and lease first. | |
| 1031 | + | welcomeTicket.End() | |
| 1032 | + | postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr) | |
| 1033 | + | if !admitted { | |
| 926 | 1034 | d.clientGone(sess, ac, tr, false) | |
| 927 | 1035 | return | |
| 928 | 1036 | } | |
| 929 | - | if !rc.markAttachmentReady(lease) { | |
| 1037 | + | if postWelcomeToken.role == attachmentSnatched { | |
| 1038 | + | if err := d.sendInitialSnatchedPanel(postWelcomeToken, postWelcomeTicket); err != nil { | |
| 1039 | + | postWelcomeTicket.End() | |
| 1040 | + | d.parkOrDropSnatchedAttachment(postWelcomeToken) | |
| 1041 | + | return | |
| 1042 | + | } | |
| 1043 | + | postWelcomeTicket.End() | |
| 1044 | + | d.runConnLoop(ac) | |
| 1045 | + | _ = tr.Close() | |
| 1046 | + | return | |
| 1047 | + | } | |
| 1048 | + | postWelcomeLease := postWelcomeToken.lease | |
| 1049 | + | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil || !rc.markAttachmentReady(postWelcomeLease) { | |
| 1050 | + | postWelcomeTicket.End() | |
| 930 | 1051 | // The attachment was displaced or detached while Welcome was in flight; | |
| 931 | 1052 | // never let this stale handshake emit an Output frame. | |
| 932 | 1053 | d.clientGone(sess, ac, tr, false) | |
| 933 | 1054 | return | |
| 934 | 1055 | } | |
| 935 | - | d.firstPaint(sess, ac, h.Size) | |
| 1056 | + | postWelcomeTicket.End() | |
| 1057 | + | paintToken := sess.attachmentToken(ac, tr) | |
| 1058 | + | if !d.firstPaintForTransition(paintToken) { | |
| 1059 | + | d.clientGone(sess, ac, tr, false) | |
| 1060 | + | return | |
| 1061 | + | } | |
| 936 | 1062 | d.runConnLoop(ac) | |
| 937 | 1063 | _ = tr.Close() | |
| 938 | 1064 | } | |
| @@ -945,24 +1071,42 @@ type protoErr struct { | |||
| 945 | 1071 | ||
| 946 | 1072 | func (e *protoErr) Error() string { return e.text } | |
| 947 | 1073 | ||
| 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) { | |
| 952 | 1080 | // Session state is the sole source for future PTY children. Update it before | |
| 953 | 1081 | // publishing the attachment; existing PTYs keep their original environment. | |
| 954 | 1082 | sess.mu.Lock() | |
| 955 | 1083 | sess.env = copyEnvironment(h.Env) | |
| 956 | 1084 | sess.terminal = term | |
| 957 | 1085 | sess.mu.Unlock() | |
| 958 | - | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | |
| 1086 | + | opts := attachClientOptions{ | |
| 959 | 1087 | clientID: h.ClientID, | |
| 960 | 1088 | resumeCapable: true, | |
| 961 | 1089 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 962 | - | }) | |
| 1090 | + | } | |
| 1091 | + | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 963 | 1092 | 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 | |
| 966 | 1110 | } | |
| 967 | 1111 | ||
| 968 | 1112 | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |
| @@ -1021,9 +1165,28 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1021 | 1165 | } | |
| 1022 | 1166 | term := terminalEnv{TrueColor: h.TrueColor} | |
| 1023 | 1167 | ||
| 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 | |
| 1027 | 1190 | } | |
| 1028 | 1191 | } | |
| 1029 | 1192 | if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach { | |
| @@ -1063,8 +1226,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1063 | 1226 | return nil, nil, err | |
| 1064 | 1227 | } | |
| 1065 | 1228 | } | |
| 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 | |
| 1068 | 1231 | ||
| 1069 | 1232 | case ports.IntentEphemeral: | |
| 1070 | 1233 | name := d.allocEphemeralNameLocked() | |
| @@ -1073,8 +1236,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1073 | 1236 | d.mu.Unlock() | |
| 1074 | 1237 | return nil, nil, err | |
| 1075 | 1238 | } | |
| 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 | |
| 1078 | 1241 | ||
| 1079 | 1242 | case ports.IntentNew: | |
| 1080 | 1243 | if h.Name == "" { | |
| @@ -1094,8 +1257,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1094 | 1257 | d.mu.Unlock() | |
| 1095 | 1258 | return nil, nil, err | |
| 1096 | 1259 | } | |
| 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 | |
| 1099 | 1262 | ||
| 1100 | 1263 | case ports.IntentAttach: | |
| 1101 | 1264 | sess := d.findByNameLocked(h.Name) | |
| @@ -1113,8 +1276,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1113 | 1276 | return nil, nil, err | |
| 1114 | 1277 | } | |
| 1115 | 1278 | } | |
| 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 | |
| 1118 | 1281 | ||
| 1119 | 1282 | default: | |
| 1120 | 1283 | d.mu.Unlock() | |
M internal/usecase/daemon/daemon_test.go +15 -2
| @@ -381,12 +381,22 @@ func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports. | |||
| 381 | 381 | ||
| 382 | 382 | func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 383 | 383 | t.Helper() | |
| 384 | - | return newManualSessionWithPTYsCleanup(t, true, ptys...) | |
| 384 | + | return newManualSessionWithPTYsClock(t, stubClock{}, ptys...) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func newManualSessionWithPTYsClock(t testing.TB, clock ports.Clock, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 388 | + | t.Helper() | |
| 389 | + | return newManualSessionWithPTYsClockCleanup(t, clock, true, ptys...) | |
| 385 | 390 | } | |
| 386 | 391 | ||
| 387 | 392 | func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 388 | 393 | t.Helper() | |
| 389 | - | d := newTestDaemonWithCleanup(t, nil, stubClock{}, registerCleanup) | |
| 394 | + | return newManualSessionWithPTYsClockCleanup(t, stubClock{}, registerCleanup, ptys...) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 398 | + | t.Helper() | |
| 399 | + | d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup) | |
| 390 | 400 | tr, sends := newCapturingTransport(t) | |
| 391 | 401 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 392 | 402 | ac.initOverlays() | |
| @@ -402,6 +412,9 @@ func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys .. | |||
| 402 | 412 | tabs = append(tabs, tb) | |
| 403 | 413 | } | |
| 404 | 414 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | |
| 415 | + | for _, tb := range tabs { | |
| 416 | + | publishTiledPaneOwners(sess, tb) | |
| 417 | + | } | |
| 405 | 418 | ac.setSession(sess) | |
| 406 | 419 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) | |
| 407 | 420 | d.sessions[sess.id] = sess | |
M internal/usecase/daemon/floating.go +98 -46
| @@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 { | |||
| 42 | 42 | return tb.floating.generation | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | // floatingTransferableLocked reports whether a tab's floating lifecycle can | |
| 46 | + | // cross an ownership commit. A warming Open remains registered to its source | |
| 47 | + | // session until installation or failure, so moves must reject only that state. | |
| 48 | + | // The caller must hold tb.mu. | |
| 49 | + | func (tb *tab) floatingTransferableLocked() bool { | |
| 50 | + | return tb != nil && tb.floating.state != floatingWarming | |
| 51 | + | } | |
| 52 | + | ||
| 45 | 53 | // toggleFloatingLocked applies the user action. Its result says whether a new | |
| 46 | 54 | // PTY launch is required and returns the generation associated with that launch | |
| 47 | 55 | // or current slot. | |
| @@ -160,15 +168,33 @@ func (d *Daemon) activateTab(sess *session, tb *tab) bool { | |||
| 160 | 168 | // activateTabAfterResize retains tab activation's warmup work while avoiding a | |
| 161 | 169 | // second resize when a synchronous outer resize request was already accepted. | |
| 162 | 170 | func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool { | |
| 171 | + | return d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, nil, nil) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // activateTabAfterResizeForLease keeps transition-owned resize effects bound to | |
| 175 | + | // the exact coordinator incarnation. A nil lease preserves direct/headless | |
| 176 | + | // activation behavior. | |
| 177 | + | func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerResizeAccepted bool, ac *attachedClient, lease *attachmentLease) bool { | |
| 163 | 178 | if d == nil || sess == nil || tb == nil { | |
| 164 | 179 | return false | |
| 165 | 180 | } | |
| 166 | 181 | // A headless session has no actual terminal destination. Deferring warmup | |
| 167 | 182 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| 168 | 183 | // 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 | + | } | |
| 172 | 198 | d.exitCopyMode(ac) | |
| 173 | 199 | if ac == nil { | |
| 174 | 200 | return false | |
| @@ -182,6 +208,9 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep | |||
| 182 | 208 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 183 | 209 | tb.mu.Unlock() | |
| 184 | 210 | if hasFloating { | |
| 211 | + | if lease != nil { | |
| 212 | + | return d.requestTransactionalResizeForLease(sess, ac, lease, size, true) | |
| 213 | + | } | |
| 185 | 214 | return d.requestTransactionalResize(sess, ac, size, true) | |
| 186 | 215 | } | |
| 187 | 216 | return false | |
| @@ -366,17 +395,22 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 366 | 395 | if err := spec.parentCtx.Err(); err != nil { | |
| 367 | 396 | return | |
| 368 | 397 | } | |
| 369 | - | openCtx, cancelOpen := context.WithCancel(spec.parentCtx) | |
| 370 | - | stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen) | |
| 398 | + | lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx) | |
| 371 | 399 | ownedByPane := false | |
| 372 | 400 | defer func() { | |
| 373 | 401 | if !ownedByPane { | |
| 374 | - | stopSessionCancel() | |
| 375 | - | cancelOpen() | |
| 402 | + | lifetime.abort() | |
| 376 | 403 | } | |
| 377 | 404 | }() | |
| 378 | - | pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size) | |
| 405 | + | pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.size) | |
| 379 | 406 | if err != nil { | |
| 407 | + | // Open retains ownership of a nonnil PTY only on success. Some factory | |
| 408 | + | // implementations can return a partially opened PTY with an error, so | |
| 409 | + | // release both resources before publishing the launch failure. | |
| 410 | + | lifetime.abort() | |
| 411 | + | if pty != nil { | |
| 412 | + | _ = pty.Close() | |
| 413 | + | } | |
| 380 | 414 | d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err) | |
| 381 | 415 | return | |
| 382 | 416 | } | |
| @@ -384,17 +418,17 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 384 | 418 | p.rect = spec.geometry.ptyRect() | |
| 385 | 419 | p.popupGeometry = spec.geometry | |
| 386 | 420 | 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 | |
| 393 | 425 | } | |
| 394 | 426 | ownedByPane = true | |
| 395 | 427 | // 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) } | |
| 398 | 432 | d.installFloating(sess, tb, p, generation) | |
| 399 | 433 | } | |
| 400 | 434 | ||
| @@ -423,8 +457,18 @@ func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, u | |||
| 423 | 457 | // installFloating installs only the current generation. A stale successful | |
| 424 | 458 | // launch is cancelled and closed outside the tab lock. | |
| 425 | 459 | func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) { | |
| 460 | + | if sess == nil || tb == nil || p == nil { | |
| 461 | + | closeFloatingPane(p) | |
| 462 | + | return | |
| 463 | + | } | |
| 426 | 464 | tb.mu.Lock() | |
| 427 | - | installed := tb.installFloatingLocked(p, generation) | |
| 465 | + | installable := tb.floating.state == floatingWarming && tb.floating.generation == generation | |
| 466 | + | if installable { | |
| 467 | + | // Publish under pane.mu before the slot becomes hidden or visible. The | |
| 468 | + | // tab-to-pane lock order also linearizes owner and slot publication. | |
| 469 | + | publishPaneOwner(p, sess, tb, generation) | |
| 470 | + | } | |
| 471 | + | installed := installable && tb.installFloatingLocked(p, generation) | |
| 428 | 472 | visible := installed && tb.floating.state == floatingVisible | |
| 429 | 473 | tb.mu.Unlock() | |
| 430 | 474 | if !installed { | |
| @@ -444,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 444 | 488 | } | |
| 445 | 489 | } | |
| 446 | 490 | ||
| 447 | - | // reapFloating ignores old readers and restores the background only when 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 { | |
| 455 | 498 | return | |
| 456 | 499 | } | |
| 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 | |
| 467 | 529 | } | |
| 468 | 530 | } | |
| 469 | 531 | ||
| @@ -485,17 +547,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) { | |||
| 485 | 547 | // closeFloatingPane releases a floating runtime at most once. It is called | |
| 486 | 548 | // only after a generation/identity check has detached the pane from its slot. | |
| 487 | 549 | 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) | |
| 499 | 551 | } | |
| 500 | 552 | ||
| 501 | 553 | func floatingCommandFallback(command, shell string) string { | |
M internal/usecase/daemon/floating_open_test.go +175 -7
| @@ -2,7 +2,9 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "io" | |
| 5 | 6 | "sync" | |
| 7 | + | "sync/atomic" | |
| 6 | 8 | "testing" | |
| 7 | 9 | "time" | |
| 8 | 10 | ||
| @@ -71,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st | |||
| 71 | 73 | ||
| 72 | 74 | func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |
| 73 | 75 | teardowns := []struct { | |
| 74 | - | name string | |
| 75 | - | teardown func(*Daemon, *session, *tab) | |
| 76 | + | name string | |
| 77 | + | desiredVisible bool | |
| 78 | + | teardown func(*Daemon, *session, *tab) | |
| 76 | 79 | }{ | |
| 77 | 80 | { | |
| 78 | - | name: "floating pane", | |
| 81 | + | name: "hidden floating pane", | |
| 79 | 82 | teardown: func(d *Daemon, _ *session, tb *tab) { | |
| 80 | 83 | d.teardownFloating(tb, nil) | |
| 81 | 84 | }, | |
| 82 | 85 | }, | |
| 83 | 86 | { | |
| 84 | - | name: "tab", | |
| 87 | + | name: "visible tab", | |
| 88 | + | desiredVisible: true, | |
| 85 | 89 | teardown: func(_ *Daemon, _ *session, tb *tab) { | |
| 86 | 90 | tb.closeAllPanes() | |
| 87 | 91 | }, | |
| 88 | 92 | }, | |
| 89 | 93 | { | |
| 90 | - | name: "session", | |
| 94 | + | name: "visible session", | |
| 95 | + | desiredVisible: true, | |
| 91 | 96 | teardown: func(d *Daemon, sess *session, _ *tab) { | |
| 92 | 97 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 93 | 98 | }, | |
| @@ -107,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 107 | 112 | d.sessions[sess.id] = sess | |
| 108 | 113 | d.mu.Unlock() | |
| 109 | 114 | tb.mu.Lock() | |
| 110 | - | generation := tb.beginFloatingWarmLocked(true) | |
| 115 | + | generation := tb.beginFloatingWarmLocked(tt.desiredVisible) | |
| 111 | 116 | tb.mu.Unlock() | |
| 112 | 117 | ||
| 113 | 118 | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | |
| @@ -127,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 127 | 132 | sess.mu.Lock() | |
| 128 | 133 | sess.client = &attachedClient{} | |
| 129 | 134 | sess.mu.Unlock() | |
| 130 | - | require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable") | |
| 135 | + | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") | |
| 131 | 136 | sess.mu.Lock() | |
| 132 | 137 | sess.client = nil | |
| 133 | 138 | sess.mu.Unlock() | |
| @@ -154,6 +159,169 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 154 | 159 | } | |
| 155 | 160 | } | |
| 156 | 161 | ||
| 162 | + | type failedOpenPTY struct { | |
| 163 | + | ctx context.Context | |
| 164 | + | readerStarts atomic.Int32 | |
| 165 | + | closes atomic.Int32 | |
| 166 | + | closed chan struct{} | |
| 167 | + | closeOnce sync.Once | |
| 168 | + | onClose func() | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func newFailedOpenPTY() *failedOpenPTY { | |
| 172 | + | return &failedOpenPTY{closed: make(chan struct{})} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (p *failedOpenPTY) Read([]byte) (int, error) { | |
| 176 | + | p.readerStarts.Add(1) | |
| 177 | + | select { | |
| 178 | + | case <-p.ctx.Done(): | |
| 179 | + | return 0, p.ctx.Err() | |
| 180 | + | case <-p.closed: | |
| 181 | + | return 0, io.EOF | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 186 | + | func (*failedOpenPTY) Resize(domain.Size) error { return nil } | |
| 187 | + | func (*failedOpenPTY) Pid() int { return 1 } | |
| 188 | + | func (*failedOpenPTY) ForegroundPgid() (int, error) { | |
| 189 | + | return 1, nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (p *failedOpenPTY) Close() error { | |
| 193 | + | p.closes.Add(1) | |
| 194 | + | if p.onClose != nil { | |
| 195 | + | p.onClose() | |
| 196 | + | } | |
| 197 | + | p.closeOnce.Do(func() { close(p.closed) }) | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.T) { | |
| 202 | + | tests := []struct { | |
| 203 | + | name string | |
| 204 | + | userOpen bool | |
| 205 | + | }{ | |
| 206 | + | {name: "user open", userOpen: true}, | |
| 207 | + | {name: "background prewarm", userOpen: false}, | |
| 208 | + | } | |
| 209 | + | for _, tt := range tests { | |
| 210 | + | t.Run(tt.name, func(t *testing.T) { | |
| 211 | + | cause := io.ErrUnexpectedEOF | |
| 212 | + | pty := newFailedOpenPTY() | |
| 213 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 214 | + | cancelCallbacks := atomic.Int32{} | |
| 215 | + | cancelled := make(chan struct{}) | |
| 216 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 217 | + | func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 218 | + | pty.ctx = ctx | |
| 219 | + | context.AfterFunc(ctx, func() { | |
| 220 | + | cancelCallbacks.Add(1) | |
| 221 | + | close(cancelled) | |
| 222 | + | }) | |
| 223 | + | return pty, cause | |
| 224 | + | }).Once() | |
| 225 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 226 | + | tb := newFloatingTestTab(t) | |
| 227 | + | tr, _ := newCapturingTransport(t) | |
| 228 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 229 | + | ac.initOverlays() | |
| 230 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 231 | + | sess := &session{id: "floating-open-error", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession, client: ac} | |
| 232 | + | ac.setSession(sess) | |
| 233 | + | ||
| 234 | + | // Close must be safe to run before failure publication and without | |
| 235 | + | // either ownership lock held. | |
| 236 | + | var unpublishedAtClose atomic.Bool | |
| 237 | + | pty.onClose = func() { | |
| 238 | + | tb.mu.Lock() | |
| 239 | + | launchUnpublished := tb.floating.pane == nil && tb.floating.state == floatingWarming | |
| 240 | + | tb.mu.Unlock() | |
| 241 | + | sess.mu.Lock() | |
| 242 | + | sessionName := sess.name | |
| 243 | + | sess.mu.Unlock() | |
| 244 | + | unpublishedAtClose.Store(launchUnpublished && sessionName == "work") | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | d.startFloating(sess, tb, tt.userOpen) | |
| 248 | + | d.sessWg.Wait() | |
| 249 | + | select { | |
| 250 | + | case <-cancelled: | |
| 251 | + | case <-time.After(time.Second): | |
| 252 | + | t.Fatal("failed floating Open process context was not cancelled") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // Exercise later cancellation/stale cleanup paths; neither may find | |
| 256 | + | // ownership to release a second time. | |
| 257 | + | cancelSession() | |
| 258 | + | d.teardownFloating(tb, ac) | |
| 259 | + | require.Equal(t, int32(1), cancelCallbacks.Load(), "process context must be released exactly once") | |
| 260 | + | require.Equal(t, int32(1), pty.closes.Load(), "returned PTY must be closed exactly once") | |
| 261 | + | require.True(t, unpublishedAtClose.Load(), "PTY must close before any failure publication") | |
| 262 | + | require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader") | |
| 263 | + | ||
| 264 | + | tb.mu.Lock() | |
| 265 | + | floating := tb.floating.pane | |
| 266 | + | publishedFailedPTY := false | |
| 267 | + | for _, published := range tb.panes { | |
| 268 | + | publishedFailedPTY = publishedFailedPTY || published.pty == pty | |
| 269 | + | } | |
| 270 | + | tb.mu.Unlock() | |
| 271 | + | require.Nil(t, floating, "failed Open must not publish the floating slot") | |
| 272 | + | require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner") | |
| 273 | + | if tt.userOpen { | |
| 274 | + | toasts := awaitToastCount(t, ac, 1) | |
| 275 | + | require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code) | |
| 276 | + | require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message) | |
| 277 | + | } else { | |
| 278 | + | toasts, _ := visibleToasts(ac) | |
| 279 | + | require.Empty(t, toasts, "background prewarm failure must stay silent") | |
| 280 | + | } | |
| 281 | + | }) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func TestInstallFloatingRejectsNilPublicationInputs(t *testing.T) { | |
| 286 | + | tests := []struct { | |
| 287 | + | name string | |
| 288 | + | nilSession bool | |
| 289 | + | nilTab bool | |
| 290 | + | nilPane bool | |
| 291 | + | wantCloses int | |
| 292 | + | }{ | |
| 293 | + | {name: "nil session", nilSession: true, wantCloses: 1}, | |
| 294 | + | {name: "nil tab", nilTab: true, wantCloses: 1}, | |
| 295 | + | {name: "nil pane", nilPane: true}, | |
| 296 | + | {name: "all nil", nilSession: true, nilTab: true, nilPane: true}, | |
| 297 | + | } | |
| 298 | + | for _, tt := range tests { | |
| 299 | + | t.Run(tt.name, func(t *testing.T) { | |
| 300 | + | d := &Daemon{} | |
| 301 | + | sess := &session{} | |
| 302 | + | tb := newFloatingTestTab(t) | |
| 303 | + | tb.mu.Lock() | |
| 304 | + | generation := tb.beginFloatingWarmLocked(true) | |
| 305 | + | tb.mu.Unlock() | |
| 306 | + | pty := newContextAwareFloatingPTY() | |
| 307 | + | p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 8}) | |
| 308 | + | ||
| 309 | + | if tt.nilSession { | |
| 310 | + | sess = nil | |
| 311 | + | } | |
| 312 | + | if tt.nilTab { | |
| 313 | + | tb = nil | |
| 314 | + | } | |
| 315 | + | if tt.nilPane { | |
| 316 | + | p = nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | require.NotPanics(t, func() { d.installFloating(sess, tb, p, generation) }) | |
| 320 | + | require.Equal(t, tt.wantCloses, pty.closeCount) | |
| 321 | + | }) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 157 | 325 | func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) { | |
| 158 | 326 | tests := []struct { | |
| 159 | 327 | name string | |
M internal/usecase/daemon/floating_test.go +85 -5
| @@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) { | |||
| 131 | 131 | } | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | + | func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) { | |
| 135 | + | tests := []struct { | |
| 136 | + | name string | |
| 137 | + | state floatingState | |
| 138 | + | want bool | |
| 139 | + | }{ | |
| 140 | + | {name: "uninitialized", state: floatingUninitialized, want: true}, | |
| 141 | + | {name: "warming", state: floatingWarming, want: false}, | |
| 142 | + | {name: "hidden installed", state: floatingHidden, want: true}, | |
| 143 | + | {name: "visible installed", state: floatingVisible, want: true}, | |
| 144 | + | } | |
| 145 | + | for _, tt := range tests { | |
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |
| 147 | + | tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}} | |
| 148 | + | tb.mu.Lock() | |
| 149 | + | got := tb.floatingTransferableLocked() | |
| 150 | + | tb.mu.Unlock() | |
| 151 | + | require.Equal(t, tt.want, got) | |
| 152 | + | require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state") | |
| 153 | + | require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation") | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 134 | 158 | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | |
| 135 | 159 | pty := portsmocks.NewMockPTY(t) | |
| 136 | 160 | readerStarted := make(chan struct{}) | |
| @@ -337,28 +361,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test | |||
| 337 | 361 | second := &pane{} | |
| 338 | 362 | tb.mu.Lock() | |
| 339 | 363 | g1 := tb.beginFloatingWarmLocked(true) | |
| 364 | + | publishPaneOwner(first, sess, tb, g1) | |
| 340 | 365 | 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) | |
| 343 | 368 | generation := tb.beginFloatingWarmLocked(true) | |
| 344 | 369 | require.True(t, tb.installFloatingLocked(second, generation)) | |
| 345 | 370 | tb.mu.Unlock() | |
| 346 | - | d.reapFloating(sess, tb, first, g1) | |
| 371 | + | d.reapInstalledFloating(first) | |
| 347 | 372 | tb.mu.Lock() | |
| 348 | 373 | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | |
| 349 | 374 | tb.mu.Unlock() | |
| 350 | 375 | } | |
| 351 | 376 | ||
| 377 | + | func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |
| 378 | + | factory := &lifetimePTYFactory{} | |
| 379 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 380 | + | tb := newFloatingTestTab(t) | |
| 381 | + | source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()} | |
| 382 | + | destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()} | |
| 383 | + | ||
| 384 | + | tb.mu.Lock() | |
| 385 | + | generation := tb.beginFloatingWarmLocked(false) | |
| 386 | + | tb.mu.Unlock() | |
| 387 | + | d.openAndInstallFloating(source, tb, floatingLaunchSpec{ | |
| 388 | + | sessionName: source.name, | |
| 389 | + | size: domain.Size{Cols: 20, Rows: 8}, | |
| 390 | + | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |
| 391 | + | paneStableID: "p_floating", | |
| 392 | + | parentCtx: tb.ctx, | |
| 393 | + | }, generation) | |
| 394 | + | ||
| 395 | + | tb.mu.Lock() | |
| 396 | + | floating := tb.floating.pane | |
| 397 | + | tb.mu.Unlock() | |
| 398 | + | require.NotNil(t, floating) | |
| 399 | + | tb.mu.Lock() | |
| 400 | + | floating.mu.Lock() | |
| 401 | + | floating.publishOwnerLocked(destination, tb, generation) | |
| 402 | + | floating.mu.Unlock() | |
| 403 | + | tb.mu.Unlock() | |
| 404 | + | ||
| 405 | + | sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |
| 406 | + | sourceClient.initOverlays() | |
| 407 | + | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |
| 408 | + | destinationClient.initOverlays() | |
| 409 | + | source.client = sourceClient | |
| 410 | + | destination.client = destinationClient | |
| 411 | + | ||
| 412 | + | floating.onExit() | |
| 413 | + | d.sessWg.Wait() | |
| 414 | + | ||
| 415 | + | tb.mu.Lock() | |
| 416 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |
| 417 | + | require.Nil(t, tb.floating.pane) | |
| 418 | + | tb.mu.Unlock() | |
| 419 | + | sourceClient.sendMu.Lock() | |
| 420 | + | _, sourceRetained := sourceClient.captureFrames[floating] | |
| 421 | + | sourceClient.sendMu.Unlock() | |
| 422 | + | destinationClient.sendMu.Lock() | |
| 423 | + | _, destinationRetained := destinationClient.captureFrames[floating] | |
| 424 | + | destinationClient.sendMu.Unlock() | |
| 425 | + | require.True(t, sourceRetained, "exit must not clean up the retired source owner") | |
| 426 | + | require.False(t, destinationRetained, "exit must clean up the current destination owner") | |
| 427 | + | } | |
| 428 | + | ||
| 352 | 429 | func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) { | |
| 353 | 430 | pty, _ := newBlockingPTY(t) | |
| 354 | 431 | tb := newFloatingTestTab(t) | |
| 355 | 432 | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |
| 433 | + | sess := &session{} | |
| 356 | 434 | tb.mu.Lock() | |
| 357 | 435 | g := tb.beginFloatingWarmLocked(true) | |
| 436 | + | publishPaneOwner(p, sess, tb, g) | |
| 358 | 437 | require.True(t, tb.installFloatingLocked(p, g)) | |
| 359 | 438 | tb.mu.Unlock() | |
| 360 | 439 | d := newTestDaemon(t, nil, stubClock{}) | |
| 361 | - | d.reapFloating(&session{}, tb, p, g) | |
| 440 | + | d.reapInstalledFloating(p) | |
| 362 | 441 | d.teardownFloating(tb, nil) | |
| 363 | 442 | pty.AssertNumberOfCalls(t, "Close", 1) | |
| 364 | 443 | } | |
| @@ -423,6 +502,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 423 | 502 | }).Once() | |
| 424 | 503 | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 425 | 504 | generation := tb.beginFloatingWarmLocked(state == floatingVisible) | |
| 505 | + | publishPaneOwner(floating, sess, tb, generation) | |
| 426 | 506 | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 427 | 507 | if state == floatingHidden { | |
| 428 | 508 | require.Equal(t, floatingHidden, tb.floating.state) | |
| @@ -439,7 +519,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 439 | 519 | ||
| 440 | 520 | reaped := make(chan struct{}) | |
| 441 | 521 | floating.onExit = func() { | |
| 442 | - | d.reapFloating(sess, tb, floating, generation) | |
| 522 | + | d.reapInstalledFloating(floating) | |
| 443 | 523 | close(reaped) | |
| 444 | 524 | } | |
| 445 | 525 | d.sessWg.Add(1) | |
M internal/usecase/daemon/input.go +86 -10
| @@ -20,6 +20,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6 | |||
| 20 | 20 | d.handleInput(sess, ac, data) | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) { | |
| 24 | + | if !token.activeEffect() { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | d.handleInputForRole(token, data) | |
| 28 | + | } | |
| 29 | + | ||
| 23 | 30 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| 24 | 31 | ac.initOverlays() | |
| 25 | 32 | ac.mouseScan.Scan(data, | |
| @@ -33,6 +40,30 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 33 | 40 | ) | |
| 34 | 41 | } | |
| 35 | 42 | ||
| 43 | + | func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) { | |
| 44 | + | ac := token.ac | |
| 45 | + | ac.initOverlays() | |
| 46 | + | ac.mouseScan.Scan(data, | |
| 47 | + | func(ev mouse.Event) { | |
| 48 | + | if token.activeEffect() { | |
| 49 | + | d.handleMouse(ac, ev) | |
| 50 | + | } | |
| 51 | + | }, | |
| 52 | + | func(b []byte) { | |
| 53 | + | if !token.activeEffect() { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | if ac.overlays.HandleInput(d, b, token.effect) { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | if !token.activeEffect() { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token}) | |
| 63 | + | }, | |
| 64 | + | ) | |
| 65 | + | } | |
| 66 | + | ||
| 36 | 67 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 37 | 68 | frameEvent := ev | |
| 38 | 69 | ac.initOverlays() | |
| @@ -267,16 +298,49 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||
| 267 | 298 | } | |
| 268 | 299 | ||
| 269 | 300 | type daemonKeyHandler struct { | |
| 270 | - | d *Daemon | |
| 271 | - | ac *attachedClient | |
| 272 | - | actions daemonActionRunner | |
| 301 | + | d *Daemon | |
| 302 | + | ac *attachedClient | |
| 303 | + | actions daemonActionRunner | |
| 304 | + | roleToken attachmentRoleToken | |
| 273 | 305 | } | |
| 274 | 306 | ||
| 275 | - | func (h daemonKeyHandler) Forward(data []byte) { | |
| 307 | + | // acquireRoleEffect preserves a synchronous frame's existing admission and | |
| 308 | + | // gives delayed router callbacks a fresh ticket for the exact captured role. | |
| 309 | + | // Direct/headless callers without a role token retain their existing behavior. | |
| 310 | + | func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) { | |
| 311 | + | if h.roleToken.ac != nil { | |
| 312 | + | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | |
| 313 | + | return h.roleToken.sess, effect, false | |
| 314 | + | } | |
| 315 | + | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | |
| 316 | + | if h.d.afterDelayedKeyEffectAttempt != nil { | |
| 317 | + | h.d.afterDelayedKeyEffectAttempt(admitted) | |
| 318 | + | } | |
| 319 | + | if !admitted { | |
| 320 | + | return nil, nil, false | |
| 321 | + | } | |
| 322 | + | if h.d.afterRoleEffectAdmitted != nil { | |
| 323 | + | token := h.roleToken | |
| 324 | + | token.effect = effect | |
| 325 | + | h.d.afterRoleEffectAdmitted(token) | |
| 326 | + | } | |
| 327 | + | return h.roleToken.sess, effect, true | |
| 328 | + | } | |
| 276 | 329 | sess := h.ac.currentSession() | |
| 330 | + | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | |
| 331 | + | return nil, nil, false | |
| 332 | + | } | |
| 333 | + | return sess, nil, false | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (h daemonKeyHandler) Forward(data []byte) { | |
| 337 | + | sess, effect, owned := h.acquireRoleEffect() | |
| 277 | 338 | if sess == nil { | |
| 278 | 339 | return | |
| 279 | 340 | } | |
| 341 | + | if owned { | |
| 342 | + | defer effect.End() | |
| 343 | + | } | |
| 280 | 344 | tb := sess.activeTab() | |
| 281 | 345 | if tb == nil { | |
| 282 | 346 | return | |
| @@ -288,10 +352,13 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 288 | 352 | } | |
| 289 | 353 | ||
| 290 | 354 | func (h daemonKeyHandler) Action(action keys.Action) { | |
| 291 | - | sess := h.ac.currentSession() | |
| 355 | + | sess, effect, owned := h.acquireRoleEffect() | |
| 292 | 356 | if sess == nil { | |
| 293 | 357 | return | |
| 294 | 358 | } | |
| 359 | + | if owned { | |
| 360 | + | defer effect.End() | |
| 361 | + | } | |
| 295 | 362 | runAction := func(request daemonActionRequest) { | |
| 296 | 363 | request.target = resolveDaemonActionTarget(sess) | |
| 297 | 364 | runner := h.actions | |
| @@ -317,7 +384,16 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 317 | 384 | case keys.ActionOpenPalette: | |
| 318 | 385 | h.d.enterPalette(sess, h.ac) | |
| 319 | 386 | case keys.ActionJumpAttention: | |
| 320 | - | if err := h.d.jumpAttention(sess, h.ac); err != nil { | |
| 387 | + | if effect == nil { | |
| 388 | + | if err := h.d.jumpAttention(sess, h.ac); err != nil { | |
| 389 | + | h.d.reportError(sess, err) | |
| 390 | + | } | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | effect.bindActionEnd(h.d, "jump-attention") | |
| 394 | + | token := h.roleToken | |
| 395 | + | token.effect = effect | |
| 396 | + | if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil { | |
| 321 | 397 | h.d.reportError(sess, err) | |
| 322 | 398 | } | |
| 323 | 399 | case keys.ActionToggleFloatingPane: | |
| @@ -326,19 +402,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 326 | 402 | h.d.reportError(sess, err) | |
| 327 | 403 | } | |
| 328 | 404 | case keys.ActionFocusPaneLeft: | |
| 329 | - | if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil { | |
| 405 | + | if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | |
| 330 | 406 | h.d.reportError(sess, err) | |
| 331 | 407 | } | |
| 332 | 408 | case keys.ActionFocusPaneRight: | |
| 333 | - | if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil { | |
| 409 | + | if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | |
| 334 | 410 | h.d.reportError(sess, err) | |
| 335 | 411 | } | |
| 336 | 412 | case keys.ActionFocusPaneUp: | |
| 337 | - | if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil { | |
| 413 | + | if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | |
| 338 | 414 | h.d.reportError(sess, err) | |
| 339 | 415 | } | |
| 340 | 416 | case keys.ActionFocusPaneDown: | |
| 341 | - | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | |
| 417 | + | if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | |
| 342 | 418 | h.d.reportError(sess, err) | |
| 343 | 419 | } | |
| 344 | 420 | case keys.ActionGrowPaneWidth: | |
M internal/usecase/daemon/layout_mutation.go +9 -9
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "slices" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | 8 | ) | |
| @@ -31,14 +33,7 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan | |||
| 31 | 33 | return false, layout.ErrNotFound | |
| 32 | 34 | } | |
| 33 | 35 | 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) { | |
| 42 | 37 | target.session.mu.Unlock() | |
| 43 | 38 | d.mu.Unlock() | |
| 44 | 39 | return false, layout.ErrNotFound | |
| @@ -74,7 +69,12 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan | |||
| 74 | 69 | d.mu.Unlock() | |
| 75 | 70 | ||
| 76 | 71 | 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 | + | } | |
| 78 | 78 | } | |
| 79 | 79 | return true, nil | |
| 80 | 80 | } | |
A internal/usecase/daemon/move.go +330 -0
| @@ -0,0 +1,330 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // movePaneRequest identifies both lifecycles and both topology objects by | |
| 10 | + | // immutable identity. Session names are advisory; the ID and incarnation are | |
| 11 | + | // the commit-time authority. | |
| 12 | + | type movePaneRequest struct { | |
| 13 | + | Source moveSessionLocator | |
| 14 | + | SourceTabID domain.TabStableID | |
| 15 | + | SourcePaneID domain.PaneStableID | |
| 16 | + | Destination moveSessionLocator | |
| 17 | + | DestinationTabID domain.TabStableID | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type moveTabRequest struct { | |
| 21 | + | Source moveSessionLocator | |
| 22 | + | SourceTabID domain.TabStableID | |
| 23 | + | Destination moveSessionLocator | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // movePane resolves, reserves, validates, and commits one pane relocation. All | |
| 27 | + | // fallible work happens before the membership publication section. Once the | |
| 28 | + | // section starts, the caller holds the ordered architecture locks and only | |
| 29 | + | // non-failing in-memory writes are permitted. | |
| 30 | + | func (d *Daemon) movePane(req movePaneRequest) (result error) { | |
| 31 | + | defer func() { result = normalizeMoveRejection(result) }() | |
| 32 | + | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || | |
| 33 | + | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | |
| 34 | + | return errMovePaneInvalid | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | d.mu.Lock() | |
| 38 | + | source := moveSessionForLocatorLocked(d, req.Source) | |
| 39 | + | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 40 | + | d.mu.Unlock() | |
| 41 | + | if source == nil || destination == nil { | |
| 42 | + | return errMoveStaleTarget | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 46 | + | if err != nil { | |
| 47 | + | return errMoveStaleTarget | |
| 48 | + | } | |
| 49 | + | reservationHeld := true | |
| 50 | + | defer func() { | |
| 51 | + | if reservationHeld { | |
| 52 | + | reservation.Release() | |
| 53 | + | } | |
| 54 | + | }() | |
| 55 | + | if d.afterMoveLifecycleReserved != nil { | |
| 56 | + | d.afterMoveLifecycleReserved() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | unlockDispatch := lockMoveDispatch(source, destination) | |
| 60 | + | dispatchHeld := true | |
| 61 | + | defer func() { | |
| 62 | + | if dispatchHeld { | |
| 63 | + | unlockDispatch() | |
| 64 | + | } | |
| 65 | + | }() | |
| 66 | + | ||
| 67 | + | // Snapshot the exact live objects and generations before waiting on resize | |
| 68 | + | // fences. The locked commit repeats every authority check before publication. | |
| 69 | + | admission, err := d.snapshotMovePaneAdmission(req, source, destination) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | sourceTab := admission.sourceTab | |
| 74 | + | destinationTab := admission.destinationTab | |
| 75 | + | movedPane := admission.movedPane | |
| 76 | + | sourceClient := admission.sourceClient | |
| 77 | + | destinationClient := admission.destinationClient | |
| 78 | + | sourceSnatched := admission.sourceSnatched | |
| 79 | + | sourceTabWasActive := admission.sourceTabWasActive | |
| 80 | + | sourceTabInitialGeneration := admission.sourceGeneration | |
| 81 | + | destinationTabInitialGeneration := admission.destinationGeneration | |
| 82 | + | finalSourceTab := admission.finalSourceTab | |
| 83 | + | if d.afterMovePaneSourceSnapshot != nil { | |
| 84 | + | d.afterMovePaneSourceSnapshot() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // A final source tab with an active client needs the centralized attachment | |
| 88 | + | // transition. Freeze that exact role set before entering the fence callback; | |
| 89 | + | // the callback then validates the handoff before changing any topology. | |
| 90 | + | var handoffReq attachmentTransitionRequest | |
| 91 | + | var handoffParticipants attachmentTransitionParticipants | |
| 92 | + | var frozen frozenRoleEffectGates | |
| 93 | + | var sourceRolesFrozen bool | |
| 94 | + | var handoffFrozen bool | |
| 95 | + | ||
| 96 | + | if finalSourceTab && source != destination && sourceClient != nil { | |
| 97 | + | transport := sourceClient.transportSnapshot() | |
| 98 | + | sourceToken := source.attachmentToken(sourceClient, transport.transport) | |
| 99 | + | if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil { | |
| 100 | + | return errMovePaneInvalid | |
| 101 | + | } | |
| 102 | + | destinationTabIndex := moveTabIndex(destination, destinationTab) | |
| 103 | + | if destinationTabIndex < 0 { | |
| 104 | + | return errMovePaneInvalid | |
| 105 | + | } | |
| 106 | + | handoffReq = attachmentTransitionRequest{ | |
| 107 | + | source: source, target: destination, next: sourceClient, | |
| 108 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 109 | + | expectedTransport: sourceToken.transport, sourceToken: &sourceToken, | |
| 110 | + | expectedSourceTab: sourceTab, activateTargetTab: true, | |
| 111 | + | targetTabIndex: destinationTabIndex, ready: true, | |
| 112 | + | } | |
| 113 | + | var err error | |
| 114 | + | handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq) | |
| 115 | + | if err != nil { | |
| 116 | + | return err | |
| 117 | + | } | |
| 118 | + | // Retiring source snatched clients is part of the same role gate set as | |
| 119 | + | // the follower. Their exact membership is checked again in the commit. | |
| 120 | + | d.mu.Lock() | |
| 121 | + | source.mu.Lock() | |
| 122 | + | for _, ac := range sourceSnatched { | |
| 123 | + | handoffParticipants.clients = append(handoffParticipants.clients, ac) | |
| 124 | + | tr := ac.transportSnapshot() | |
| 125 | + | if tr.transport != nil { | |
| 126 | + | handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | source.mu.Unlock() | |
| 130 | + | d.mu.Unlock() | |
| 131 | + | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 132 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 133 | + | d.afterRoleEffectGateFrozen("move-pane", ac) | |
| 134 | + | } | |
| 135 | + | }}, handoffParticipants.clients...) | |
| 136 | + | if !frozen.acquired || !frozen.drained { | |
| 137 | + | frozen.unfreeze() | |
| 138 | + | return errMovePaneInvalid | |
| 139 | + | } | |
| 140 | + | handoffFrozen = true | |
| 141 | + | sourceRolesFrozen = true | |
| 142 | + | handoffReq.roleEffectsFrozen = true | |
| 143 | + | } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 { | |
| 144 | + | // A headless source may still have persistent snatched clients. Freeze | |
| 145 | + | // their exact role gates before retiring the source registry. | |
| 146 | + | d.mu.Lock() | |
| 147 | + | source.mu.Lock() | |
| 148 | + | participants := append([]*attachedClient(nil), sourceSnatched...) | |
| 149 | + | interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched)) | |
| 150 | + | for _, ac := range sourceSnatched { | |
| 151 | + | if transport := ac.transportSnapshot(); transport.transport != nil { | |
| 152 | + | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | source.mu.Unlock() | |
| 156 | + | d.mu.Unlock() | |
| 157 | + | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 158 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 159 | + | d.afterRoleEffectGateFrozen("move-pane", ac) | |
| 160 | + | } | |
| 161 | + | }}, participants...) | |
| 162 | + | if !frozen.acquired || !frozen.drained { | |
| 163 | + | frozen.unfreeze() | |
| 164 | + | return errMovePaneInvalid | |
| 165 | + | } | |
| 166 | + | sourceRolesFrozen = true | |
| 167 | + | } | |
| 168 | + | if sourceRolesFrozen { | |
| 169 | + | defer func() { | |
| 170 | + | if sourceRolesFrozen { | |
| 171 | + | frozen.unfreeze() | |
| 172 | + | } | |
| 173 | + | }() | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | commit := movePaneCommit{ | |
| 177 | + | req: req, | |
| 178 | + | source: source, | |
| 179 | + | destination: destination, | |
| 180 | + | sourceTab: sourceTab, | |
| 181 | + | destinationTab: destinationTab, | |
| 182 | + | movedPane: movedPane, | |
| 183 | + | sourceClient: sourceClient, | |
| 184 | + | sourceSnatched: sourceSnatched, | |
| 185 | + | sourceGeneration: sourceTabInitialGeneration, | |
| 186 | + | destinationGeneration: destinationTabInitialGeneration, | |
| 187 | + | handoffFrozen: handoffFrozen, | |
| 188 | + | frozenRoles: frozen, | |
| 189 | + | handoffReq: handoffReq, | |
| 190 | + | } | |
| 191 | + | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | |
| 192 | + | if !fences.acquire(func() bool { return commit.publishLocked(d) }) { | |
| 193 | + | commit.releasePublication() | |
| 194 | + | if commit.err != nil { | |
| 195 | + | return commit.err | |
| 196 | + | } | |
| 197 | + | return errMoveStaleTarget | |
| 198 | + | } | |
| 199 | + | // Owner/layout fences end at the atomic publication boundary. The immutable | |
| 200 | + | // postcommit plan owns every later cleanup and effect. | |
| 201 | + | fences.Release() | |
| 202 | + | postcommit := movePanePostcommitPlan{ | |
| 203 | + | source: source, | |
| 204 | + | destination: destination, | |
| 205 | + | sourceTab: sourceTab, | |
| 206 | + | destinationTab: destinationTab, | |
| 207 | + | movedPane: movedPane, | |
| 208 | + | destinationClient: destinationClient, | |
| 209 | + | sourceCleanupToken: commit.sourceCleanupToken, | |
| 210 | + | handoffResult: commit.handoffResult, | |
| 211 | + | syncCleanup: commit.syncCleanup, | |
| 212 | + | frozenRoles: frozen, | |
| 213 | + | rolesFrozen: sourceRolesFrozen, | |
| 214 | + | unlockDispatch: unlockDispatch, | |
| 215 | + | reservation: reservation, | |
| 216 | + | sourceTabWasActive: sourceTabWasActive, | |
| 217 | + | sourceTabRemoved: commit.sourceTabRemoved, | |
| 218 | + | sourceEmpty: commit.sourceEmpty, | |
| 219 | + | retiredParked: commit.retiredParked, | |
| 220 | + | retiredAttachments: commit.retiredAttachments, | |
| 221 | + | sourceMetadata: commit.sourceMetadata, | |
| 222 | + | sourceMetadataValid: commit.sourceMetadataValid, | |
| 223 | + | destinationMetadata: commit.destinationMetadata, | |
| 224 | + | destinationMetadataValid: commit.destinationMetadataValid, | |
| 225 | + | } | |
| 226 | + | // Transfer cleanup ownership away from the admission defers only after the | |
| 227 | + | // complete postcommit plan exists. | |
| 228 | + | sourceRolesFrozen = false | |
| 229 | + | dispatchHeld = false | |
| 230 | + | reservationHeld = false | |
| 231 | + | postcommit.execute(d) | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // moveSessionForLocatorLocked resolves only live sessions. The name is | |
| 236 | + | // intentionally ignored after the stable ID/incarnation pair is checked. | |
| 237 | + | func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session { | |
| 238 | + | if d == nil { | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | sess := d.sessions[locator.ID] | |
| 242 | + | if sess == nil || sess.incarnation != locator.Incarnation { | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | return sess | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func moveSessionLocatorCurrentLocked(sess *session, locator moveSessionLocator) bool { | |
| 249 | + | return sess != nil && sess.id == locator.ID && sess.incarnation == locator.Incarnation | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab { | |
| 253 | + | if sess == nil { | |
| 254 | + | return nil | |
| 255 | + | } | |
| 256 | + | for _, tb := range sess.tabs { | |
| 257 | + | if tb != nil && tb.stableID == string(stableID) { | |
| 258 | + | return tb | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | return nil | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func moveTabIndex(sess *session, target *tab) int { | |
| 265 | + | if sess == nil || target == nil { | |
| 266 | + | return -1 | |
| 267 | + | } | |
| 268 | + | sess.mu.Lock() | |
| 269 | + | defer sess.mu.Unlock() | |
| 270 | + | return indexMoveTabLocked(sess, target) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func indexMoveTabLocked(sess *session, target *tab) int { | |
| 274 | + | for i, tb := range sess.tabs { | |
| 275 | + | if tb == target { | |
| 276 | + | return i | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | return -1 | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func moveTabMemberLocked(sess *session, target *tab) bool { | |
| 283 | + | return indexMoveTabLocked(sess, target) >= 0 | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func lockMoveTabs(a, b *tab) func() { | |
| 287 | + | if a == nil && b == nil { | |
| 288 | + | return func() {} | |
| 289 | + | } | |
| 290 | + | if a == nil { | |
| 291 | + | b.mu.Lock() | |
| 292 | + | return b.mu.Unlock | |
| 293 | + | } | |
| 294 | + | if b == nil { | |
| 295 | + | a.mu.Lock() | |
| 296 | + | return a.mu.Unlock | |
| 297 | + | } | |
| 298 | + | if a == b { | |
| 299 | + | a.mu.Lock() | |
| 300 | + | return a.mu.Unlock | |
| 301 | + | } | |
| 302 | + | first, second := a, b | |
| 303 | + | if first.stableID > second.stableID || | |
| 304 | + | (first.stableID == second.stableID && reflect.ValueOf(first).Pointer() > reflect.ValueOf(second).Pointer()) { | |
| 305 | + | first, second = second, first | |
| 306 | + | } | |
| 307 | + | first.mu.Lock() | |
| 308 | + | second.mu.Lock() | |
| 309 | + | return func() { | |
| 310 | + | second.mu.Unlock() | |
| 311 | + | first.mu.Unlock() | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func lockMoveDispatch(a, b *session) func() { | |
| 316 | + | if a == b { | |
| 317 | + | a.dispatchMu.Lock() | |
| 318 | + | return a.dispatchMu.Unlock | |
| 319 | + | } | |
| 320 | + | first, second := a, b | |
| 321 | + | if first.id > second.id { | |
| 322 | + | first, second = second, first | |
| 323 | + | } | |
| 324 | + | first.dispatchMu.Lock() | |
| 325 | + | second.dispatchMu.Lock() | |
| 326 | + | return func() { | |
| 327 | + | second.dispatchMu.Unlock() | |
| 328 | + | first.dispatchMu.Unlock() | |
| 329 | + | } | |
| 330 | + | } | |
A internal/usecase/daemon/move_acceptance_test.go +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string { | |
| 14 | + | names := make([]string, 0, len(updates)) | |
| 15 | + | for _, update := range updates { | |
| 16 | + | names = append(names, update.Name) | |
| 17 | + | } | |
| 18 | + | return names | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type moveCatalogueRecorder struct { | |
| 22 | + | daemon *Daemon | |
| 23 | + | source *session | |
| 24 | + | destination *session | |
| 25 | + | mu sync.Mutex | |
| 26 | + | updates []domain.CatalogueMetadataUpdate | |
| 27 | + | lockViolation bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil } | |
| 31 | + | func (c *moveCatalogueRecorder) Record(string) (domain.CatalogueRecord, bool, error) { | |
| 32 | + | return domain.CatalogueRecord{}, false, nil | |
| 33 | + | } | |
| 34 | + | func (c *moveCatalogueRecorder) Create(domain.CatalogueRecord) error { return nil } | |
| 35 | + | func (c *moveCatalogueRecorder) Replace(string, domain.CatalogueRecord) error { return nil } | |
| 36 | + | func (c *moveCatalogueRecorder) Rename(string, domain.CatalogueRecord) error { return nil } | |
| 37 | + | func (c *moveCatalogueRecorder) Delete(string) error { return nil } | |
| 38 | + | func (c *moveCatalogueRecorder) Sync() error { return nil } | |
| 39 | + | func (c *moveCatalogueRecorder) Close() error { return nil } | |
| 40 | + | func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error { | |
| 41 | + | violated := false | |
| 42 | + | if c.daemon.mu.TryLock() { | |
| 43 | + | c.daemon.mu.Unlock() | |
| 44 | + | } else { | |
| 45 | + | violated = true | |
| 46 | + | } | |
| 47 | + | if c.source.mu.TryLock() { | |
| 48 | + | c.source.mu.Unlock() | |
| 49 | + | } else { | |
| 50 | + | violated = true | |
| 51 | + | } | |
| 52 | + | if c.destination.mu.TryLock() { | |
| 53 | + | c.destination.mu.Unlock() | |
| 54 | + | } else { | |
| 55 | + | violated = true | |
| 56 | + | } | |
| 57 | + | c.mu.Lock() | |
| 58 | + | c.updates = append(c.updates, update) | |
| 59 | + | c.lockViolation = c.lockViolation || violated | |
| 60 | + | c.mu.Unlock() | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T) { | |
| 65 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 66 | + | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| 67 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 68 | + | activePTY, releaseActive := newBlockingPTY(t) | |
| 69 | + | defer releaseMoved() | |
| 70 | + | defer releaseRemaining() | |
| 71 | + | defer releaseDestination() | |
| 72 | + | defer releaseActive() | |
| 73 | + | ||
| 74 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 75 | + | sourceTab := source.tabs[0] | |
| 76 | + | sourceTab.stableID = "source-tab" | |
| 77 | + | moved := sourceTab.focusedPane() | |
| 78 | + | remaining := newPaneWithStableID("pane-2", "remaining-pane", remainingPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 79 | + | sourceTab.mu.Lock() | |
| 80 | + | sourceTab.tree = &layout.Tree{ | |
| 81 | + | Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 82 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), | |
| 83 | + | }}, | |
| 84 | + | Focus: "pane-1", | |
| 85 | + | } | |
| 86 | + | sourceTab.panes[remaining.id] = remaining | |
| 87 | + | sourceTab.mu.Unlock() | |
| 88 | + | publishPaneOwner(remaining, source, sourceTab, 0) | |
| 89 | + | ||
| 90 | + | destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23}) | |
| 91 | + | destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 92 | + | destination := &session{ | |
| 93 | + | id: "destination", name: "destination", ephemeral: true, | |
| 94 | + | tabs: []*tab{destinationActive, destinationTab}, active: 0, | |
| 95 | + | } | |
| 96 | + | publishTiledPaneOwners(destination, destinationActive) | |
| 97 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 98 | + | d.mu.Lock() | |
| 99 | + | d.sessions[destination.id] = destination | |
| 100 | + | d.mu.Unlock() | |
| 101 | + | source.ephemeral = false | |
| 102 | + | destination.ephemeral = false | |
| 103 | + | source.snapEligible.Store(true) | |
| 104 | + | destination.snapEligible.Store(true) | |
| 105 | + | catalogue := &moveCatalogueRecorder{daemon: d, source: source, destination: destination} | |
| 106 | + | d.catalogue = catalogue | |
| 107 | + | d.persistEnabled = true | |
| 108 | + | source.snapshotMu.Lock() | |
| 109 | + | beforeSourceSnapshot := source.snapshotGeneration | |
| 110 | + | source.snapshotMu.Unlock() | |
| 111 | + | destination.snapshotMu.Lock() | |
| 112 | + | beforeDestinationSnapshot := destination.snapshotGeneration | |
| 113 | + | destination.snapshotMu.Unlock() | |
| 114 | + | ||
| 115 | + | sourceTab.mu.Lock() | |
| 116 | + | beforeSourceGeneration := sourceTab.layoutGeneration | |
| 117 | + | sourceTab.mu.Unlock() | |
| 118 | + | destinationTab.mu.Lock() | |
| 119 | + | beforeDestinationGeneration := destinationTab.layoutGeneration | |
| 120 | + | destinationTab.mu.Unlock() | |
| 121 | + | moved.mu.Lock() | |
| 122 | + | beforeOwnerGeneration := moved.ownerGeneration | |
| 123 | + | moved.mu.Unlock() | |
| 124 | + | ||
| 125 | + | require.NoError(t, d.movePane(movePaneRequest{ | |
| 126 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 127 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 128 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 129 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 130 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 131 | + | })) | |
| 132 | + | ||
| 133 | + | source.mu.Lock() | |
| 134 | + | sourceTabs := append([]*tab(nil), source.tabs...) | |
| 135 | + | source.mu.Unlock() | |
| 136 | + | require.Len(t, sourceTabs, 1) | |
| 137 | + | require.Same(t, sourceTab, sourceTabs[0]) | |
| 138 | + | sourceTab.mu.Lock() | |
| 139 | + | remainingPane := sourceTab.panes[remaining.id] | |
| 140 | + | sourceFocus := sourceTab.tree.Focus | |
| 141 | + | sourceGeneration := sourceTab.layoutGeneration | |
| 142 | + | sourceTab.mu.Unlock() | |
| 143 | + | require.Same(t, remaining, remainingPane) | |
| 144 | + | require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf") | |
| 145 | + | destination.mu.Lock() | |
| 146 | + | destinationActiveTab := destination.tabs[destination.active] | |
| 147 | + | destination.mu.Unlock() | |
| 148 | + | require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged") | |
| 149 | + | destinationTab.mu.Lock() | |
| 150 | + | destinationMovedPane := destinationTab.panes[moved.id] | |
| 151 | + | destinationFocus := destinationTab.tree.Focus | |
| 152 | + | destinationGeneration := destinationTab.layoutGeneration | |
| 153 | + | destinationTab.mu.Unlock() | |
| 154 | + | require.Same(t, moved, destinationMovedPane) | |
| 155 | + | require.Equal(t, destinationFocus, moved.id) | |
| 156 | + | moved.mu.Lock() | |
| 157 | + | ownerGeneration := moved.ownerGeneration | |
| 158 | + | moved.mu.Unlock() | |
| 159 | + | require.Equal(t, beforeOwnerGeneration+1, ownerGeneration, "move publishes exactly one new owner generation") | |
| 160 | + | require.Equal(t, beforeSourceGeneration+1, sourceGeneration) | |
| 161 | + | require.Equal(t, beforeDestinationGeneration+1, destinationGeneration) | |
| 162 | + | source.snapshotMu.Lock() | |
| 163 | + | require.GreaterOrEqual(t, source.snapshotGeneration, beforeSourceSnapshot+1) | |
| 164 | + | require.True(t, source.snapDirty.Load()) | |
| 165 | + | source.snapshotMu.Unlock() | |
| 166 | + | destination.snapshotMu.Lock() | |
| 167 | + | require.GreaterOrEqual(t, destination.snapshotGeneration, beforeDestinationSnapshot+1) | |
| 168 | + | require.True(t, destination.snapDirty.Load()) | |
| 169 | + | destination.snapshotMu.Unlock() | |
| 170 | + | catalogue.mu.Lock() | |
| 171 | + | updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...) | |
| 172 | + | lockViolation := catalogue.lockViolation | |
| 173 | + | catalogue.mu.Unlock() | |
| 174 | + | require.False(t, lockViolation, "catalogue publication ran under an architecture lock") | |
| 175 | + | require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates)) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) { | |
| 179 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 180 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 181 | + | defer releaseMoved() | |
| 182 | + | defer releaseDestination() | |
| 183 | + | ||
| 184 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 185 | + | sourceTab := source.tabs[0] | |
| 186 | + | sourceTab.stableID = "source-tab" | |
| 187 | + | moved := sourceTab.focusedPane() | |
| 188 | + | destination := &session{ | |
| 189 | + | id: "destination", name: "destination", ephemeral: true, | |
| 190 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |
| 191 | + | } | |
| 192 | + | destinationTab := destination.tabs[0] | |
| 193 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 194 | + | d.mu.Lock() | |
| 195 | + | d.sessions[destination.id] = destination | |
| 196 | + | d.mu.Unlock() | |
| 197 | + | ||
| 198 | + | sourceTab.mu.Lock() | |
| 199 | + | beforeSource := sourceTab.tree.Clone() | |
| 200 | + | sourceTab.mu.Unlock() | |
| 201 | + | destinationTab.mu.Lock() | |
| 202 | + | beforeDestination := destinationTab.tree.Clone() | |
| 203 | + | destinationTab.mu.Unlock() | |
| 204 | + | beforeOwner := moved.ownerSnapshot() | |
| 205 | + | staleIncarnation := source.incarnation | |
| 206 | + | staleIncarnation[0]++ | |
| 207 | + | err := d.movePane(movePaneRequest{ | |
| 208 | + | Source: moveSessionLocator{ID: source.id, Incarnation: staleIncarnation}, | |
| 209 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 210 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 211 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 212 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 213 | + | }) | |
| 214 | + | require.ErrorIs(t, err, errMovePaneInvalid) | |
| 215 | + | sourceTab.mu.Lock() | |
| 216 | + | afterSource := sourceTab.tree.Clone() | |
| 217 | + | sourceMovedPane := sourceTab.panes[moved.id] | |
| 218 | + | sourceTab.mu.Unlock() | |
| 219 | + | destinationTab.mu.Lock() | |
| 220 | + | afterDestination := destinationTab.tree.Clone() | |
| 221 | + | destinationMovedPane := destinationTab.panes[moved.id] | |
| 222 | + | destinationTab.mu.Unlock() | |
| 223 | + | require.Equal(t, beforeSource, afterSource) | |
| 224 | + | require.Equal(t, beforeDestination, afterDestination) | |
| 225 | + | require.Same(t, beforeOwner, moved.ownerSnapshot()) | |
| 226 | + | require.Same(t, moved, sourceMovedPane) | |
| 227 | + | require.NotSame(t, moved, destinationMovedPane) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) { | |
| 231 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 232 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 233 | + | defer releaseMoved() | |
| 234 | + | defer releaseDestination() | |
| 235 | + | ||
| 236 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 237 | + | sourceTab := source.tabs[0] | |
| 238 | + | sourceTab.stableID = "source-tab" | |
| 239 | + | moved := sourceTab.focusedPane() | |
| 240 | + | destination := &session{ | |
| 241 | + | id: "destination", name: "destination", ephemeral: true, | |
| 242 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |
| 243 | + | } | |
| 244 | + | destinationTab := destination.tabs[0] | |
| 245 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 246 | + | d.mu.Lock() | |
| 247 | + | d.sessions[destination.id] = destination | |
| 248 | + | d.mu.Unlock() | |
| 249 | + | ||
| 250 | + | destinationTab.mu.Lock() | |
| 251 | + | beforeDestination := destinationTab.tree.Clone() | |
| 252 | + | destinationTab.mu.Unlock() | |
| 253 | + | beforeOwner := moved.ownerSnapshot() | |
| 254 | + | d.afterMovePaneSourceSnapshot = func() { | |
| 255 | + | sourceTab.mu.Lock() | |
| 256 | + | sourceTab.layoutGeneration++ | |
| 257 | + | sourceTab.mu.Unlock() | |
| 258 | + | } | |
| 259 | + | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |
| 260 | + | ||
| 261 | + | err := d.movePane(movePaneRequest{ | |
| 262 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 263 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 264 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 265 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 266 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 267 | + | }) | |
| 268 | + | require.Error(t, err) | |
| 269 | + | destinationTab.mu.Lock() | |
| 270 | + | afterDestination := destinationTab.tree.Clone() | |
| 271 | + | destinationTab.mu.Unlock() | |
| 272 | + | sourceTab.mu.Lock() | |
| 273 | + | sourceMovedPane := sourceTab.panes[moved.id] | |
| 274 | + | sourceTab.mu.Unlock() | |
| 275 | + | require.Equal(t, beforeDestination, afterDestination) | |
| 276 | + | require.Same(t, beforeOwner, moved.ownerSnapshot()) | |
| 277 | + | require.Same(t, moved, sourceMovedPane) | |
| 278 | + | } | |
A internal/usecase/daemon/move_atomicity_test.go +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type moveCommitObservation struct { | |
| 13 | + | sourceRegistered bool | |
| 14 | + | sourceTabMember bool | |
| 15 | + | sourcePaneMember bool | |
| 16 | + | destinationTabMember bool | |
| 17 | + | destinationPaneMember bool | |
| 18 | + | sourceActive *tab | |
| 19 | + | destinationActive *tab | |
| 20 | + | sourceFocus layout.PaneID | |
| 21 | + | destinationFocus layout.PaneID | |
| 22 | + | ownerSession *session | |
| 23 | + | ownerTab *tab | |
| 24 | + | sourceRole attachmentRole | |
| 25 | + | destinationRole attachmentRole | |
| 26 | + | sourceClientSession *session | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // readMoveCommitObservation follows the same architecture lock order as the | |
| 30 | + | // move commit. Every field is therefore one supported reader's coherent view, | |
| 31 | + | // rather than a collection of lock-free observations that could straddle a | |
| 32 | + | // publication boundary. | |
| 33 | + | func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, sourceClient *attachedClient) moveCommitObservation { | |
| 34 | + | d.mu.Lock() | |
| 35 | + | d.notices.routingMu.Lock() | |
| 36 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 37 | + | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 38 | + | movedPane.mu.Lock() | |
| 39 | + | ||
| 40 | + | owner := movedPane.ownerSnapshot() | |
| 41 | + | observation := moveCommitObservation{ | |
| 42 | + | sourceRegistered: d.sessions[source.id] == source, | |
| 43 | + | sourceTabMember: moveTabMemberLocked(source, sourceTab), | |
| 44 | + | sourcePaneMember: sourceTab.panes[movedPane.id] == movedPane, | |
| 45 | + | destinationTabMember: moveTabMemberLocked(destination, destinationTab), | |
| 46 | + | destinationPaneMember: destinationTab.panes[movedPane.id] == movedPane, | |
| 47 | + | sourceActive: activeMoveTabLocked(source), | |
| 48 | + | destinationActive: activeMoveTabLocked(destination), | |
| 49 | + | sourceFocus: sourceTab.tree.Focus, | |
| 50 | + | destinationFocus: destinationTab.tree.Focus, | |
| 51 | + | ownerSession: nil, | |
| 52 | + | ownerTab: nil, | |
| 53 | + | sourceRole: source.attachmentRoleLocked(sourceClient), | |
| 54 | + | destinationRole: destination.attachmentRoleLocked(sourceClient), | |
| 55 | + | sourceClientSession: sourceClient.currentSession(), | |
| 56 | + | } | |
| 57 | + | if owner != nil { | |
| 58 | + | observation.ownerSession = owner.session | |
| 59 | + | observation.ownerTab = owner.tab | |
| 60 | + | } | |
| 61 | + | movedPane.mu.Unlock() | |
| 62 | + | unlockTabs() | |
| 63 | + | unlockSessions() | |
| 64 | + | d.notices.routingMu.Unlock() | |
| 65 | + | d.mu.Unlock() | |
| 66 | + | return observation | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func activeMoveTabLocked(sess *session) *tab { | |
| 70 | + | if sess == nil || sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | return sess.tabs[sess.active] | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) { | |
| 77 | + | p1, release1 := newBlockingPTY(t) | |
| 78 | + | p2, release2 := newBlockingPTY(t) | |
| 79 | + | defer release1() | |
| 80 | + | defer release2() | |
| 81 | + | ||
| 82 | + | d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1) | |
| 83 | + | sourceTab := source.tabs[0] | |
| 84 | + | sourceTab.stableID = "source-tab" | |
| 85 | + | movedPane := sourceTab.focusedPane() | |
| 86 | + | ||
| 87 | + | destination := &session{ | |
| 88 | + | id: "destination", | |
| 89 | + | name: "destination", | |
| 90 | + | ephemeral: true, | |
| 91 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 92 | + | active: 0, | |
| 93 | + | } | |
| 94 | + | destinationTab := destination.tabs[0] | |
| 95 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 96 | + | d.mu.Lock() | |
| 97 | + | d.sessions[destination.id] = destination | |
| 98 | + | d.mu.Unlock() | |
| 99 | + | require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true)) | |
| 100 | + | ||
| 101 | + | commitEntered := make(chan struct{}) | |
| 102 | + | releaseCommit := make(chan struct{}) | |
| 103 | + | d.beforeMovePaneCommit = func() { | |
| 104 | + | close(commitEntered) | |
| 105 | + | <-releaseCommit | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | moveDone := make(chan error, 1) | |
| 109 | + | go func() { | |
| 110 | + | moveDone <- d.movePane(movePaneRequest{ | |
| 111 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 112 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 113 | + | SourcePaneID: domain.PaneStableID(movedPane.stableID), | |
| 114 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 115 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 116 | + | }) | |
| 117 | + | }() | |
| 118 | + | <-commitEntered | |
| 119 | + | ||
| 120 | + | type reader struct { | |
| 121 | + | name string | |
| 122 | + | read func(moveCommitObservation) bool | |
| 123 | + | } | |
| 124 | + | readers := []reader{ | |
| 125 | + | {name: "source membership", read: func(o moveCommitObservation) bool { | |
| 126 | + | return o.sourceRegistered || o.sourceTabMember || o.sourcePaneMember | |
| 127 | + | }}, | |
| 128 | + | {name: "destination membership", read: func(o moveCommitObservation) bool { return o.destinationTabMember && !o.destinationPaneMember }}, | |
| 129 | + | {name: "owner", read: func(o moveCommitObservation) bool { return o.ownerSession == source && o.ownerTab == sourceTab }}, | |
| 130 | + | {name: "active tab and focus", read: func(o moveCommitObservation) bool { | |
| 131 | + | return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2") | |
| 132 | + | }}, | |
| 133 | + | {name: "attachment roles", read: func(o moveCommitObservation) bool { | |
| 134 | + | return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source | |
| 135 | + | }}, | |
| 136 | + | } | |
| 137 | + | started := make(chan string, len(readers)) | |
| 138 | + | done := make(chan struct { | |
| 139 | + | name string | |
| 140 | + | observation moveCommitObservation | |
| 141 | + | }, len(readers)) | |
| 142 | + | for _, current := range readers { | |
| 143 | + | go func(current reader) { | |
| 144 | + | started <- current.name | |
| 145 | + | observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 146 | + | done <- struct { | |
| 147 | + | name string | |
| 148 | + | observation moveCommitObservation | |
| 149 | + | }{name: current.name, observation: observation} | |
| 150 | + | }(current) | |
| 151 | + | } | |
| 152 | + | for range readers { | |
| 153 | + | <-started | |
| 154 | + | } | |
| 155 | + | for range readers { | |
| 156 | + | select { | |
| 157 | + | case result := <-done: | |
| 158 | + | t.Fatalf("%s reader completed while the move commit was paused: %#v", result.name, result.observation) | |
| 159 | + | default: | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | close(releaseCommit) | |
| 164 | + | require.NoError(t, <-moveDone) | |
| 165 | + | for _, current := range readers { | |
| 166 | + | result := <-done | |
| 167 | + | require.False(t, current.read(result.observation), "%s observed partial move state: %#v", current.name, result.observation) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 171 | + | require.False(t, observation.sourceRegistered) | |
| 172 | + | require.False(t, observation.sourceTabMember) | |
| 173 | + | require.False(t, observation.sourcePaneMember) | |
| 174 | + | require.True(t, observation.destinationTabMember) | |
| 175 | + | require.True(t, observation.destinationPaneMember) | |
| 176 | + | require.Nil(t, observation.sourceActive) | |
| 177 | + | require.Equal(t, layout.PaneID("pane-1"), observation.sourceFocus) | |
| 178 | + | require.Same(t, destinationTab, observation.destinationActive) | |
| 179 | + | require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus) | |
| 180 | + | require.Same(t, destination, observation.ownerSession) | |
| 181 | + | require.Same(t, destinationTab, observation.ownerTab) | |
| 182 | + | require.Equal(t, attachmentDetached, observation.sourceRole) | |
| 183 | + | require.Equal(t, attachmentActive, observation.destinationRole) | |
| 184 | + | require.Same(t, destination, observation.sourceClientSession) | |
| 185 | + | } | |
A internal/usecase/daemon/move_attachments.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // frozenMoveAttachmentRetirement is proof that every source snatched client | |
| 4 | + | // retired by a final move has had its exact role-effect gate frozen and drained. | |
| 5 | + | // It is prepared while the source session lock is held, before topology writes. | |
| 6 | + | type frozenMoveAttachmentRetirement struct { | |
| 7 | + | clients []*attachedClient | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | func snapshotMoveSnatchedLocked(sess *session) []*attachedClient { | |
| 11 | + | clients := make([]*attachedClient, 0, len(sess.snatched)) | |
| 12 | + | for ac := range sess.snatched { | |
| 13 | + | clients = append(clients, ac) | |
| 14 | + | } | |
| 15 | + | return clients | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool { | |
| 19 | + | if sess == nil || len(sess.snatched) != len(admitted) { | |
| 20 | + | return false | |
| 21 | + | } | |
| 22 | + | for _, ac := range admitted { | |
| 23 | + | if _, ok := sess.snatched[ac]; !ok { | |
| 24 | + | return false | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | return true | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) { | |
| 31 | + | if !sameMoveSnatchedLocked(sess, admitted) { | |
| 32 | + | return frozenMoveAttachmentRetirement{}, false | |
| 33 | + | } | |
| 34 | + | if len(admitted) == 0 { | |
| 35 | + | return frozenMoveAttachmentRetirement{}, true | |
| 36 | + | } | |
| 37 | + | if !frozen.acquired || !frozen.drained { | |
| 38 | + | return frozenMoveAttachmentRetirement{}, false | |
| 39 | + | } | |
| 40 | + | for _, ac := range admitted { | |
| 41 | + | if !frozen.contains(ac) { | |
| 42 | + | return frozenMoveAttachmentRetirement{}, false | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true | |
| 46 | + | } | |
A internal/usecase/daemon/move_cleanup_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |
| 13 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 14 | + | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| 15 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 16 | + | defer releaseMoved() | |
| 17 | + | defer releaseRemaining() | |
| 18 | + | defer releaseDestination() | |
| 19 | + | ||
| 20 | + | d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 21 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 22 | + | sourceTab := source.tabs[0] | |
| 23 | + | sourceTab.stableID = "source-tab" | |
| 24 | + | moved := sourceTab.focusedPane() | |
| 25 | + | client.sendMu.Lock() | |
| 26 | + | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |
| 27 | + | client.sendMu.Unlock() | |
| 28 | + | ||
| 29 | + | destination := &session{ | |
| 30 | + | id: "destination", name: "destination", ephemeral: true, | |
| 31 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 32 | + | active: 0, | |
| 33 | + | } | |
| 34 | + | destinationTab := destination.tabs[0] | |
| 35 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 36 | + | d.mu.Lock() | |
| 37 | + | d.sessions[destination.id] = destination | |
| 38 | + | d.mu.Unlock() | |
| 39 | + | ||
| 40 | + | moved.mu.Lock() | |
| 41 | + | owner := moved.effectLeaseLocked() | |
| 42 | + | moved.mu.Unlock() | |
| 43 | + | members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}} | |
| 44 | + | rc := source.renderCoordinator() | |
| 45 | + | require.NotNil(t, rc) | |
| 46 | + | lease := rc.attachmentLease(client) | |
| 47 | + | epoch := rc.recordResizeRequestForLease(client.size, client, lease) | |
| 48 | + | ticket, admitted := beginActiveLeaseEffect(source, client, lease) | |
| 49 | + | require.True(t, admitted) | |
| 50 | + | defer ticket.End() | |
| 51 | + | ||
| 52 | + | sendLocked := make(chan struct{}) | |
| 53 | + | allowResizeFence := make(chan struct{}) | |
| 54 | + | d.afterResizeCommitSendLocked = func() { | |
| 55 | + | close(sendLocked) | |
| 56 | + | <-allowResizeFence | |
| 57 | + | } | |
| 58 | + | defer func() { d.afterResizeCommitSendLocked = nil }() | |
| 59 | + | resizeDone := make(chan bool, 1) | |
| 60 | + | go func() { | |
| 61 | + | resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size) | |
| 62 | + | }() | |
| 63 | + | awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu") | |
| 64 | + | ||
| 65 | + | insideMoveCommit := make(chan struct{}) | |
| 66 | + | allowMoveCommit := make(chan struct{}) | |
| 67 | + | d.beforeMovePaneCommit = func() { | |
| 68 | + | close(insideMoveCommit) | |
| 69 | + | <-allowMoveCommit | |
| 70 | + | } | |
| 71 | + | defer func() { d.beforeMovePaneCommit = nil }() | |
| 72 | + | moveDone := make(chan error, 1) | |
| 73 | + | go func() { | |
| 74 | + | moveDone <- d.movePane(movePaneRequest{ | |
| 75 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 76 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 77 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 78 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 79 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 80 | + | }) | |
| 81 | + | }() | |
| 82 | + | awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section") | |
| 83 | + | ||
| 84 | + | // Resize now owns sendMu and waits for the moved pane fence. The move may | |
| 85 | + | // finish only if it releases every resize fence before capture cleanup tries | |
| 86 | + | // to acquire sendMu. | |
| 87 | + | close(allowResizeFence) | |
| 88 | + | close(allowMoveCommit) | |
| 89 | + | require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit")) | |
| 90 | + | require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner")) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |
| 94 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 95 | + | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| 96 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 97 | + | defer releaseMoved() | |
| 98 | + | defer releaseRemaining() | |
| 99 | + | defer releaseDestination() | |
| 100 | + | ||
| 101 | + | d, source, displaced, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 102 | + | sourceTab := source.tabs[0] | |
| 103 | + | remainingTab := source.tabs[1] | |
| 104 | + | sourceTab.stableID = "source-tab" | |
| 105 | + | remainingTab.stableID = "remaining-tab" | |
| 106 | + | moved := sourceTab.focusedPane() | |
| 107 | + | require.NotNil(t, d.attachCoordinator(source, nil, displaced, true)) | |
| 108 | + | ||
| 109 | + | destination := &session{ | |
| 110 | + | id: "destination", name: "destination", ephemeral: true, | |
| 111 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 112 | + | active: 0, | |
| 113 | + | } | |
| 114 | + | destinationTab := destination.tabs[0] | |
| 115 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 116 | + | d.mu.Lock() | |
| 117 | + | d.sessions[destination.id] = destination | |
| 118 | + | d.mu.Unlock() | |
| 119 | + | ||
| 120 | + | // Both clients carry state tied specifically to the pane. The displaced | |
| 121 | + | // client was captured before the fence wait; the replacement is the only | |
| 122 | + | // attachment that may be cleaned after commit. | |
| 123 | + | displaced.overlays.copyMu.Lock() | |
| 124 | + | displaced.overlays.copyPane = moved | |
| 125 | + | displaced.overlays.copyMode = &scopy.Mode{} | |
| 126 | + | displaced.overlays.copyMu.Unlock() | |
| 127 | + | displaced.sendMu.Lock() | |
| 128 | + | displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |
| 129 | + | displaced.sendMu.Unlock() | |
| 130 | + | ||
| 131 | + | replacementTransport, _ := newCapturingTransport(t) | |
| 132 | + | replacement := &attachedClient{ | |
| 133 | + | tr: replacementTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, | |
| 134 | + | } | |
| 135 | + | replacement.initOverlays() | |
| 136 | + | replacement.setSession(source) | |
| 137 | + | replacement.overlays.copyMu.Lock() | |
| 138 | + | replacement.overlays.copyPane = moved | |
| 139 | + | replacement.overlays.copyMode = &scopy.Mode{} | |
| 140 | + | replacement.overlays.copyMu.Unlock() | |
| 141 | + | replacement.sendMu.Lock() | |
| 142 | + | replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |
| 143 | + | replacement.sendMu.Unlock() | |
| 144 | + | ||
| 145 | + | // Keep the source layout fence occupied while the move has captured its | |
| 146 | + | // pre-fence source client. Replacing the client at this barrier reproduces | |
| 147 | + | // the stale cleanup race without sleeps. | |
| 148 | + | source.layoutApplyMu.Lock() | |
| 149 | + | snapshot := make(chan struct{}) | |
| 150 | + | releaseSnapshot := make(chan struct{}) | |
| 151 | + | d.afterMovePaneSourceSnapshot = func() { | |
| 152 | + | close(snapshot) | |
| 153 | + | <-releaseSnapshot | |
| 154 | + | } | |
| 155 | + | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |
| 156 | + | ||
| 157 | + | moveDone := make(chan error, 1) | |
| 158 | + | go func() { | |
| 159 | + | moveDone <- d.movePane(movePaneRequest{ | |
| 160 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 161 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 162 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 163 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 164 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 165 | + | }) | |
| 166 | + | }() | |
| 167 | + | awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment") | |
| 168 | + | ||
| 169 | + | source.mu.Lock() | |
| 170 | + | source.client = replacement | |
| 171 | + | source.mu.Unlock() | |
| 172 | + | close(releaseSnapshot) | |
| 173 | + | source.layoutApplyMu.Unlock() | |
| 174 | + | require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release")) | |
| 175 | + | ||
| 176 | + | displaced.overlays.copyMu.Lock() | |
| 177 | + | require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned") | |
| 178 | + | require.Same(t, moved, displaced.overlays.copyPane) | |
| 179 | + | displaced.overlays.copyMu.Unlock() | |
| 180 | + | displaced.sendMu.Lock() | |
| 181 | + | _, displacedCaptured := displaced.captureFrames[moved] | |
| 182 | + | displaced.sendMu.Unlock() | |
| 183 | + | require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned") | |
| 184 | + | ||
| 185 | + | replacement.overlays.copyMu.Lock() | |
| 186 | + | require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned") | |
| 187 | + | require.Nil(t, replacement.overlays.copyPane) | |
| 188 | + | replacement.overlays.copyMu.Unlock() | |
| 189 | + | replacement.sendMu.Lock() | |
| 190 | + | _, replacementCaptured := replacement.captureFrames[moved] | |
| 191 | + | replacement.sendMu.Unlock() | |
| 192 | + | require.False(t, replacementCaptured, "current source capture was not pruned") | |
| 193 | + | } | |
A internal/usecase/daemon/move_final_source_attachment_race_test.go +211 -0
| @@ -0,0 +1,211 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type finalMoveAttachmentMutation int | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota | |
| 15 | + | installSnatchedAfterFinalMoveAdmission | |
| 16 | + | replaceSnatchedAfterFinalMoveAdmission | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func newFinalMoveRaceClient(sess *session) *attachedClient { | |
| 20 | + | ac := &attachedClient{ | |
| 21 | + | tr: &closeTrackingTransport{}, | |
| 22 | + | output: newOutputStateStream(), | |
| 23 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | + | } | |
| 25 | + | ac.initOverlays() | |
| 26 | + | ac.setSession(sess) | |
| 27 | + | return ac | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func makeFinalMoveSourceHeadless(source *session, original *attachedClient) { | |
| 31 | + | source.mu.Lock() | |
| 32 | + | source.client = nil | |
| 33 | + | source.mu.Unlock() | |
| 34 | + | original.setSession(nil) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) { | |
| 38 | + | current = newFinalMoveRaceClient(source) | |
| 39 | + | source.mu.Lock() | |
| 40 | + | switch mutation { | |
| 41 | + | case installActiveAfterFinalMoveAdmission: | |
| 42 | + | source.client = current | |
| 43 | + | case installSnatchedAfterFinalMoveAdmission: | |
| 44 | + | source.addSnatchedLocked(current) | |
| 45 | + | case replaceSnatchedAfterFinalMoveAdmission: | |
| 46 | + | for ac := range source.snatched { | |
| 47 | + | replaced = ac | |
| 48 | + | delete(source.snatched, ac) | |
| 49 | + | ac.setSession(nil) | |
| 50 | + | break | |
| 51 | + | } | |
| 52 | + | source.addSnatchedLocked(current) | |
| 53 | + | } | |
| 54 | + | source.mu.Unlock() | |
| 55 | + | return current, replaced | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) { | |
| 59 | + | t.Helper() | |
| 60 | + | require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned") | |
| 61 | + | switch mutation { | |
| 62 | + | case installActiveAfterFinalMoveAdmission: | |
| 63 | + | require.Equal(t, attachmentActive, source.attachmentRole(current)) | |
| 64 | + | case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission: | |
| 65 | + | require.Equal(t, attachmentSnatched, source.attachmentRole(current)) | |
| 66 | + | } | |
| 67 | + | if replaced != nil { | |
| 68 | + | require.Nil(t, replaced.currentSession()) | |
| 69 | + | require.Equal(t, attachmentDetached, source.attachmentRole(replaced)) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 74 | + | for _, tc := range []struct { | |
| 75 | + | name string | |
| 76 | + | mutation finalMoveAttachmentMutation | |
| 77 | + | existing bool | |
| 78 | + | }{ | |
| 79 | + | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 80 | + | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 81 | + | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 82 | + | } { | |
| 83 | + | t.Run(tc.name, func(t *testing.T) { | |
| 84 | + | d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t) | |
| 85 | + | defer release() | |
| 86 | + | original := source.client | |
| 87 | + | makeFinalMoveSourceHeadless(source, original) | |
| 88 | + | if tc.existing { | |
| 89 | + | existing := newFinalMoveRaceClient(source) | |
| 90 | + | source.mu.Lock() | |
| 91 | + | source.addSnatchedLocked(existing) | |
| 92 | + | source.mu.Unlock() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | sourceGeneration := sourceTab.layoutGeneration | |
| 96 | + | destinationGeneration := destinationTab.layoutGeneration | |
| 97 | + | destinationPaneAtMovedID := destinationTab.panes[moved.id] | |
| 98 | + | owner := moved.ownerSnapshot() | |
| 99 | + | barrier := make(chan struct{}) | |
| 100 | + | releaseMove := make(chan struct{}) | |
| 101 | + | d.afterMovePaneSourceSnapshot = func() { | |
| 102 | + | close(barrier) | |
| 103 | + | <-releaseMove | |
| 104 | + | } | |
| 105 | + | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |
| 106 | + | ||
| 107 | + | moveDone := make(chan error, 1) | |
| 108 | + | go func() { | |
| 109 | + | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 110 | + | }() | |
| 111 | + | waitMoveRace(t, barrier, "move pane admission") | |
| 112 | + | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 113 | + | currentGeneration := current.roleGeneration.Load() | |
| 114 | + | close(releaseMove) | |
| 115 | + | err := waitMoveRace(t, moveDone, "move pane rejection") | |
| 116 | + | ||
| 117 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 118 | + | d.mu.Lock() | |
| 119 | + | require.Same(t, source, d.sessions[source.id]) | |
| 120 | + | require.Same(t, destination, d.sessions[destination.id]) | |
| 121 | + | d.mu.Unlock() | |
| 122 | + | source.mu.Lock() | |
| 123 | + | require.Equal(t, []*tab{sourceTab}, source.tabs) | |
| 124 | + | source.mu.Unlock() | |
| 125 | + | sourceTab.mu.Lock() | |
| 126 | + | require.Same(t, moved, sourceTab.panes[moved.id]) | |
| 127 | + | require.Equal(t, sourceGeneration, sourceTab.layoutGeneration) | |
| 128 | + | sourceTab.mu.Unlock() | |
| 129 | + | destinationTab.mu.Lock() | |
| 130 | + | require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id]) | |
| 131 | + | require.Equal(t, destinationGeneration, destinationTab.layoutGeneration) | |
| 132 | + | destinationTab.mu.Unlock() | |
| 133 | + | require.Equal(t, owner, moved.ownerSnapshot()) | |
| 134 | + | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 135 | + | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 141 | + | for _, tc := range []struct { | |
| 142 | + | name string | |
| 143 | + | mutation finalMoveAttachmentMutation | |
| 144 | + | existing bool | |
| 145 | + | }{ | |
| 146 | + | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 147 | + | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 148 | + | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 149 | + | } { | |
| 150 | + | t.Run(tc.name, func(t *testing.T) { | |
| 151 | + | d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 152 | + | moved := source.tabs[0] | |
| 153 | + | moved.stableID = "moved-tab" | |
| 154 | + | makeFinalMoveSourceHeadless(source, original) | |
| 155 | + | if tc.existing { | |
| 156 | + | existing := newFinalMoveRaceClient(source) | |
| 157 | + | source.mu.Lock() | |
| 158 | + | source.addSnatchedLocked(existing) | |
| 159 | + | source.mu.Unlock() | |
| 160 | + | } | |
| 161 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 162 | + | destinationTabs := append([]*tab(nil), destination.tabs...) | |
| 163 | + | sourceGeneration := moved.layoutGeneration | |
| 164 | + | owners := make(map[*pane]*paneOwner, len(moved.panes)) | |
| 165 | + | for _, p := range moved.panes { | |
| 166 | + | owners[p] = p.ownerSnapshot() | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | barrier := make(chan struct{}) | |
| 170 | + | releaseMove := make(chan struct{}) | |
| 171 | + | d.afterMoveTabSourceSnapshot = func() { | |
| 172 | + | close(barrier) | |
| 173 | + | <-releaseMove | |
| 174 | + | } | |
| 175 | + | defer func() { d.afterMoveTabSourceSnapshot = nil }() | |
| 176 | + | moveDone := make(chan error, 1) | |
| 177 | + | go func() { | |
| 178 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 179 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 180 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 181 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 182 | + | }) | |
| 183 | + | }() | |
| 184 | + | waitMoveRace(t, barrier, "move tab admission") | |
| 185 | + | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 186 | + | currentGeneration := current.roleGeneration.Load() | |
| 187 | + | close(releaseMove) | |
| 188 | + | err := waitMoveRace(t, moveDone, "move tab rejection") | |
| 189 | + | ||
| 190 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 191 | + | d.mu.Lock() | |
| 192 | + | require.Same(t, source, d.sessions[source.id]) | |
| 193 | + | require.Same(t, destination, d.sessions[destination.id]) | |
| 194 | + | d.mu.Unlock() | |
| 195 | + | source.mu.Lock() | |
| 196 | + | require.Equal(t, []*tab{moved}, source.tabs) | |
| 197 | + | source.mu.Unlock() | |
| 198 | + | destination.mu.Lock() | |
| 199 | + | require.Equal(t, destinationTabs, destination.tabs) | |
| 200 | + | destination.mu.Unlock() | |
| 201 | + | moved.mu.Lock() | |
| 202 | + | require.Equal(t, sourceGeneration, moved.layoutGeneration) | |
| 203 | + | moved.mu.Unlock() | |
| 204 | + | for p, owner := range owners { | |
| 205 | + | require.Equal(t, owner, p.ownerSnapshot()) | |
| 206 | + | } | |
| 207 | + | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 208 | + | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | } | |
A internal/usecase/daemon/move_gate_race_test.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *session, *tab, func()) { | |
| 15 | + | t.Helper() | |
| 16 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 17 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 18 | + | d, source, client, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 19 | + | sourceTab := source.tabs[0] | |
| 20 | + | sourceTab.stableID = "source-tab" | |
| 21 | + | moved := sourceTab.focusedPane() | |
| 22 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 23 | + | ||
| 24 | + | destination := &session{ | |
| 25 | + | id: "destination", name: "destination", ephemeral: true, | |
| 26 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 27 | + | active: 0, | |
| 28 | + | } | |
| 29 | + | destinationTab := destination.tabs[0] | |
| 30 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 31 | + | d.mu.Lock() | |
| 32 | + | d.sessions[destination.id] = destination | |
| 33 | + | d.mu.Unlock() | |
| 34 | + | return d, source, sourceTab, moved, destination, destinationTab, func() { | |
| 35 | + | releaseMoved() | |
| 36 | + | releaseDestination() | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func moveGateRaceRequest(source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) movePaneRequest { | |
| 41 | + | return movePaneRequest{ | |
| 42 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 43 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 44 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 45 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 46 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func waitMoveRace[T any](t *testing.T, ch <-chan T, name string) T { | |
| 51 | + | t.Helper() | |
| 52 | + | select { | |
| 53 | + | case value := <-ch: | |
| 54 | + | return value | |
| 55 | + | case <-timeAfterMoveRace(): | |
| 56 | + | t.Fatalf("%s did not finish", name) | |
| 57 | + | var zero T | |
| 58 | + | return zero | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // timeAfterMoveRace is kept in one helper so these barrier tests use a | |
| 63 | + | // deterministic bounded wait rather than sleeps. | |
| 64 | + | func timeAfterMoveRace() <-chan time.Time { | |
| 65 | + | return time.After(testWaitTimeout) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) { | |
| 69 | + | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |
| 70 | + | defer releasePTYs() | |
| 71 | + | ||
| 72 | + | moveReserved := make(chan struct{}) | |
| 73 | + | releaseMoveAdmission := make(chan struct{}) | |
| 74 | + | d.afterMoveLifecycleReserved = func() { | |
| 75 | + | close(moveReserved) | |
| 76 | + | <-releaseMoveAdmission | |
| 77 | + | } | |
| 78 | + | killFrozen := make(chan struct{}) | |
| 79 | + | releaseKill := make(chan struct{}) | |
| 80 | + | var killFrozenOnce sync.Once | |
| 81 | + | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 82 | + | if action != "" { | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | killFrozenOnce.Do(func() { close(killFrozen) }) | |
| 86 | + | <-releaseKill | |
| 87 | + | } | |
| 88 | + | defer func() { | |
| 89 | + | d.afterMoveLifecycleReserved = nil | |
| 90 | + | d.afterRoleEffectGateFrozen = nil | |
| 91 | + | }() | |
| 92 | + | ||
| 93 | + | moveDone := make(chan error, 1) | |
| 94 | + | go func() { | |
| 95 | + | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 96 | + | }() | |
| 97 | + | waitMoveRace(t, moveReserved, "move reservation") | |
| 98 | + | ||
| 99 | + | killDone := make(chan error, 1) | |
| 100 | + | go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }() | |
| 101 | + | waitMoveRace(t, killFrozen, "kill gate freeze") | |
| 102 | + | close(releaseMoveAdmission) | |
| 103 | + | moveErr := waitMoveRace(t, moveDone, "move abort after kill gate acquisition") | |
| 104 | + | close(releaseKill) | |
| 105 | + | killErr := waitMoveRace(t, killDone, "kill teardown") | |
| 106 | + | ||
| 107 | + | require.ErrorIs(t, moveErr, errMovePaneInvalid) | |
| 108 | + | require.NoError(t, killErr) | |
| 109 | + | d.mu.Lock() | |
| 110 | + | _, sourceLive := d.sessions[source.id] | |
| 111 | + | _, destinationLive := d.sessions[destination.id] | |
| 112 | + | d.mu.Unlock() | |
| 113 | + | require.False(t, sourceLive, "kill winner must retire the source") | |
| 114 | + | require.True(t, destinationLive) | |
| 115 | + | require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move") | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) { | |
| 119 | + | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |
| 120 | + | defer releasePTYs() | |
| 121 | + | ||
| 122 | + | moveGateFrozen := make(chan struct{}) | |
| 123 | + | releaseMove := make(chan struct{}) | |
| 124 | + | var moveGateFrozenOnce sync.Once | |
| 125 | + | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 126 | + | if action != "move-pane" { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | moveGateFrozenOnce.Do(func() { close(moveGateFrozen) }) | |
| 130 | + | <-releaseMove | |
| 131 | + | } | |
| 132 | + | killSnapshotted := make(chan struct{}) | |
| 133 | + | var killSnapshottedOnce sync.Once | |
| 134 | + | d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) { | |
| 135 | + | if action == "" { | |
| 136 | + | killSnapshottedOnce.Do(func() { close(killSnapshotted) }) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | defer func() { | |
| 140 | + | d.afterRoleEffectGateFrozen = nil | |
| 141 | + | d.afterRoleEffectParticipantsSnapshotted = nil | |
| 142 | + | }() | |
| 143 | + | ||
| 144 | + | moveDone := make(chan error, 1) | |
| 145 | + | go func() { | |
| 146 | + | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 147 | + | }() | |
| 148 | + | waitMoveRace(t, moveGateFrozen, "move gate freeze") | |
| 149 | + | ||
| 150 | + | killDone := make(chan error, 1) | |
| 151 | + | go func() { killDone <- d.killSession(source, ports.ReasonSessionKilled, false) }() | |
| 152 | + | waitMoveRace(t, killSnapshotted, "kill participant snapshot") | |
| 153 | + | close(releaseMove) | |
| 154 | + | ||
| 155 | + | require.NoError(t, waitMoveRace(t, moveDone, "move commit")) | |
| 156 | + | require.NoError(t, waitMoveRace(t, killDone, "kill loser")) | |
| 157 | + | d.mu.Lock() | |
| 158 | + | _, sourceLive := d.sessions[source.id] | |
| 159 | + | _, destinationLive := d.sessions[destination.id] | |
| 160 | + | d.mu.Unlock() | |
| 161 | + | require.False(t, sourceLive) | |
| 162 | + | require.True(t, destinationLive) | |
| 163 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 164 | + | } | |
A internal/usecase/daemon/move_handoff_failure_test.go +338 -0
| @@ -0,0 +1,338 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "maps" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type moveTabState struct { | |
| 15 | + | ptr *tab | |
| 16 | + | stableID string | |
| 17 | + | tree *layout.Tree | |
| 18 | + | panes map[layout.PaneID]*pane | |
| 19 | + | nextPaneID int | |
| 20 | + | layoutGeneration uint64 | |
| 21 | + | floatingState floatingState | |
| 22 | + | floatingPane *pane | |
| 23 | + | floatingVisible bool | |
| 24 | + | floatingGeneration uint64 | |
| 25 | + | ctxDone bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type moveSessionState struct { | |
| 29 | + | ptr *session | |
| 30 | + | id domain.SessionID | |
| 31 | + | name string | |
| 32 | + | incarnation domain.IncarnationID | |
| 33 | + | tabs []*tab | |
| 34 | + | active int | |
| 35 | + | client *attachedClient | |
| 36 | + | snatched map[*attachedClient]struct{} | |
| 37 | + | moveReservations uint | |
| 38 | + | snapshotGeneration uint64 | |
| 39 | + | snapshotPublished uint64 | |
| 40 | + | snapshotDirty bool | |
| 41 | + | snapshotEligible bool | |
| 42 | + | ctxDone bool | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type moveLeaseState struct { | |
| 46 | + | lease *attachmentLease | |
| 47 | + | attachment *attachedClient | |
| 48 | + | ready bool | |
| 49 | + | active bool | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type moveClientState struct { | |
| 53 | + | transport transportSnapshot | |
| 54 | + | roleGeneration uint64 | |
| 55 | + | currentSession *session | |
| 56 | + | sourceRole attachmentRole | |
| 57 | + | destinationRole attachmentRole | |
| 58 | + | sourceLease moveLeaseState | |
| 59 | + | destinationLease moveLeaseState | |
| 60 | + | roleEffectPhase roleEffectPhase | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type movePaneState struct { | |
| 64 | + | id layout.PaneID | |
| 65 | + | stableID string | |
| 66 | + | owner *paneOwner | |
| 67 | + | ownerGeneration uint64 | |
| 68 | + | ctxDone bool | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type moveState struct { | |
| 72 | + | closing bool | |
| 73 | + | registry map[domain.SessionID]*session | |
| 74 | + | source moveSessionState | |
| 75 | + | destination moveSessionState | |
| 76 | + | sourceTab moveTabState | |
| 77 | + | destinationTab moveTabState | |
| 78 | + | movedPane movePaneState | |
| 79 | + | client moveClientState | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState { | |
| 83 | + | clientState := captureMoveClientState(source, destination, client) | |
| 84 | + | d.mu.Lock() | |
| 85 | + | d.notices.routingMu.Lock() | |
| 86 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 87 | + | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 88 | + | movedPane.mu.Lock() | |
| 89 | + | ||
| 90 | + | registry := make(map[domain.SessionID]*session, len(d.sessions)) | |
| 91 | + | maps.Copy(registry, d.sessions) | |
| 92 | + | state := moveState{ | |
| 93 | + | closing: d.closing, | |
| 94 | + | registry: registry, | |
| 95 | + | source: captureMoveSessionStateLocked(source), | |
| 96 | + | destination: captureMoveSessionStateLocked(destination), | |
| 97 | + | sourceTab: captureMoveTabStateLocked(sourceTab), | |
| 98 | + | destinationTab: captureMoveTabStateLocked(destinationTab), | |
| 99 | + | movedPane: movePaneState{ | |
| 100 | + | id: movedPane.id, | |
| 101 | + | stableID: movedPane.stableID, | |
| 102 | + | owner: movedPane.ownerSnapshot(), | |
| 103 | + | ownerGeneration: movedPane.ownerGeneration, | |
| 104 | + | ctxDone: moveContextDone(movedPane.ctx), | |
| 105 | + | }, | |
| 106 | + | client: clientState, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | movedPane.mu.Unlock() | |
| 110 | + | unlockTabs() | |
| 111 | + | unlockSessions() | |
| 112 | + | d.notices.routingMu.Unlock() | |
| 113 | + | d.mu.Unlock() | |
| 114 | + | return state | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func captureMoveSessionStateLocked(sess *session) moveSessionState { | |
| 118 | + | snatched := make(map[*attachedClient]struct{}, len(sess.snatched)) | |
| 119 | + | maps.Copy(snatched, sess.snatched) | |
| 120 | + | return moveSessionState{ | |
| 121 | + | ptr: sess, | |
| 122 | + | id: sess.id, | |
| 123 | + | name: sess.name, | |
| 124 | + | incarnation: sess.incarnation, | |
| 125 | + | tabs: append([]*tab(nil), sess.tabs...), | |
| 126 | + | active: sess.active, | |
| 127 | + | client: sess.client, | |
| 128 | + | snatched: snatched, | |
| 129 | + | moveReservations: sess.moveReservations, | |
| 130 | + | snapshotGeneration: sess.snapshotGeneration, | |
| 131 | + | snapshotPublished: sess.snapshotPublishedGeneration, | |
| 132 | + | snapshotDirty: sess.snapDirty.Load(), | |
| 133 | + | snapshotEligible: sess.snapEligible.Load(), | |
| 134 | + | ctxDone: moveContextDone(sess.ctx), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func captureMoveTabStateLocked(tb *tab) moveTabState { | |
| 139 | + | panes := make(map[layout.PaneID]*pane, len(tb.panes)) | |
| 140 | + | maps.Copy(panes, tb.panes) | |
| 141 | + | return moveTabState{ | |
| 142 | + | ptr: tb, | |
| 143 | + | stableID: tb.stableID, | |
| 144 | + | tree: tb.tree.Clone(), | |
| 145 | + | panes: panes, | |
| 146 | + | nextPaneID: tb.nextPaneID, | |
| 147 | + | layoutGeneration: tb.layoutGeneration, | |
| 148 | + | floatingState: tb.floating.state, | |
| 149 | + | floatingPane: tb.floating.pane, | |
| 150 | + | floatingVisible: tb.floating.desiredVisible, | |
| 151 | + | floatingGeneration: tb.floating.generation, | |
| 152 | + | ctxDone: moveContextDone(tb.ctx), | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // captureMoveClientState snapshots coordinator and role-effect state before | |
| 157 | + | // callers acquire daemon/session/tab move-state locks. This keeps coordinator | |
| 158 | + | // and role-gate locks above, never beneath, the outer move fixture locks. | |
| 159 | + | func captureMoveClientState(source, destination *session, client *attachedClient) moveClientState { | |
| 160 | + | sourceLease := captureMoveLeaseState(source, client) | |
| 161 | + | destinationLease := captureMoveLeaseState(destination, client) | |
| 162 | + | roleEffectPhase := captureMoveRoleEffectPhase(client) | |
| 163 | + | return moveClientState{ | |
| 164 | + | transport: client.transportSnapshot(), | |
| 165 | + | roleGeneration: client.roleGeneration.Load(), | |
| 166 | + | currentSession: client.currentSession(), | |
| 167 | + | sourceRole: source.attachmentRole(client), | |
| 168 | + | destinationRole: destination.attachmentRole(client), | |
| 169 | + | sourceLease: sourceLease, | |
| 170 | + | destinationLease: destinationLease, | |
| 171 | + | roleEffectPhase: roleEffectPhase, | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState { | |
| 176 | + | coordinator := sess.renderCoordinator() | |
| 177 | + | if coordinator == nil { | |
| 178 | + | return moveLeaseState{} | |
| 179 | + | } | |
| 180 | + | coordinator.mu.Lock() | |
| 181 | + | defer coordinator.mu.Unlock() | |
| 182 | + | lease := coordinator.lease | |
| 183 | + | if lease == nil || lease.attachment != client { | |
| 184 | + | return moveLeaseState{lease: lease} | |
| 185 | + | } | |
| 186 | + | return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active} | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase { | |
| 190 | + | client.roleEffects.mu.Lock() | |
| 191 | + | defer client.roleEffects.mu.Unlock() | |
| 192 | + | return client.roleEffects.phase | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func moveContextDone(ctx context.Context) bool { | |
| 196 | + | return ctx != nil && ctx.Err() != nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 200 | + | p1, release1 := newBlockingPTY(t) | |
| 201 | + | p2, release2 := newBlockingPTY(t) | |
| 202 | + | defer release1() | |
| 203 | + | defer release2() | |
| 204 | + | ||
| 205 | + | d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1) | |
| 206 | + | sourceTab := source.tabs[0] | |
| 207 | + | sourceTab.stableID = "source-tab" | |
| 208 | + | movedPane := sourceTab.focusedPane() | |
| 209 | + | ||
| 210 | + | destination := &session{ | |
| 211 | + | id: "destination", | |
| 212 | + | name: "destination", | |
| 213 | + | ephemeral: true, | |
| 214 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 215 | + | active: 0, | |
| 216 | + | } | |
| 217 | + | destinationTab := destination.tabs[0] | |
| 218 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 219 | + | d.mu.Lock() | |
| 220 | + | d.sessions[destination.id] = destination | |
| 221 | + | d.mu.Unlock() | |
| 222 | + | require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true)) | |
| 223 | + | ||
| 224 | + | before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 225 | + | originalTransport := before.client.transport | |
| 226 | + | staleTransport := &closeTrackingTransport{} | |
| 227 | + | staleInjected := make(chan struct{}) | |
| 228 | + | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 229 | + | if action != "move-pane" || client != sourceClient { | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | client.replaceTransport(staleTransport) | |
| 233 | + | close(staleInjected) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | moveDone := make(chan error, 1) | |
| 237 | + | go func() { | |
| 238 | + | moveDone <- d.movePane(movePaneRequest{ | |
| 239 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 240 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 241 | + | SourcePaneID: domain.PaneStableID(movedPane.stableID), | |
| 242 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 243 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 244 | + | }) | |
| 245 | + | }() | |
| 246 | + | <-staleInjected | |
| 247 | + | err := <-moveDone | |
| 248 | + | ||
| 249 | + | // Restore the exact test-injected transport incarnation before comparing the | |
| 250 | + | // complete state. The move itself must not be able to alter this state. | |
| 251 | + | sourceClient.linkMu.Lock() | |
| 252 | + | sourceClient.tr = originalTransport.transport | |
| 253 | + | sourceClient.transportIncarnation = originalTransport.incarnation | |
| 254 | + | sourceClient.linkMu.Unlock() | |
| 255 | + | d.afterRoleEffectGateFrozen = nil | |
| 256 | + | ||
| 257 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 258 | + | after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 259 | + | require.Equal(t, before, after, "stale handoff validation must reject before any move publication") | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | type moveTabHandoffState struct { | |
| 263 | + | closing bool | |
| 264 | + | registry map[domain.SessionID]*session | |
| 265 | + | source moveSessionState | |
| 266 | + | destination moveSessionState | |
| 267 | + | movedTab moveTabState | |
| 268 | + | follower moveClientState | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState { | |
| 272 | + | followerState := captureMoveClientState(source, destination, follower) | |
| 273 | + | d.mu.Lock() | |
| 274 | + | d.notices.routingMu.Lock() | |
| 275 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 276 | + | moved.mu.Lock() | |
| 277 | + | ||
| 278 | + | registry := make(map[domain.SessionID]*session, len(d.sessions)) | |
| 279 | + | maps.Copy(registry, d.sessions) | |
| 280 | + | state := moveTabHandoffState{ | |
| 281 | + | closing: d.closing, | |
| 282 | + | registry: registry, | |
| 283 | + | source: captureMoveSessionStateLocked(source), | |
| 284 | + | destination: captureMoveSessionStateLocked(destination), | |
| 285 | + | movedTab: captureMoveTabStateLocked(moved), | |
| 286 | + | follower: followerState, | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | moved.mu.Unlock() | |
| 290 | + | unlockSessions() | |
| 291 | + | d.notices.routingMu.Unlock() | |
| 292 | + | d.mu.Unlock() | |
| 293 | + | return state | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 297 | + | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 298 | + | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 299 | + | floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx) | |
| 300 | + | t.Cleanup(floating.cancel) | |
| 301 | + | moved.mu.Lock() | |
| 302 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9} | |
| 303 | + | moved.mu.Unlock() | |
| 304 | + | publishPaneOwner(floating, source, moved, 9) | |
| 305 | + | ||
| 306 | + | before := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 307 | + | originalTransport := before.follower.transport | |
| 308 | + | staleTransport := &closeTrackingTransport{} | |
| 309 | + | staleInjected := make(chan struct{}) | |
| 310 | + | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 311 | + | if action != "move-tab" || client != follower { | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | client.replaceTransport(staleTransport) | |
| 315 | + | close(staleInjected) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | moveDone := make(chan error, 1) | |
| 319 | + | go func() { | |
| 320 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 321 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 322 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 323 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 324 | + | }) | |
| 325 | + | }() | |
| 326 | + | <-staleInjected | |
| 327 | + | err := <-moveDone | |
| 328 | + | ||
| 329 | + | follower.linkMu.Lock() | |
| 330 | + | follower.tr = originalTransport.transport | |
| 331 | + | follower.transportIncarnation = originalTransport.incarnation | |
| 332 | + | follower.linkMu.Unlock() | |
| 333 | + | d.afterRoleEffectGateFrozen = nil | |
| 334 | + | ||
| 335 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 336 | + | after := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 337 | + | require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication") | |
| 338 | + | } | |
A internal/usecase/daemon/move_lifecycle.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "errors" | |
| 6 | + | "slices" | |
| 7 | + | "sync" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var errMoveLifecycleUnavailable = errors.New("move lifecycle is no longer available") | |
| 11 | + | ||
| 12 | + | // moveLifecycleReservation pins a unique, canonically ordered set of sessions | |
| 13 | + | // plus the daemon move gate. Release is idempotent so every abort path can use | |
| 14 | + | // defer without coordinating ownership with its caller. | |
| 15 | + | type moveLifecycleReservation struct { | |
| 16 | + | daemon *Daemon | |
| 17 | + | sessions []*session | |
| 18 | + | once sync.Once | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // reserveMoveLifecycles admits a move at the daemon gate before touching any | |
| 22 | + | // per-session lifecycle state. All session counts publish atomically while the | |
| 23 | + | // complete ordered teardown lock set is held. | |
| 24 | + | func (d *Daemon) reserveMoveLifecycles(source, destination *session) (*moveLifecycleReservation, error) { | |
| 25 | + | if d == nil || source == nil || destination == nil { | |
| 26 | + | return nil, errMoveLifecycleUnavailable | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | d.moveLifecycleMu.Lock() | |
| 30 | + | if d.moveLifecycleClosing { | |
| 31 | + | d.moveLifecycleMu.Unlock() | |
| 32 | + | return nil, errMoveLifecycleUnavailable | |
| 33 | + | } | |
| 34 | + | d.mu.Lock() | |
| 35 | + | closing := d.closing | |
| 36 | + | d.mu.Unlock() | |
| 37 | + | if closing { | |
| 38 | + | d.moveLifecycleMu.Unlock() | |
| 39 | + | return nil, errMoveLifecycleUnavailable | |
| 40 | + | } | |
| 41 | + | d.moveLifecycleActive++ | |
| 42 | + | d.signalMoveLifecycleChangedLocked() | |
| 43 | + | d.moveLifecycleMu.Unlock() | |
| 44 | + | if d.afterMoveLifecycleGateBeforeTeardownLocks != nil { | |
| 45 | + | d.afterMoveLifecycleGateBeforeTeardownLocks() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | sessions := uniqueMoveSessions(source, destination) | |
| 49 | + | for _, sess := range sessions { | |
| 50 | + | sess.teardownMu.Lock() | |
| 51 | + | } | |
| 52 | + | for _, sess := range sessions { | |
| 53 | + | if sess.teardownActive { | |
| 54 | + | unlockMoveSessions(sessions) | |
| 55 | + | d.moveLifecycleMu.Lock() | |
| 56 | + | d.moveLifecycleActive-- | |
| 57 | + | d.signalMoveLifecycleChangedLocked() | |
| 58 | + | d.moveLifecycleMu.Unlock() | |
| 59 | + | return nil, errMoveLifecycleUnavailable | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | for _, sess := range sessions { | |
| 63 | + | sess.moveReservations++ | |
| 64 | + | sess.signalTeardownChangedLocked() | |
| 65 | + | } | |
| 66 | + | unlockMoveSessions(sessions) | |
| 67 | + | ||
| 68 | + | return &moveLifecycleReservation{daemon: d, sessions: sessions}, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func uniqueMoveSessions(source, destination *session) []*session { | |
| 72 | + | sessions := []*session{source} | |
| 73 | + | if destination != source { | |
| 74 | + | sessions = append(sessions, destination) | |
| 75 | + | } | |
| 76 | + | slices.SortFunc(sessions, func(left, right *session) int { | |
| 77 | + | return cmp.Compare(left.id, right.id) | |
| 78 | + | }) | |
| 79 | + | return sessions | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func unlockMoveSessions(sessions []*session) { | |
| 83 | + | for i := len(sessions) - 1; i >= 0; i-- { | |
| 84 | + | sessions[i].teardownMu.Unlock() | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (r *moveLifecycleReservation) Release() { | |
| 89 | + | if r == nil { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | r.once.Do(func() { | |
| 93 | + | r.daemon.moveLifecycleMu.Lock() | |
| 94 | + | for _, sess := range r.sessions { | |
| 95 | + | sess.teardownMu.Lock() | |
| 96 | + | } | |
| 97 | + | for _, sess := range r.sessions { | |
| 98 | + | sess.moveReservations-- | |
| 99 | + | sess.signalTeardownChangedLocked() | |
| 100 | + | } | |
| 101 | + | unlockMoveSessions(r.sessions) | |
| 102 | + | r.daemon.moveLifecycleActive-- | |
| 103 | + | r.daemon.signalMoveLifecycleChangedLocked() | |
| 104 | + | r.daemon.moveLifecycleMu.Unlock() | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary | |
| 109 | + | // routing, then drains reservations without holding either daemon mutex. Pane | |
| 110 | + | // process lifetime is cancelled only after the final reservation releases. | |
| 111 | + | func (d *Daemon) closeMoveLifecycles() { | |
| 112 | + | if d == nil { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | d.moveLifecycleMu.Lock() | |
| 116 | + | if !d.moveLifecycleClosing { | |
| 117 | + | d.moveLifecycleClosing = true | |
| 118 | + | d.signalMoveLifecycleChangedLocked() | |
| 119 | + | } | |
| 120 | + | d.moveLifecycleMu.Unlock() | |
| 121 | + | ||
| 122 | + | // Publish ordinary shutdown admission separately: no move gate mutex is held | |
| 123 | + | // while taking the architecture registry lock. | |
| 124 | + | d.mu.Lock() | |
| 125 | + | d.closing = true | |
| 126 | + | d.mu.Unlock() | |
| 127 | + | ||
| 128 | + | d.moveLifecycleMu.Lock() | |
| 129 | + | for d.moveLifecycleActive != 0 { | |
| 130 | + | changed := d.moveLifecycleChangeLocked() | |
| 131 | + | d.moveLifecycleMu.Unlock() | |
| 132 | + | <-changed | |
| 133 | + | d.moveLifecycleMu.Lock() | |
| 134 | + | } | |
| 135 | + | cancel := d.paneProcessCancel | |
| 136 | + | d.moveLifecycleMu.Unlock() | |
| 137 | + | if cancel != nil { | |
| 138 | + | cancel() | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (d *Daemon) moveLifecycleChangeLocked() chan struct{} { | |
| 143 | + | if d.moveLifecycleChanged == nil { | |
| 144 | + | d.moveLifecycleChanged = make(chan struct{}) | |
| 145 | + | } | |
| 146 | + | return d.moveLifecycleChanged | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (d *Daemon) signalMoveLifecycleChangedLocked() { | |
| 150 | + | close(d.moveLifecycleChangeLocked()) | |
| 151 | + | d.moveLifecycleChanged = make(chan struct{}) | |
| 152 | + | } | |
A internal/usecase/daemon/move_lifecycle_test.go +253 -0
| @@ -0,0 +1,253 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) { | |
| 13 | + | t.Helper() | |
| 14 | + | for { | |
| 15 | + | sess.teardownMu.Lock() | |
| 16 | + | gotReservations := sess.moveReservations | |
| 17 | + | gotWaiters := sess.teardownWaiters | |
| 18 | + | changed := sess.teardownChangeLocked() | |
| 19 | + | sess.teardownMu.Unlock() | |
| 20 | + | if gotReservations == reservations && gotWaiters == waiters { | |
| 21 | + | return | |
| 22 | + | } | |
| 23 | + | select { | |
| 24 | + | case <-changed: | |
| 25 | + | case <-time.After(testWaitTimeout): | |
| 26 | + | t.Fatalf("move reservations/waiters = %d/%d, want %d/%d", gotReservations, gotWaiters, reservations, waiters) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func awaitDaemonMoveActive(t *testing.T, d *Daemon, want uint) { | |
| 32 | + | t.Helper() | |
| 33 | + | for { | |
| 34 | + | d.moveLifecycleMu.Lock() | |
| 35 | + | active := d.moveLifecycleActive | |
| 36 | + | changed := d.moveLifecycleChangeLocked() | |
| 37 | + | d.moveLifecycleMu.Unlock() | |
| 38 | + | if active == want { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | select { | |
| 42 | + | case <-changed: | |
| 43 | + | case <-time.After(testWaitTimeout): | |
| 44 | + | t.Fatalf("active move lifecycles = %d, want %d", active, want) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func awaitDaemonMoveClosing(t *testing.T, d *Daemon) { | |
| 50 | + | t.Helper() | |
| 51 | + | for { | |
| 52 | + | d.moveLifecycleMu.Lock() | |
| 53 | + | closing := d.moveLifecycleClosing | |
| 54 | + | changed := d.moveLifecycleChangeLocked() | |
| 55 | + | d.moveLifecycleMu.Unlock() | |
| 56 | + | if closing { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | select { | |
| 60 | + | case <-changed: | |
| 61 | + | case <-time.After(testWaitTimeout): | |
| 62 | + | t.Fatal("daemon move lifecycle gate did not begin closing") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) { | |
| 68 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 69 | + | sess := &session{id: "source"} | |
| 70 | + | ||
| 71 | + | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | awaitMoveTeardownState(t, sess, 1, 0) | |
| 74 | + | ||
| 75 | + | teardown := make(chan bool, 1) | |
| 76 | + | go func() { teardown <- sess.beginTeardown(nil) }() | |
| 77 | + | awaitMoveTeardownState(t, sess, 1, 1) | |
| 78 | + | select { | |
| 79 | + | case <-teardown: | |
| 80 | + | t.Fatal("teardown claimed ownership while the move lifecycle was reserved") | |
| 81 | + | default: | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | reservation.Release() | |
| 85 | + | require.True(t, awaitTestValue(t, teardown, "teardown did not wake after move release")) | |
| 86 | + | sess.finishTeardown() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) { | |
| 90 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 91 | + | first := &session{id: "a"} | |
| 92 | + | second := &session{id: "b"} | |
| 93 | + | ||
| 94 | + | // Both opposite-direction reservations must block on a before either takes | |
| 95 | + | // b. This proves order from immutable IDs rather than argument direction. | |
| 96 | + | first.teardownMu.Lock() | |
| 97 | + | beforeTeardownLocks := make(chan struct{}, 2) | |
| 98 | + | d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} } | |
| 99 | + | defer func() { d.afterMoveLifecycleGateBeforeTeardownLocks = nil }() | |
| 100 | + | results := make(chan *moveLifecycleReservation, 2) | |
| 101 | + | errs := make(chan error, 2) | |
| 102 | + | for _, pair := range [][2]*session{{first, second}, {second, first}} { | |
| 103 | + | go func() { | |
| 104 | + | reservation, err := d.reserveMoveLifecycles(pair[0], pair[1]) | |
| 105 | + | results <- reservation | |
| 106 | + | errs <- err | |
| 107 | + | }() | |
| 108 | + | } | |
| 109 | + | awaitDaemonMoveActive(t, d, 2) | |
| 110 | + | for range 2 { | |
| 111 | + | awaitTestCompletion(t, beforeTeardownLocks, "move did not reach ordered teardown-lock admission") | |
| 112 | + | } | |
| 113 | + | require.True(t, second.teardownMu.TryLock(), "opposite move locked b before the shared lowest-ID session") | |
| 114 | + | second.teardownMu.Unlock() | |
| 115 | + | first.teardownMu.Unlock() | |
| 116 | + | ||
| 117 | + | for range 2 { | |
| 118 | + | require.NoError(t, awaitTestValue(t, errs, "opposite move reservation did not finish")) | |
| 119 | + | reservation := awaitTestValue(t, results, "opposite move reservation token missing") | |
| 120 | + | require.NotNil(t, reservation) | |
| 121 | + | reservation.Release() | |
| 122 | + | } | |
| 123 | + | awaitDaemonMoveActive(t, d, 0) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.T) { | |
| 127 | + | for _, teardownSession := range []string{"source", "destination"} { | |
| 128 | + | t.Run(teardownSession, func(t *testing.T) { | |
| 129 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 130 | + | source := &session{id: "source"} | |
| 131 | + | destination := &session{id: "destination"} | |
| 132 | + | tearingDown := source | |
| 133 | + | if teardownSession == "destination" { | |
| 134 | + | tearingDown = destination | |
| 135 | + | } | |
| 136 | + | require.True(t, tearingDown.beginTeardown(nil)) | |
| 137 | + | ||
| 138 | + | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 139 | + | require.ErrorIs(t, err, errMoveLifecycleUnavailable) | |
| 140 | + | require.Nil(t, reservation) | |
| 141 | + | awaitMoveTeardownState(t, source, 0, 0) | |
| 142 | + | awaitMoveTeardownState(t, destination, 0, 0) | |
| 143 | + | awaitDaemonMoveActive(t, d, 0) | |
| 144 | + | tearingDown.finishTeardown() | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) { | |
| 150 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 151 | + | sess := &session{id: "same"} | |
| 152 | + | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | awaitMoveTeardownState(t, sess, 1, 0) | |
| 155 | + | ||
| 156 | + | const releasers = 8 | |
| 157 | + | released := make(chan struct{}, releasers) | |
| 158 | + | for range releasers { | |
| 159 | + | go func() { | |
| 160 | + | reservation.Release() | |
| 161 | + | released <- struct{}{} | |
| 162 | + | }() | |
| 163 | + | } | |
| 164 | + | for range releasers { | |
| 165 | + | awaitTestCompletion(t, released, "concurrent reservation release did not return") | |
| 166 | + | } | |
| 167 | + | awaitMoveTeardownState(t, sess, 0, 0) | |
| 168 | + | awaitDaemonMoveActive(t, d, 0) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) { | |
| 172 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 173 | + | sess := &session{id: "source"} | |
| 174 | + | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | |
| 177 | + | ||
| 178 | + | teardown := make(chan bool, 1) | |
| 179 | + | go func() { teardown <- sess.beginTeardown(deadline) }() | |
| 180 | + | awaitMoveTeardownState(t, sess, 1, 1) | |
| 181 | + | close(deadline.done) | |
| 182 | + | require.False(t, awaitTestValue(t, teardown, "cancelled teardown waiter did not return")) | |
| 183 | + | awaitMoveTeardownState(t, sess, 1, 0) | |
| 184 | + | awaitDaemonMoveActive(t, d, 1) | |
| 185 | + | ||
| 186 | + | reservation.Release() | |
| 187 | + | awaitMoveTeardownState(t, sess, 0, 0) | |
| 188 | + | awaitDaemonMoveActive(t, d, 0) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) { | |
| 192 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 193 | + | sess := &session{id: "source"} | |
| 194 | + | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | ||
| 197 | + | claimed := make(chan struct{}, 2) | |
| 198 | + | finish := make(chan struct{}, 2) | |
| 199 | + | done := make(chan struct{}, 2) | |
| 200 | + | for range 2 { | |
| 201 | + | go func() { | |
| 202 | + | if sess.beginTeardown(nil) { | |
| 203 | + | claimed <- struct{}{} | |
| 204 | + | <-finish | |
| 205 | + | sess.finishTeardown() | |
| 206 | + | } | |
| 207 | + | done <- struct{}{} | |
| 208 | + | }() | |
| 209 | + | } | |
| 210 | + | awaitMoveTeardownState(t, sess, 1, 2) | |
| 211 | + | reservation.Release() | |
| 212 | + | for range 2 { | |
| 213 | + | awaitTestCompletion(t, claimed, "teardown waiter did not claim ownership") | |
| 214 | + | finish <- struct{}{} | |
| 215 | + | } | |
| 216 | + | for range 2 { | |
| 217 | + | awaitTestCompletion(t, done, "teardown waiter did not complete") | |
| 218 | + | } | |
| 219 | + | awaitMoveTeardownState(t, sess, 0, 0) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) { | |
| 223 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 224 | + | source := &session{id: "source"} | |
| 225 | + | destination := &session{id: "destination"} | |
| 226 | + | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | paneLifetime := d.paneProcessCtx | |
| 229 | + | require.NotNil(t, paneLifetime) | |
| 230 | + | ||
| 231 | + | shutdown := make(chan bool, 1) | |
| 232 | + | go func() { shutdown <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 233 | + | awaitDaemonMoveClosing(t, d) | |
| 234 | + | select { | |
| 235 | + | case <-paneLifetime.Done(): | |
| 236 | + | t.Fatal("shutdown cancelled transferable pane lifetime before the move released") | |
| 237 | + | default: | |
| 238 | + | } | |
| 239 | + | select { | |
| 240 | + | case <-shutdown: | |
| 241 | + | t.Fatal("shutdown completed while a move lifecycle remained reserved") | |
| 242 | + | default: | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | reservation.Release() | |
| 246 | + | awaitTestCompletion(t, paneLifetime.Done(), "shutdown did not cancel pane lifetime after move drain") | |
| 247 | + | require.False(t, awaitTestValue(t, shutdown, "shutdown did not continue after move drain")) | |
| 248 | + | ||
| 249 | + | reservation, err = d.reserveMoveLifecycles(source, destination) | |
| 250 | + | require.ErrorIs(t, err, errMoveLifecycleUnavailable) | |
| 251 | + | require.Nil(t, reservation) | |
| 252 | + | awaitDaemonMoveActive(t, d, 0) | |
| 253 | + | } | |
A internal/usecase/daemon/move_pane_admission.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // movePaneAdmission is an immutable snapshot of the exact live objects and | |
| 8 | + | // generations a move may later commit. Resize fences are acquired only after | |
| 9 | + | // this snapshot; the locked commit revalidates every field before publication. | |
| 10 | + | type movePaneAdmission struct { | |
| 11 | + | source *session | |
| 12 | + | destination *session | |
| 13 | + | sourceTab *tab | |
| 14 | + | destinationTab *tab | |
| 15 | + | movedPane *pane | |
| 16 | + | sourceClient *attachedClient | |
| 17 | + | destinationClient *attachedClient | |
| 18 | + | sourceSnatched []*attachedClient | |
| 19 | + | sourceGeneration uint64 | |
| 20 | + | destinationGeneration uint64 | |
| 21 | + | finalSourceTab bool | |
| 22 | + | sourceTabWasActive bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) { | |
| 26 | + | d.mu.Lock() | |
| 27 | + | defer d.mu.Unlock() | |
| 28 | + | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 29 | + | return nil, errMoveStaleTarget | |
| 30 | + | } | |
| 31 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 32 | + | defer unlockSessions() | |
| 33 | + | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 34 | + | return nil, errMoveStaleTarget | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | sourceTab := findMoveTabLocked(source, req.SourceTabID) | |
| 38 | + | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | |
| 39 | + | if sourceTab == nil || destinationTab == nil { | |
| 40 | + | return nil, errMoveStaleTarget | |
| 41 | + | } | |
| 42 | + | if sourceTab == destinationTab { | |
| 43 | + | return nil, errMovePaneInvalid | |
| 44 | + | } | |
| 45 | + | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 46 | + | defer unlockTabs() | |
| 47 | + | ||
| 48 | + | var movedPane *pane | |
| 49 | + | for _, candidate := range sourceTab.panes { | |
| 50 | + | if candidate != nil && candidate.stableID == string(req.SourcePaneID) { | |
| 51 | + | movedPane = candidate | |
| 52 | + | break | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | if movedPane == nil { | |
| 56 | + | return nil, errMoveStaleTarget | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | return &movePaneAdmission{ | |
| 60 | + | source: source, | |
| 61 | + | destination: destination, | |
| 62 | + | sourceTab: sourceTab, | |
| 63 | + | destinationTab: destinationTab, | |
| 64 | + | movedPane: movedPane, | |
| 65 | + | sourceClient: source.client, | |
| 66 | + | destinationClient: destination.client, | |
| 67 | + | sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 68 | + | sourceGeneration: sourceTab.layoutGeneration, | |
| 69 | + | destinationGeneration: destinationTab.layoutGeneration, | |
| 70 | + | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | |
| 71 | + | sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1, | |
| 72 | + | sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab, | |
| 73 | + | }, nil | |
| 74 | + | } | |
A internal/usecase/daemon/move_pane_candidate.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // movePaneCandidate contains only unpublished topology and identity changes. | |
| 13 | + | // The caller must revalidate the live tabs before committing these values. | |
| 14 | + | type movePaneCandidate struct { | |
| 15 | + | pane *pane | |
| 16 | + | ||
| 17 | + | sourceID layout.PaneID | |
| 18 | + | destinationID layout.PaneID | |
| 19 | + | removeSourceTab bool | |
| 20 | + | ||
| 21 | + | sourceTree *layout.Tree | |
| 22 | + | sourcePlacements []layout.Placement | |
| 23 | + | destinationTree *layout.Tree | |
| 24 | + | destinationPlacements []layout.Placement | |
| 25 | + | destinationNextPaneID int | |
| 26 | + | sourceGeneration uint64 | |
| 27 | + | destinationGeneration uint64 | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // prepareMovePaneCandidate builds both affected layouts without publishing any | |
| 31 | + | // tab or pane state. The caller is responsible for holding and later | |
| 32 | + | // revalidating the source and destination tab state. | |
| 33 | + | func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneCandidate, error) { | |
| 34 | + | if source == nil || destination == nil || source == destination || moved == nil || | |
| 35 | + | source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved || | |
| 36 | + | !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil || | |
| 37 | + | destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) { | |
| 38 | + | return nil, errMovePaneInvalid | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | candidate := &movePaneCandidate{ | |
| 42 | + | pane: moved, | |
| 43 | + | sourceID: moved.id, | |
| 44 | + | sourceGeneration: source.layoutGeneration, | |
| 45 | + | destinationGeneration: destination.layoutGeneration, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | sourceLeaves := layout.LeafIDs(source.tree.Root) | |
| 49 | + | if len(sourceLeaves) == 1 { | |
| 50 | + | switch source.floating.state { | |
| 51 | + | case floatingWarming: | |
| 52 | + | return nil, errMoveFloatingWarming | |
| 53 | + | case floatingHidden, floatingVisible: | |
| 54 | + | return nil, errMoveFinalSourceFloating | |
| 55 | + | } | |
| 56 | + | candidate.removeSourceTab = true | |
| 57 | + | } else { | |
| 58 | + | candidate.sourceTree = source.tree.Clone() | |
| 59 | + | if err := candidate.sourceTree.Close(moved.id); err != nil { | |
| 60 | + | return nil, errMovePaneInvalid | |
| 61 | + | } | |
| 62 | + | var ok bool | |
| 63 | + | candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source)) | |
| 64 | + | if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) { | |
| 65 | + | return nil, errMoveTooSmall | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | var err error | |
| 70 | + | candidate.destinationID, candidate.destinationNextPaneID, err = allocateMovePaneID(destination, moved.id) | |
| 71 | + | if err != nil { | |
| 72 | + | return nil, err | |
| 73 | + | } | |
| 74 | + | candidate.destinationTree = destination.tree.Clone() | |
| 75 | + | area := tabArea(destination) | |
| 76 | + | if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil { | |
| 77 | + | if errors.Is(err, layout.ErrTooSmall) { | |
| 78 | + | return nil, errMoveTooSmall | |
| 79 | + | } | |
| 80 | + | return nil, errMovePaneInvalid | |
| 81 | + | } | |
| 82 | + | var ok bool | |
| 83 | + | candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area) | |
| 84 | + | if !ok { | |
| 85 | + | return nil, errMoveTooSmall | |
| 86 | + | } | |
| 87 | + | return candidate, nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func allocateMovePaneID(destination *tab, sourceID layout.PaneID) (layout.PaneID, int, error) { | |
| 91 | + | next := max(destination.nextPaneID, 1) | |
| 92 | + | for { | |
| 93 | + | id := layout.PaneID(fmt.Sprintf("pane-%d", next)) | |
| 94 | + | _, mapped := destination.panes[id] | |
| 95 | + | available := id != sourceID && !mapped && !layout.ContainsLeaf(destination.tree.Root, id) | |
| 96 | + | if available { | |
| 97 | + | if next == math.MaxInt { | |
| 98 | + | return id, next, nil | |
| 99 | + | } | |
| 100 | + | return id, next + 1, nil | |
| 101 | + | } | |
| 102 | + | if next == math.MaxInt { | |
| 103 | + | return "", 0, errMovePaneIDExhausted | |
| 104 | + | } | |
| 105 | + | next++ | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func tabArea(tb *tab) domain.Rect { | |
| 110 | + | return domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 111 | + | } | |
A internal/usecase/daemon/move_pane_commit.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // movePaneCommit owns the single architecture-locked publication section. Its | |
| 9 | + | // inputs are immutable admission/fence snapshots; outputs form the postcommit | |
| 10 | + | // plan. publishLocked performs all fallible checks before its first write. | |
| 11 | + | type movePaneCommit struct { | |
| 12 | + | req movePaneRequest | |
| 13 | + | source, destination *session | |
| 14 | + | sourceTab, destinationTab *tab | |
| 15 | + | movedPane *pane | |
| 16 | + | sourceClient *attachedClient | |
| 17 | + | sourceSnatched []*attachedClient | |
| 18 | + | sourceGeneration uint64 | |
| 19 | + | destinationGeneration uint64 | |
| 20 | + | handoffFrozen bool | |
| 21 | + | frozenRoles frozenRoleEffectGates | |
| 22 | + | handoffReq attachmentTransitionRequest | |
| 23 | + | handoffPublication *attachmentPublication | |
| 24 | + | err error | |
| 25 | + | ||
| 26 | + | handoffResult attachmentTransitionResult | |
| 27 | + | syncCleanup syncTimerCleanup | |
| 28 | + | sourceCleanupToken attachmentRoleToken | |
| 29 | + | sourceEmpty bool | |
| 30 | + | sourceTabRemoved bool | |
| 31 | + | retiredParked []parkedAttachmentRetirement | |
| 32 | + | retiredAttachments []detachedAttachmentSnapshot | |
| 33 | + | sourceMetadata domain.CatalogueMetadataUpdate | |
| 34 | + | sourceMetadataValid bool | |
| 35 | + | destinationMetadata domain.CatalogueMetadataUpdate | |
| 36 | + | destinationMetadataValid bool | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (c *movePaneCommit) releasePublication() { | |
| 40 | + | if c != nil && c.handoffPublication != nil { | |
| 41 | + | c.handoffPublication.unlockCoordinators() | |
| 42 | + | c.handoffPublication = nil | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |
| 47 | + | var candidate *movePaneCandidate | |
| 48 | + | var retirement frozenMoveAttachmentRetirement | |
| 49 | + | var err error | |
| 50 | + | d.mu.Lock() | |
| 51 | + | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| 52 | + | d.mu.Unlock() | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | d.notices.routingMu.Lock() | |
| 56 | + | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |
| 57 | + | defer unlockSessions() | |
| 58 | + | defer d.notices.routingMu.Unlock() | |
| 59 | + | defer d.mu.Unlock() | |
| 60 | + | ||
| 61 | + | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | if c.handoffFrozen { | |
| 65 | + | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || | |
| 66 | + | c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) || | |
| 67 | + | c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | c.handoffPublication, err = d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| 71 | + | if err != nil { | |
| 72 | + | c.err = err | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | defer c.releasePublication() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab) | |
| 79 | + | c.movedPane.mu.Lock() | |
| 80 | + | defer c.movedPane.mu.Unlock() | |
| 81 | + | defer unlockTabs() | |
| 82 | + | ||
| 83 | + | if !moveTabMemberLocked(c.source, c.sourceTab) || !moveTabMemberLocked(c.destination, c.destinationTab) || c.sourceTab == c.destinationTab || | |
| 84 | + | c.sourceTab.layoutGeneration != c.sourceGeneration || c.destinationTab.layoutGeneration != c.destinationGeneration || | |
| 85 | + | c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane || | |
| 86 | + | c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) || | |
| 87 | + | c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil { | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | owner := c.movedPane.ownerSnapshot() | |
| 91 | + | if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) { | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane) | |
| 95 | + | if err != nil { | |
| 96 | + | c.err = err | |
| 97 | + | return false | |
| 98 | + | } | |
| 99 | + | if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration { | |
| 100 | + | return false | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | |
| 104 | + | if sourceWillEmpty && c.source != c.destination { | |
| 105 | + | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || | |
| 106 | + | (c.sourceClient != nil) != c.handoffFrozen { | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | var retirementOK bool | |
| 110 | + | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles) | |
| 111 | + | if !retirementOK { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Everything above is fallible. Topology, owner, lifecycle registry, and | |
| 117 | + | // optional attachment roles now become visible under one lock section. | |
| 118 | + | if candidate.removeSourceTab { | |
| 119 | + | idx := indexMoveTabLocked(c.source, c.sourceTab) | |
| 120 | + | if idx < 0 { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 124 | + | c.sourceTabRemoved = true | |
| 125 | + | if len(c.source.tabs) == 0 { | |
| 126 | + | c.source.active = -1 | |
| 127 | + | } else if c.source.active > idx { | |
| 128 | + | c.source.active-- | |
| 129 | + | } else if c.source.active >= len(c.source.tabs) { | |
| 130 | + | c.source.active = len(c.source.tabs) - 1 | |
| 131 | + | } | |
| 132 | + | } else { | |
| 133 | + | c.sourceTab.tree = candidate.sourceTree | |
| 134 | + | c.sourceTab.bumpLayoutGenerationLocked() | |
| 135 | + | } | |
| 136 | + | delete(c.sourceTab.panes, candidate.sourceID) | |
| 137 | + | if d.beforeMovePaneCommit != nil { | |
| 138 | + | d.beforeMovePaneCommit() | |
| 139 | + | } | |
| 140 | + | c.destinationTab.tree = candidate.destinationTree | |
| 141 | + | c.destinationTab.nextPaneID = candidate.destinationNextPaneID | |
| 142 | + | c.destinationTab.panes[candidate.destinationID] = c.movedPane | |
| 143 | + | c.destinationTab.bumpLayoutGenerationLocked() | |
| 144 | + | c.movedPane.id = candidate.destinationID | |
| 145 | + | oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0) | |
| 146 | + | if c.handoffPublication != nil { | |
| 147 | + | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |
| 148 | + | c.handoffPublication.unlockCoordinators() | |
| 149 | + | c.handoffPublication = nil | |
| 150 | + | } | |
| 151 | + | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | |
| 152 | + | if c.handoffResult.published.ac != nil { | |
| 153 | + | c.sourceCleanupToken = c.handoffResult.published | |
| 154 | + | } else if c.source.client != nil { | |
| 155 | + | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | |
| 159 | + | delete(d.sessions, c.source.id) | |
| 160 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 161 | + | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 162 | + | } | |
| 163 | + | c.sourceEmpty = len(c.source.tabs) == 0 | |
| 164 | + | if !c.source.ephemeral { | |
| 165 | + | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |
| 166 | + | c.sourceMetadataValid = true | |
| 167 | + | } | |
| 168 | + | if !c.destination.ephemeral && c.destination != c.source { | |
| 169 | + | c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |
| 170 | + | c.destinationMetadataValid = true | |
| 171 | + | } | |
| 172 | + | return true | |
| 173 | + | } | |
A internal/usecase/daemon/move_pane_postcommit.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // movePanePostcommitPlan is captured while the move's architecture and owner | |
| 13 | + | // fences still protect publication. It contains only immutable pointers, | |
| 14 | + | // generations, and exact attachment tokens needed by fallible follow-up work. | |
| 15 | + | func firstMovePane(panes []*pane) *pane { | |
| 16 | + | if len(panes) == 0 { | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | return panes[0] | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type movePanePostcommitPlan struct { | |
| 23 | + | source *session | |
| 24 | + | destination *session | |
| 25 | + | sourceTab *tab | |
| 26 | + | destinationTab *tab | |
| 27 | + | movedPane *pane | |
| 28 | + | movedPanes []*pane | |
| 29 | + | destinationClient *attachedClient | |
| 30 | + | sourceCleanupToken attachmentRoleToken | |
| 31 | + | handoffResult attachmentTransitionResult | |
| 32 | + | syncCleanup syncTimerCleanup | |
| 33 | + | frozenRoles frozenRoleEffectGates | |
| 34 | + | rolesFrozen bool | |
| 35 | + | unlockDispatch func() | |
| 36 | + | reservation *moveLifecycleReservation | |
| 37 | + | oldTabCancel context.CancelFunc | |
| 38 | + | ||
| 39 | + | sourceTabWasActive bool | |
| 40 | + | sourceTabRemoved bool | |
| 41 | + | sourceEmpty bool | |
| 42 | + | ||
| 43 | + | retiredParked []parkedAttachmentRetirement | |
| 44 | + | retiredAttachments []detachedAttachmentSnapshot | |
| 45 | + | sourceMetadata domain.CatalogueMetadataUpdate | |
| 46 | + | sourceMetadataValid bool | |
| 47 | + | destinationMetadata domain.CatalogueMetadataUpdate | |
| 48 | + | destinationMetadataValid bool | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // execute runs only after every resize-owner fence and architecture lock is | |
| 52 | + | // released. Generation-bound tokens make stale effects drop rather than route | |
| 53 | + | // back to the retired source. | |
| 54 | + | func (p movePanePostcommitPlan) execute(d *Daemon) { | |
| 55 | + | if p.oldTabCancel != nil { | |
| 56 | + | p.oldTabCancel() | |
| 57 | + | } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil { | |
| 58 | + | p.sourceTab.cancel() | |
| 59 | + | } | |
| 60 | + | if p.sourceCleanupToken.current() { | |
| 61 | + | cleanupClient := p.sourceCleanupToken.ac | |
| 62 | + | panes := p.movedPanes | |
| 63 | + | if len(panes) == 0 && p.movedPane != nil { | |
| 64 | + | panes = []*pane{p.movedPane} | |
| 65 | + | } | |
| 66 | + | for _, movedPane := range panes { | |
| 67 | + | if cleanupClient.overlays != nil { | |
| 68 | + | cleanupClient.overlays.clearCopyModeForPane(movedPane) | |
| 69 | + | } | |
| 70 | + | cleanupClient.pruneCaptureFrames(movedPane) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Snapshot dirtiness is admitted while lifecycle reservations still pin | |
| 75 | + | // both sessions. Repository publication remains outside every lock below. | |
| 76 | + | markSnapshotDirty(p.destination) | |
| 77 | + | if !p.sourceEmpty { | |
| 78 | + | markSnapshotDirty(p.source) | |
| 79 | + | } | |
| 80 | + | p.syncCleanup.finish() | |
| 81 | + | if p.rolesFrozen { | |
| 82 | + | p.frozenRoles.unfreeze() | |
| 83 | + | } | |
| 84 | + | p.unlockDispatch() | |
| 85 | + | p.reservation.Release() | |
| 86 | + | ||
| 87 | + | if p.sourceTabWasActive && !moveSessionRetired(d, p.source) { | |
| 88 | + | d.activateTab(p.source, p.source.activeTab()) | |
| 89 | + | } | |
| 90 | + | if !moveSessionRetired(d, p.source) { | |
| 91 | + | sourceLayoutTab := p.sourceTab | |
| 92 | + | if p.sourceTabRemoved { | |
| 93 | + | sourceLayoutTab = p.source.activeTab() | |
| 94 | + | } | |
| 95 | + | if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab { | |
| 96 | + | d.applyTabLayout(p.source, sourceLayoutTab) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | d.applyTabLayout(p.destination, p.destinationTab) | |
| 100 | + | if p.sourceMetadataValid && !p.sourceEmpty { | |
| 101 | + | d.markCatalogueDirty(p.sourceMetadata) | |
| 102 | + | } | |
| 103 | + | if p.destinationMetadataValid { | |
| 104 | + | d.markCatalogueDirty(p.destinationMetadata) | |
| 105 | + | } | |
| 106 | + | if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) { | |
| 107 | + | d.invalidateRender(p.destination, p.destinationClient, true, "move.go") | |
| 108 | + | } | |
| 109 | + | if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) { | |
| 110 | + | d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go") | |
| 111 | + | } | |
| 112 | + | if p.handoffResult.published.ac != nil { | |
| 113 | + | follower := p.handoffResult.published.ac | |
| 114 | + | d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false) | |
| 115 | + | follower.recordPreviousSession(p.source) | |
| 116 | + | d.deferAttachmentTransitionCleanups(p.handoffResult) | |
| 117 | + | d.firstPaintForTransition(p.handoffResult.published) | |
| 118 | + | } | |
| 119 | + | if p.sourceEmpty { | |
| 120 | + | retireEmptySessionAfterMove(d, p.source) | |
| 121 | + | } | |
| 122 | + | for _, attachment := range p.retiredAttachments { | |
| 123 | + | d.unregisterPreview(attachment.ac) | |
| 124 | + | attachment.ac.clearPreviousSession() | |
| 125 | + | attachment.ac.clearCaptureFrames() | |
| 126 | + | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | |
| 127 | + | } | |
| 128 | + | for _, retirement := range p.retiredParked { | |
| 129 | + | if retirement.parked == nil || retirement.parked.ac == nil { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | d.unregisterPreview(retirement.parked.ac) | |
| 133 | + | retirement.parked.ac.clearCaptureFrames() | |
| 134 | + | } | |
| 135 | + | finishParkedAttachmentRetirements(p.retiredParked) | |
| 136 | + | if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled { | |
| 137 | + | if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil { | |
| 138 | + | if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil { | |
| 139 | + | d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // retireEmptyMoveSessionLocked removes all source-owned attachment roles that | |
| 146 | + | // were not transferred. It runs inside the move commit, after the moved pane | |
| 147 | + | // has already acquired its destination owner, and performs no external work. | |
| 148 | + | func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot { | |
| 149 | + | if sess == nil || sess.client != nil { | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients)) | |
| 153 | + | for _, ac := range retirement.clients { | |
| 154 | + | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | |
| 155 | + | delete(sess.snatched, ac) | |
| 156 | + | ac.roleGeneration.Add(1) | |
| 157 | + | ac.setSession(nil) | |
| 158 | + | ac.invalidateFrozenRoleCapability() | |
| 159 | + | } | |
| 160 | + | sess.tabs = nil | |
| 161 | + | sess.active = -1 | |
| 162 | + | return retired | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func moveSessionRetired(d *Daemon, sess *session) bool { | |
| 166 | + | if d == nil || sess == nil { | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | d.mu.Lock() | |
| 170 | + | _, live := d.sessions[sess.id] | |
| 171 | + | d.mu.Unlock() | |
| 172 | + | return !live | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func destinationTabIsActive(sess *session, target *tab) bool { | |
| 176 | + | if sess == nil || target == nil { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | sess.mu.Lock() | |
| 180 | + | defer sess.mu.Unlock() | |
| 181 | + | return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func sessionClientIs(sess *session, client *attachedClient) bool { | |
| 185 | + | if sess == nil || client == nil { | |
| 186 | + | return false | |
| 187 | + | } | |
| 188 | + | sess.mu.Lock() | |
| 189 | + | defer sess.mu.Unlock() | |
| 190 | + | return sess.client == client | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // retireEmptySessionAfterMove completes only source-owned lifecycle work. The | |
| 194 | + | // moved pane has already been removed from source.tabs, so stopInMemoryLifecycle | |
| 195 | + | // cannot cancel or close the transferred PTY. | |
| 196 | + | func retireEmptySessionAfterMove(d *Daemon, sess *session) { | |
| 197 | + | if d == nil || sess == nil { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | sess.stopInMemoryLifecycle() | |
| 201 | + | if coordinator := sess.renderCoordinator(); coordinator != nil { | |
| 202 | + | coordinator.beginSessionTeardown().finish() | |
| 203 | + | coordinator.waitForTimerWorkers() | |
| 204 | + | } | |
| 205 | + | sess.mu.Lock() | |
| 206 | + | clipFiles := append([]string(nil), sess.clipFiles...) | |
| 207 | + | sess.clipFiles = nil | |
| 208 | + | sess.mu.Unlock() | |
| 209 | + | for _, path := range clipFiles { | |
| 210 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 211 | + | d.log.Warn("removing clipboard temp file after move failed", "err", err, "path", path) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } | |
A internal/usecase/daemon/move_persistence_test.go +280 -0
| @@ -0,0 +1,280 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type movePersistenceCatalogue struct { | |
| 17 | + | mu sync.Mutex | |
| 18 | + | records map[string]domain.CatalogueRecord | |
| 19 | + | events []string | |
| 20 | + | source *session | |
| 21 | + | destination *session | |
| 22 | + | requireSourceDirty bool | |
| 23 | + | requireDestDirty bool | |
| 24 | + | dirtyBeforeUpdate bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (c *movePersistenceCatalogue) Records() ([]domain.CatalogueRecord, error) { return nil, nil } | |
| 28 | + | func (c *movePersistenceCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |
| 29 | + | c.mu.Lock() | |
| 30 | + | defer c.mu.Unlock() | |
| 31 | + | record, ok := c.records[name] | |
| 32 | + | return record, ok, nil | |
| 33 | + | } | |
| 34 | + | func (c *movePersistenceCatalogue) Create(domain.CatalogueRecord) error { return nil } | |
| 35 | + | func (c *movePersistenceCatalogue) Replace(string, domain.CatalogueRecord) error { return nil } | |
| 36 | + | func (c *movePersistenceCatalogue) Rename(string, domain.CatalogueRecord) error { return nil } | |
| 37 | + | func (c *movePersistenceCatalogue) Delete(name string) error { | |
| 38 | + | c.mu.Lock() | |
| 39 | + | defer c.mu.Unlock() | |
| 40 | + | delete(c.records, name) | |
| 41 | + | c.events = append(c.events, "delete:"+name) | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | func (c *movePersistenceCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error { | |
| 45 | + | sourceDirty := true | |
| 46 | + | if c.requireSourceDirty { | |
| 47 | + | c.source.snapshotMu.Lock() | |
| 48 | + | sourceDirty = c.source.snapDirty.Load() && c.source.snapshotGeneration > 0 | |
| 49 | + | c.source.snapshotMu.Unlock() | |
| 50 | + | } | |
| 51 | + | destinationDirty := true | |
| 52 | + | if c.requireDestDirty { | |
| 53 | + | c.destination.snapshotMu.Lock() | |
| 54 | + | destinationDirty = c.destination.snapDirty.Load() && c.destination.snapshotGeneration > 0 | |
| 55 | + | c.destination.snapshotMu.Unlock() | |
| 56 | + | } | |
| 57 | + | c.mu.Lock() | |
| 58 | + | defer c.mu.Unlock() | |
| 59 | + | c.dirtyBeforeUpdate = c.dirtyBeforeUpdate && sourceDirty && destinationDirty | |
| 60 | + | c.events = append(c.events, "metadata:"+update.Name) | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | func (c *movePersistenceCatalogue) Sync() error { return nil } | |
| 64 | + | func (c *movePersistenceCatalogue) Close() error { return nil } | |
| 65 | + | ||
| 66 | + | type movePersistenceRepository struct { | |
| 67 | + | noOpSnapshotRepository | |
| 68 | + | mu sync.Mutex | |
| 69 | + | events []string | |
| 70 | + | publish error | |
| 71 | + | daemon *Daemon | |
| 72 | + | source *session | |
| 73 | + | destination *session | |
| 74 | + | moved *tab | |
| 75 | + | pane *pane | |
| 76 | + | outsideLocks bool | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error { | |
| 80 | + | outsideLocks := true | |
| 81 | + | unlocks := make([]func(), 0, 7) | |
| 82 | + | candidates := make([]struct { | |
| 83 | + | tryLock func() bool | |
| 84 | + | unlock func() | |
| 85 | + | }, 0, 7) | |
| 86 | + | if r.daemon != nil { | |
| 87 | + | candidates = append(candidates, struct { | |
| 88 | + | tryLock func() bool | |
| 89 | + | unlock func() | |
| 90 | + | }{r.daemon.mu.TryLock, r.daemon.mu.Unlock}) | |
| 91 | + | } | |
| 92 | + | if r.source != nil { | |
| 93 | + | candidates = append(candidates, | |
| 94 | + | struct { | |
| 95 | + | tryLock func() bool | |
| 96 | + | unlock func() | |
| 97 | + | }{r.source.mu.TryLock, r.source.mu.Unlock}, | |
| 98 | + | struct { | |
| 99 | + | tryLock func() bool | |
| 100 | + | unlock func() | |
| 101 | + | }{r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock}) | |
| 102 | + | } | |
| 103 | + | if r.destination != nil { | |
| 104 | + | candidates = append(candidates, | |
| 105 | + | struct { | |
| 106 | + | tryLock func() bool | |
| 107 | + | unlock func() | |
| 108 | + | }{r.destination.mu.TryLock, r.destination.mu.Unlock}, | |
| 109 | + | struct { | |
| 110 | + | tryLock func() bool | |
| 111 | + | unlock func() | |
| 112 | + | }{r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock}) | |
| 113 | + | } | |
| 114 | + | if r.moved != nil { | |
| 115 | + | candidates = append(candidates, struct { | |
| 116 | + | tryLock func() bool | |
| 117 | + | unlock func() | |
| 118 | + | }{r.moved.mu.TryLock, r.moved.mu.Unlock}) | |
| 119 | + | } | |
| 120 | + | if r.pane != nil { | |
| 121 | + | candidates = append(candidates, struct { | |
| 122 | + | tryLock func() bool | |
| 123 | + | unlock func() | |
| 124 | + | }{r.pane.mu.TryLock, r.pane.mu.Unlock}) | |
| 125 | + | } | |
| 126 | + | for _, candidate := range candidates { | |
| 127 | + | if candidate.tryLock() { | |
| 128 | + | unlocks = append(unlocks, candidate.unlock) | |
| 129 | + | } else { | |
| 130 | + | outsideLocks = false | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | for i := len(unlocks) - 1; i >= 0; i-- { | |
| 134 | + | unlocks[i]() | |
| 135 | + | } | |
| 136 | + | r.mu.Lock() | |
| 137 | + | r.events = append(r.events, "publish") | |
| 138 | + | r.outsideLocks = outsideLocks | |
| 139 | + | r.mu.Unlock() | |
| 140 | + | return r.publish | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (r *movePersistenceRepository) DeleteIncarnation(_ context.Context, _ domain.IncarnationID) error { | |
| 144 | + | r.mu.Lock() | |
| 145 | + | r.events = append(r.events, "purge") | |
| 146 | + | r.mu.Unlock() | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestMoveTabPersistenceMatrix(t *testing.T) { | |
| 151 | + | tests := []struct { | |
| 152 | + | name string | |
| 153 | + | sourceNamed bool | |
| 154 | + | destinationNamed bool | |
| 155 | + | finalSource bool | |
| 156 | + | wantSourceGeneration bool | |
| 157 | + | wantDestinationGeneration bool | |
| 158 | + | wantMetadata []string | |
| 159 | + | wantPurge bool | |
| 160 | + | }{ | |
| 161 | + | {name: "named to named", sourceNamed: true, destinationNamed: true, wantSourceGeneration: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:work", "metadata:destination"}}, | |
| 162 | + | {name: "ephemeral to named", destinationNamed: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}}, | |
| 163 | + | {name: "named to named final source", sourceNamed: true, destinationNamed: true, finalSource: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}, wantPurge: true}, | |
| 164 | + | {name: "named to ephemeral final source", sourceNamed: true, finalSource: true, wantPurge: true}, | |
| 165 | + | {name: "ephemeral to ephemeral", finalSource: true}, | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | for _, tt := range tests { | |
| 169 | + | t.Run(tt.name, func(t *testing.T) { | |
| 170 | + | ptyCount := 2 | |
| 171 | + | if tt.finalSource { | |
| 172 | + | ptyCount = 1 | |
| 173 | + | } | |
| 174 | + | ptys := make([]ports.PTY, ptyCount) | |
| 175 | + | for i := range ptys { | |
| 176 | + | ptys[i] = newQuietPTY() | |
| 177 | + | } | |
| 178 | + | d, source, client, _ := newManualSessionWithPTYs(t, ptys...) | |
| 179 | + | if tt.finalSource { | |
| 180 | + | source.client = nil | |
| 181 | + | client.setSession(nil) | |
| 182 | + | } | |
| 183 | + | source.incarnation = domain.IncarnationID{1} | |
| 184 | + | source.ephemeral = !tt.sourceNamed | |
| 185 | + | source.snapEligible.Store(tt.sourceNamed) | |
| 186 | + | source.snapshotWake = d.snapshotWake | |
| 187 | + | moved := source.tabs[0] | |
| 188 | + | moved.stableID = "moved-tab" | |
| 189 | + | ||
| 190 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 191 | + | destination.ephemeral = !tt.destinationNamed | |
| 192 | + | destination.snapEligible.Store(tt.destinationNamed) | |
| 193 | + | destination.snapshotWake = d.snapshotWake | |
| 194 | + | ||
| 195 | + | catalogue := &movePersistenceCatalogue{ | |
| 196 | + | records: map[string]domain.CatalogueRecord{}, | |
| 197 | + | source: source, | |
| 198 | + | destination: destination, | |
| 199 | + | requireSourceDirty: tt.sourceNamed && !tt.finalSource, | |
| 200 | + | requireDestDirty: tt.destinationNamed, | |
| 201 | + | dirtyBeforeUpdate: true, | |
| 202 | + | } | |
| 203 | + | if tt.sourceNamed { | |
| 204 | + | catalogue.records[source.name] = domain.CatalogueRecord{Name: source.name, IncarnationID: source.incarnation} | |
| 205 | + | } | |
| 206 | + | if tt.destinationNamed { | |
| 207 | + | catalogue.records[destination.name] = domain.CatalogueRecord{Name: destination.name, IncarnationID: destination.incarnation} | |
| 208 | + | } | |
| 209 | + | repository := &movePersistenceRepository{} | |
| 210 | + | WithCatalogue(catalogue, nil)(d) | |
| 211 | + | WithSnapshotRepository(repository)(d) | |
| 212 | + | WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d) | |
| 213 | + | ||
| 214 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 215 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 216 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 217 | + | })) | |
| 218 | + | ||
| 219 | + | source.snapshotMu.Lock() | |
| 220 | + | sourceGeneration := source.snapshotGeneration | |
| 221 | + | source.snapshotMu.Unlock() | |
| 222 | + | destination.snapshotMu.Lock() | |
| 223 | + | destinationGeneration := destination.snapshotGeneration | |
| 224 | + | destination.snapshotMu.Unlock() | |
| 225 | + | require.Equal(t, tt.wantSourceGeneration, sourceGeneration > 0) | |
| 226 | + | require.Equal(t, tt.wantDestinationGeneration, destinationGeneration > 0) | |
| 227 | + | catalogue.mu.Lock() | |
| 228 | + | metadata := append([]string(nil), catalogue.events...) | |
| 229 | + | dirtyBeforeUpdate := catalogue.dirtyBeforeUpdate | |
| 230 | + | catalogue.mu.Unlock() | |
| 231 | + | require.True(t, dirtyBeforeUpdate, "snapshot dirtiness must be admitted before catalogue metadata") | |
| 232 | + | if tt.wantPurge { | |
| 233 | + | require.Equal(t, append(append([]string(nil), tt.wantMetadata...), "delete:"+source.name), metadata) | |
| 234 | + | } else { | |
| 235 | + | require.ElementsMatch(t, tt.wantMetadata, metadata) | |
| 236 | + | } | |
| 237 | + | repository.mu.Lock() | |
| 238 | + | purged := len(repository.events) == 1 && repository.events[0] == "purge" | |
| 239 | + | repository.mu.Unlock() | |
| 240 | + | require.Equal(t, tt.wantPurge, purged) | |
| 241 | + | require.Same(t, moved, destination.tabs[len(destination.tabs)-1]) | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) { | |
| 247 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 248 | + | source.ephemeral = true | |
| 249 | + | moved := source.tabs[0] | |
| 250 | + | moved.stableID = "moved-tab" | |
| 251 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 252 | + | destination.ephemeral = false | |
| 253 | + | destination.snapEligible.Store(true) | |
| 254 | + | destination.snapshotWake = d.snapshotWake | |
| 255 | + | ||
| 256 | + | repository := &movePersistenceRepository{ | |
| 257 | + | publish: errors.New("injected publication failure"), | |
| 258 | + | daemon: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), | |
| 259 | + | } | |
| 260 | + | WithSnapshotRepository(repository)(d) | |
| 261 | + | startSnapshotEncodeWorker(t, d) | |
| 262 | + | ||
| 263 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 264 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 265 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 266 | + | })) | |
| 267 | + | d.scheduleEligibleRepositorySnapshots() | |
| 268 | + | awaitSnapshotIdle(t, destination) | |
| 269 | + | ||
| 270 | + | require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "repository failure must not roll back live membership") | |
| 271 | + | require.Same(t, destination, moved.focusedPane().ownerSnapshot().session) | |
| 272 | + | require.True(t, destination.snapDirty.Load(), "failed publication remains scheduler-retryable") | |
| 273 | + | repository.mu.Lock() | |
| 274 | + | require.Equal(t, []string{"publish"}, repository.events, "move persistence uses only the existing snapshot publication format") | |
| 275 | + | require.True(t, repository.outsideLocks, "repository publication must run outside architecture locks") | |
| 276 | + | repository.mu.Unlock() | |
| 277 | + | history := d.notices.history() | |
| 278 | + | require.NotEmpty(t, history) | |
| 279 | + | require.Equal(t, domain.NoticeSnapshotWrite, history[len(history)-1].Code) | |
| 280 | + | } | |
A internal/usecase/daemon/move_picker.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 2 | + | package daemon | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |
| 10 | + | if d == nil || source.Session.ID == "" || source.TabID == "" { | |
| 11 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 12 | + | } | |
| 13 | + | sess := d.sessionByID(source.Session.ID) | |
| 14 | + | if sess == nil || sess.incarnation != source.Session.Incarnation { | |
| 15 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 16 | + | } | |
| 17 | + | sess.mu.Lock() | |
| 18 | + | defer sess.mu.Unlock() | |
| 19 | + | if source.Client != nil && sess.client != source.Client { | |
| 20 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid) | |
| 21 | + | } | |
| 22 | + | tb := findMoveTabLocked(sess, source.TabID) | |
| 23 | + | if tb == nil { | |
| 24 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid) | |
| 25 | + | } | |
| 26 | + | if source.PaneID == "" { | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | tb.mu.Lock() | |
| 30 | + | defer tb.mu.Unlock() | |
| 31 | + | if paneByStableIDLocked(tb, string(source.PaneID)) == nil { | |
| 32 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid) | |
| 33 | + | } | |
| 34 | + | return nil | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type pickerIntent uint8 | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | pickerNavigate pickerIntent = iota | |
| 41 | + | pickerMovePane | |
| 42 | + | pickerMoveTab | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | type moveSessionLocator struct { | |
| 46 | + | ID domain.SessionID | |
| 47 | + | Incarnation domain.IncarnationID | |
| 48 | + | Name string | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type moveSourceLocator struct { | |
| 52 | + | Session moveSessionLocator | |
| 53 | + | TabID domain.TabStableID | |
| 54 | + | PaneID domain.PaneStableID | |
| 55 | + | Client *attachedClient | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| 59 | + | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { | |
| 60 | + | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | |
| 61 | + | if intent != pickerNavigate { | |
| 62 | + | if _, ok := model.Selected(); !ok { | |
| 63 | + | return errNoMoveDestination | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | d.publishPicker(sess, ac, model, intent, source) | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func pickerSelectionMode(intent pickerIntent) picker.SelectionMode { | |
| 72 | + | switch intent { | |
| 73 | + | case pickerMovePane: | |
| 74 | + | return picker.SelectMovePaneTab | |
| 75 | + | case pickerMoveTab: | |
| 76 | + | return picker.SelectMoveTabSession | |
| 77 | + | default: | |
| 78 | + | return picker.SelectNavigationTab | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error { | |
| 83 | + | destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name} | |
| 84 | + | switch intent { | |
| 85 | + | case pickerMovePane: | |
| 86 | + | if target.TabID == "" { | |
| 87 | + | return errMovePaneInvalid | |
| 88 | + | } | |
| 89 | + | return d.movePane(movePaneRequest{ | |
| 90 | + | Source: source.Session, | |
| 91 | + | SourceTabID: source.TabID, | |
| 92 | + | SourcePaneID: source.PaneID, | |
| 93 | + | Destination: destination, | |
| 94 | + | DestinationTabID: target.TabID, | |
| 95 | + | }) | |
| 96 | + | case pickerMoveTab: | |
| 97 | + | return d.moveTab(moveTabRequest{ | |
| 98 | + | Source: source.Session, | |
| 99 | + | SourceTabID: source.TabID, | |
| 100 | + | Destination: destination, | |
| 101 | + | }) | |
| 102 | + | default: | |
| 103 | + | return errMovePaneInvalid | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | |
| 108 | + | d.mu.Lock() | |
| 109 | + | sess := d.sessions[target.Session] | |
| 110 | + | if sess == nil { | |
| 111 | + | d.mu.Unlock() | |
| 112 | + | return nil, nil | |
| 113 | + | } | |
| 114 | + | sess.mu.Lock() | |
| 115 | + | d.mu.Unlock() | |
| 116 | + | defer sess.mu.Unlock() | |
| 117 | + | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) { | |
| 118 | + | return nil, nil | |
| 119 | + | } | |
| 120 | + | if intent == pickerMoveTab { | |
| 121 | + | if sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 122 | + | return nil, nil | |
| 123 | + | } | |
| 124 | + | return sess, sess.tabs[sess.active] | |
| 125 | + | } | |
| 126 | + | if target.TabID != "" { | |
| 127 | + | for _, tb := range sess.tabs { | |
| 128 | + | if domain.TabStableID(tb.stableID) == target.TabID { | |
| 129 | + | return sess, tb | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return nil, nil | |
| 133 | + | } | |
| 134 | + | if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | |
| 135 | + | return nil, nil | |
| 136 | + | } | |
| 137 | + | return sess, sess.tabs[target.TabIndex] | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (d *Daemon) refreshPicker(ac *attachedClient) { | |
| 141 | + | sess := ac.currentSession() | |
| 142 | + | if sess == nil { | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | rt := ac.overlays | |
| 146 | + | rt.pickerMu.Lock() | |
| 147 | + | if rt.picker == nil { | |
| 148 | + | rt.pickerMu.Unlock() | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | intent, source := rt.pickerIntent, rt.pickerSource | |
| 152 | + | current := picker.SourceFilter{} | |
| 153 | + | if intent != pickerNavigate { | |
| 154 | + | selected, _ := rt.picker.Selected() | |
| 155 | + | current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID} | |
| 156 | + | } | |
| 157 | + | rt.pickerMu.Unlock() | |
| 158 | + | model := d.newPickerModel(sess, intent, source, current) | |
| 159 | + | if intent != pickerNavigate { | |
| 160 | + | if _, ok := model.Selected(); !ok { | |
| 161 | + | d.closePicker(ac) | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | rt.pickerMu.Lock() | |
| 166 | + | updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source | |
| 167 | + | if updated { | |
| 168 | + | rt.picker = model | |
| 169 | + | } | |
| 170 | + | rt.pickerMu.Unlock() | |
| 171 | + | if updated { | |
| 172 | + | d.registerPreviewForSelection(ac) | |
| 173 | + | } | |
| 174 | + | } | |
A internal/usecase/daemon/move_picker_test.go +329 -0
| @@ -0,0 +1,329 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) { | |
| 15 | + | t.Helper() | |
| 16 | + | return setupMovePickerSessionsWithClock(t, stubClock{}, extraDestinationTabs) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) { | |
| 20 | + | t.Helper() | |
| 21 | + | sourcePTY, releaseSource := newBlockingPTY(t) | |
| 22 | + | d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY) | |
| 23 | + | source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1} | |
| 24 | + | d.mu.Lock() | |
| 25 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 26 | + | d.sessions[source.id] = source | |
| 27 | + | d.mu.Unlock() | |
| 28 | + | sourceTab := source.tabs[0] | |
| 29 | + | sourceTab.stableID = "source-tab" | |
| 30 | + | sourcePane := sourceTab.focusedPane() | |
| 31 | + | sourcePane.stableID = "source-pane" | |
| 32 | + | ||
| 33 | + | releases := []func(){releaseSource} | |
| 34 | + | destPTY, releaseDest := newBlockingPTY(t) | |
| 35 | + | releases = append(releases, releaseDest) | |
| 36 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 37 | + | publishTiledPaneOwners(source, sourceTab) | |
| 38 | + | destination := &session{ | |
| 39 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 40 | + | ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0, | |
| 41 | + | } | |
| 42 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 43 | + | for range extraDestinationTabs { | |
| 44 | + | extraPTY, releaseExtra := newBlockingPTY(t) | |
| 45 | + | releases = append(releases, releaseExtra) | |
| 46 | + | extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 47 | + | destination.tabs = append(destination.tabs, extraTab) | |
| 48 | + | publishTiledPaneOwners(destination, extraTab) | |
| 49 | + | } | |
| 50 | + | d.mu.Lock() | |
| 51 | + | d.sessions[destination.id] = destination | |
| 52 | + | d.mu.Unlock() | |
| 53 | + | return d, source, ac, destination, destinationTab, releases | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) { | |
| 57 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 58 | + | defer releaseAll(releases) | |
| 59 | + | ||
| 60 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 61 | + | require.True(t, ac.overlays.paletteActive()) | |
| 62 | + | d.handleInput(source, ac, []byte("MPN\r")) | |
| 63 | + | require.False(t, ac.overlays.paletteActive()) | |
| 64 | + | require.True(t, ac.overlays.pickerActive()) | |
| 65 | + | ||
| 66 | + | ac.overlays.pickerMu.Lock() | |
| 67 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 68 | + | ac.overlays.pickerMu.Unlock() | |
| 69 | + | require.Equal(t, pickerMovePane, intent) | |
| 70 | + | require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session) | |
| 71 | + | require.Equal(t, domain.TabStableID("source-tab"), captured.TabID) | |
| 72 | + | require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID) | |
| 73 | + | require.Same(t, ac, captured.Client) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) { | |
| 77 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 78 | + | defer releaseAll(releases) | |
| 79 | + | sourceTab := source.tabs[0] | |
| 80 | + | sourceTab.stableID = "active-tab" | |
| 81 | + | ||
| 82 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 83 | + | d.handleInput(source, ac, []byte("MTB\r")) | |
| 84 | + | require.True(t, ac.overlays.pickerActive()) | |
| 85 | + | ||
| 86 | + | ac.overlays.pickerMu.Lock() | |
| 87 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 88 | + | ac.overlays.pickerMu.Unlock() | |
| 89 | + | require.Equal(t, pickerMoveTab, intent) | |
| 90 | + | require.Equal(t, domain.TabStableID("active-tab"), captured.TabID) | |
| 91 | + | require.Equal(t, domain.PaneStableID(""), captured.PaneID) | |
| 92 | + | require.Same(t, ac, captured.Client) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) { | |
| 96 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 97 | + | defer releaseAll(releases) | |
| 98 | + | d.mu.Lock() | |
| 99 | + | delete(d.sessions, domain.SessionID("destination")) | |
| 100 | + | d.mu.Unlock() | |
| 101 | + | ||
| 102 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 103 | + | d.handleInput(source, ac, []byte("MPN\r")) | |
| 104 | + | ||
| 105 | + | require.False(t, ac.overlays.pickerActive()) | |
| 106 | + | history := d.notices.history() | |
| 107 | + | require.NotEmpty(t, history) | |
| 108 | + | require.Equal(t, "No destination available.", history[0].Message) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |
| 112 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 113 | + | defer releaseAll(releases) | |
| 114 | + | source.mu.Lock() | |
| 115 | + | source.client = nil | |
| 116 | + | source.mu.Unlock() | |
| 117 | + | sourceTab := source.tabs[0] | |
| 118 | + | moved := sourceTab.focusedPane() | |
| 119 | + | ||
| 120 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 121 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 122 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 123 | + | })) | |
| 124 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 125 | + | ||
| 126 | + | require.False(t, ac.overlays.pickerActive()) | |
| 127 | + | require.Nil(t, source.tabs) | |
| 128 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 129 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |
| 133 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 134 | + | defer releaseAll(releases) | |
| 135 | + | source.mu.Lock() | |
| 136 | + | source.client = nil | |
| 137 | + | source.mu.Unlock() | |
| 138 | + | sourceTab := source.tabs[0] | |
| 139 | + | moved := sourceTab.focusedPane() | |
| 140 | + | ||
| 141 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 142 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 143 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 144 | + | })) | |
| 145 | + | ac.overlays.pickerMu.Lock() | |
| 146 | + | target, ok := ac.overlays.picker.Selected() | |
| 147 | + | ac.overlays.pickerMu.Unlock() | |
| 148 | + | require.True(t, ok) | |
| 149 | + | require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{ | |
| 150 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 151 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 152 | + | }, target)) | |
| 153 | + | ||
| 154 | + | require.Nil(t, source.tabs) | |
| 155 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 156 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |
| 160 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 161 | + | defer releaseAll(releases) | |
| 162 | + | source.mu.Lock() | |
| 163 | + | source.client = nil | |
| 164 | + | source.mu.Unlock() | |
| 165 | + | movedTab := source.tabs[0] | |
| 166 | + | movedTab.stableID = "moved-tab" | |
| 167 | + | ||
| 168 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |
| 169 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 170 | + | TabID: "moved-tab", | |
| 171 | + | })) | |
| 172 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 173 | + | ||
| 174 | + | require.False(t, ac.overlays.pickerActive()) | |
| 175 | + | require.Nil(t, source.tabs) | |
| 176 | + | require.Len(t, destination.tabs, 2) | |
| 177 | + | require.Same(t, movedTab, destination.tabs[1]) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestMovePickerEscapePerformsNoMutation(t *testing.T) { | |
| 181 | + | clk := &signalClock{timers: make(chan *signalTimer, 16)} | |
| 182 | + | d, source, ac, _, _, releases := setupMovePickerSessionsWithClock(t, clk, 0) | |
| 183 | + | defer releaseAll(releases) | |
| 184 | + | before := len(source.tabs) | |
| 185 | + | ||
| 186 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 187 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 188 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 189 | + | })) | |
| 190 | + | for len(clk.timers) > 0 { | |
| 191 | + | <-clk.timers | |
| 192 | + | } | |
| 193 | + | d.handlePickerInput(ac, []byte("\x1b")) | |
| 194 | + | timer := <-clk.timers | |
| 195 | + | timer.ch <- time.Now() | |
| 196 | + | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 197 | + | require.Len(t, source.tabs, before) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) { | |
| 201 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 202 | + | defer releaseAll(releases) | |
| 203 | + | ||
| 204 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 205 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 206 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 207 | + | })) | |
| 208 | + | d.mu.Lock() | |
| 209 | + | delete(d.sessions, destination.id) | |
| 210 | + | d.mu.Unlock() | |
| 211 | + | ||
| 212 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 213 | + | ||
| 214 | + | require.False(t, ac.overlays.pickerActive()) | |
| 215 | + | history := d.notices.history() | |
| 216 | + | require.NotEmpty(t, history) | |
| 217 | + | require.Equal(t, "Destination is no longer available.", history[0].Message) | |
| 218 | + | require.Len(t, source.tabs, 1) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) { | |
| 222 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 223 | + | defer releaseAll(releases) | |
| 224 | + | sourceTab := source.tabs[0] | |
| 225 | + | ||
| 226 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 227 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 228 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 229 | + | })) | |
| 230 | + | sourceTab.mu.Lock() | |
| 231 | + | delete(sourceTab.panes, sourceTab.tree.Focus) | |
| 232 | + | sourceTab.mu.Unlock() | |
| 233 | + | ||
| 234 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 235 | + | ||
| 236 | + | require.True(t, ac.overlays.pickerActive()) | |
| 237 | + | history := d.notices.history() | |
| 238 | + | require.NotEmpty(t, history) | |
| 239 | + | require.Equal(t, "Pane no longer exists.", history[0].Message) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) { | |
| 243 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 244 | + | defer releaseAll(releases) | |
| 245 | + | ||
| 246 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |
| 247 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 248 | + | TabID: "source-tab", | |
| 249 | + | })) | |
| 250 | + | source.mu.Lock() | |
| 251 | + | source.tabs = nil | |
| 252 | + | source.mu.Unlock() | |
| 253 | + | ||
| 254 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 255 | + | ||
| 256 | + | require.True(t, ac.overlays.pickerActive()) | |
| 257 | + | history := d.notices.history() | |
| 258 | + | require.NotEmpty(t, history) | |
| 259 | + | require.Equal(t, "Tab no longer exists.", history[0].Message) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |
| 263 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 264 | + | defer releaseAll(releases) | |
| 265 | + | ||
| 266 | + | require.NoError(t, paletteExec{d: d, sess: source, ac: ac}.OpenMovePanePicker()) | |
| 267 | + | replacement := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size} | |
| 268 | + | replacement.initOverlays() | |
| 269 | + | replacement.setSession(source) | |
| 270 | + | source.mu.Lock() | |
| 271 | + | source.client = replacement | |
| 272 | + | source.mu.Unlock() | |
| 273 | + | ||
| 274 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 275 | + | ||
| 276 | + | require.True(t, ac.overlays.pickerActive()) | |
| 277 | + | require.Len(t, source.tabs, 1) | |
| 278 | + | require.Len(t, destination.tabs, 1) | |
| 279 | + | history := d.notices.history() | |
| 280 | + | require.NotEmpty(t, history) | |
| 281 | + | require.Equal(t, "Source client is no longer active.", history[0].Message) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) { | |
| 285 | + | d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 286 | + | defer releaseAll(releases) | |
| 287 | + | follower.replaceTransport(&closeTrackingTransport{}) | |
| 288 | + | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 289 | + | ||
| 290 | + | displacedTransport := &closeTrackingTransport{} | |
| 291 | + | displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size} | |
| 292 | + | displaced.initOverlays() | |
| 293 | + | displaced.setSession(destination) | |
| 294 | + | destination.mu.Lock() | |
| 295 | + | destination.client = displaced | |
| 296 | + | destination.mu.Unlock() | |
| 297 | + | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 298 | + | ||
| 299 | + | var pickerClosed atomic.Bool | |
| 300 | + | d.afterDisplacedCleanupStarted = func() { | |
| 301 | + | require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization") | |
| 302 | + | pickerClosed.Store(true) | |
| 303 | + | } | |
| 304 | + | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 305 | + | ||
| 306 | + | require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{ | |
| 307 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 308 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 309 | + | })) | |
| 310 | + | d.handlePickerInput(follower, []byte("\r")) | |
| 311 | + | d.attachmentCleanupWg.Wait() | |
| 312 | + | ||
| 313 | + | require.True(t, pickerClosed.Load()) | |
| 314 | + | require.False(t, follower.overlays.pickerActive()) | |
| 315 | + | frames := displacedTransport.Sends() | |
| 316 | + | require.NotEmpty(t, frames) | |
| 317 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | |
| 321 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 322 | + | defer releaseAll(releases) | |
| 323 | + | ||
| 324 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 325 | + | d.handleInput(source, ac, []byte("MPN\r")) | |
| 326 | + | require.True(t, ac.overlays.pickerActive()) | |
| 327 | + | require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open") | |
| 328 | + | source.dispatchMu.Unlock() | |
| 329 | + | } | |
A internal/usecase/daemon/move_rejection.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // moveRejectionReason is the daemon API category for a rejected move. It keeps | |
| 12 | + | // callers independent of topology and layout implementation errors. | |
| 13 | + | type moveRejectionReason uint8 | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | moveRejectionInvalid moveRejectionReason = iota + 1 | |
| 17 | + | moveRejectionNoDestination | |
| 18 | + | moveRejectionFloatingWarming | |
| 19 | + | moveRejectionFinalSourceFloating | |
| 20 | + | moveRejectionStaleTarget | |
| 21 | + | moveRejectionTooSmall | |
| 22 | + | moveRejectionPaneIDExhausted | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // moveRejection carries a stable category while Unwrap preserves identities | |
| 26 | + | // relied on by lower-level move tests and callers. | |
| 27 | + | type moveRejection struct { | |
| 28 | + | Reason moveRejectionReason | |
| 29 | + | msg string | |
| 30 | + | cause error | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (e *moveRejection) Error() string { return e.msg } | |
| 34 | + | func (e *moveRejection) Unwrap() error { return e.cause } | |
| 35 | + | ||
| 36 | + | // moveRejectionDescriptor is the canonical presentation for one rejection | |
| 37 | + | // reason. Palette and control adapters only wrap these values for their | |
| 38 | + | // respective transports. | |
| 39 | + | type moveRejectionDescriptor struct { | |
| 40 | + | Reason moveRejectionReason | |
| 41 | + | PaletteCode domain.NoticeCode | |
| 42 | + | PaletteSeverity domain.NoticeSeverity | |
| 43 | + | PaletteText string | |
| 44 | + | CommandCode uint16 | |
| 45 | + | CommandText string | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func moveRejectionDescriptorFor(err error) (moveRejectionDescriptor, bool) { | |
| 49 | + | reason, ok := moveRejectionReasonFor(err) | |
| 50 | + | if !ok { | |
| 51 | + | return moveRejectionDescriptor{}, false | |
| 52 | + | } | |
| 53 | + | return moveRejectionDescriptorByReason(reason), true | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func moveRejectionReasonFor(err error) (moveRejectionReason, bool) { | |
| 57 | + | var rejection *moveRejection | |
| 58 | + | if errors.As(err, &rejection) { | |
| 59 | + | return rejection.Reason, true | |
| 60 | + | } | |
| 61 | + | if errors.Is(err, errMoveLifecycleUnavailable) { | |
| 62 | + | return moveRejectionStaleTarget, true | |
| 63 | + | } | |
| 64 | + | return 0, false | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func moveRejectionDescriptorByReason(reason moveRejectionReason) moveRejectionDescriptor { | |
| 68 | + | descriptor := moveRejectionDescriptor{ | |
| 69 | + | Reason: reason, | |
| 70 | + | PaletteCode: domain.NoticeInternal, | |
| 71 | + | PaletteSeverity: domain.NoticeError, | |
| 72 | + | PaletteText: "Move failed.", | |
| 73 | + | CommandCode: ports.ErrNoSuchTarget, | |
| 74 | + | CommandText: "move request is invalid", | |
| 75 | + | } | |
| 76 | + | switch reason { | |
| 77 | + | case moveRejectionNoDestination: | |
| 78 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |
| 79 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 80 | + | descriptor.PaletteText = "No destination available." | |
| 81 | + | descriptor.CommandText = "no move destination available" | |
| 82 | + | case moveRejectionFloatingWarming: | |
| 83 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |
| 84 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 85 | + | descriptor.PaletteText = "Wait for the floating pane to finish opening." | |
| 86 | + | descriptor.CommandText = "wait for the floating pane to finish opening" | |
| 87 | + | case moveRejectionFinalSourceFloating: | |
| 88 | + | descriptor.PaletteCode = domain.NoticeLayoutTooSmall | |
| 89 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 90 | + | descriptor.PaletteText = "Close the floating pane or move the whole tab." | |
| 91 | + | descriptor.CommandText = "close the floating pane or move the whole tab" | |
| 92 | + | case moveRejectionStaleTarget: | |
| 93 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |
| 94 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 95 | + | descriptor.PaletteText = "Destination is no longer available." | |
| 96 | + | descriptor.CommandText = "move target is no longer available" | |
| 97 | + | case moveRejectionTooSmall: | |
| 98 | + | descriptor.PaletteCode = domain.NoticeLayoutTooSmall | |
| 99 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 100 | + | descriptor.PaletteText = "Not enough space in destination tab." | |
| 101 | + | descriptor.CommandText = "not enough space in destination tab" | |
| 102 | + | case moveRejectionPaneIDExhausted: | |
| 103 | + | descriptor.PaletteText = "Destination pane IDs are exhausted." | |
| 104 | + | descriptor.CommandText = "destination pane IDs are exhausted" | |
| 105 | + | } | |
| 106 | + | return descriptor | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func movePickerUserError(err error) error { | |
| 110 | + | if err == nil { | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | var userErr *domain.UserError | |
| 114 | + | if errors.As(err, &userErr) { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | descriptor, ok := moveRejectionDescriptorFor(err) | |
| 118 | + | if !ok { | |
| 119 | + | descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid) | |
| 120 | + | } | |
| 121 | + | if descriptor.PaletteSeverity == domain.NoticeWarn { | |
| 122 | + | return domain.UserWarn(descriptor.PaletteCode, descriptor.PaletteText, err) | |
| 123 | + | } | |
| 124 | + | return domain.UserErr(descriptor.PaletteCode, descriptor.PaletteText, err) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func isMoveCommandError(err error) bool { | |
| 128 | + | _, ok := moveRejectionReasonFor(err) | |
| 129 | + | return ok | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func moveCommandFailure(err error) ports.CommandResult { | |
| 133 | + | descriptor, ok := moveRejectionDescriptorFor(err) | |
| 134 | + | if !ok { | |
| 135 | + | descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid) | |
| 136 | + | } | |
| 137 | + | return commandFailure(descriptor.CommandCode, descriptor.CommandText) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func normalizeMoveRejection(err error) error { | |
| 141 | + | if err == nil { | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | var rejection *moveRejection | |
| 145 | + | if errors.As(err, &rejection) { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | if errors.Is(err, layout.ErrTooSmall) { | |
| 149 | + | return errMoveTooSmall | |
| 150 | + | } | |
| 151 | + | if errors.Is(err, errMoveLifecycleUnavailable) { | |
| 152 | + | return &moveRejection{Reason: moveRejectionStaleTarget, msg: errMoveStaleTarget.msg, cause: errors.Join(errMovePaneInvalid, err)} | |
| 153 | + | } | |
| 154 | + | return &moveRejection{Reason: moveRejectionInvalid, msg: errMovePaneInvalid.msg, cause: errors.Join(errMovePaneInvalid, err)} | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | var ( | |
| 158 | + | errMovePaneInvalid = &moveRejection{Reason: moveRejectionInvalid, msg: "move request is invalid"} | |
| 159 | + | errNoMoveDestination = &moveRejection{ | |
| 160 | + | Reason: moveRejectionNoDestination, | |
| 161 | + | msg: "no eligible move destination", | |
| 162 | + | } | |
| 163 | + | errMoveFloatingWarming = &moveRejection{ | |
| 164 | + | Reason: moveRejectionFloatingWarming, | |
| 165 | + | msg: "floating pane is still opening", | |
| 166 | + | } | |
| 167 | + | errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane") | |
| 168 | + | errMoveFinalSourceFloating = &moveRejection{ | |
| 169 | + | Reason: moveRejectionFinalSourceFloating, | |
| 170 | + | msg: "final source pane cannot move while its tab has a floating pane", | |
| 171 | + | cause: errMovePaneFloatingSibling, | |
| 172 | + | } | |
| 173 | + | errMoveStaleTarget = &moveRejection{ | |
| 174 | + | Reason: moveRejectionStaleTarget, | |
| 175 | + | msg: "move target is no longer available", | |
| 176 | + | cause: errMovePaneInvalid, | |
| 177 | + | } | |
| 178 | + | errMoveTooSmall = &moveRejection{ | |
| 179 | + | Reason: moveRejectionTooSmall, | |
| 180 | + | msg: "move destination is too small", | |
| 181 | + | cause: layout.ErrTooSmall, | |
| 182 | + | } | |
| 183 | + | errMovePaneIDExhausted = &moveRejection{ | |
| 184 | + | Reason: moveRejectionPaneIDExhausted, | |
| 185 | + | msg: "destination pane IDs exhausted", | |
| 186 | + | } | |
| 187 | + | ) | |
A internal/usecase/daemon/move_rejection_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | err error | |
| 18 | + | reason moveRejectionReason | |
| 19 | + | legacy error | |
| 20 | + | }{ | |
| 21 | + | {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination}, | |
| 22 | + | {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming}, | |
| 23 | + | {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, legacy: errMovePaneFloatingSibling}, | |
| 24 | + | {name: "stale target", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, legacy: errMovePaneInvalid}, | |
| 25 | + | {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, legacy: layout.ErrTooSmall}, | |
| 26 | + | {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted}, | |
| 27 | + | {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid}, | |
| 28 | + | } | |
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | var rejection *moveRejection | |
| 32 | + | require.ErrorAs(t, tt.err, &rejection) | |
| 33 | + | require.Equal(t, tt.reason, rejection.Reason) | |
| 34 | + | if tt.legacy != nil { | |
| 35 | + | require.True(t, errors.Is(tt.err, tt.legacy)) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | require.NotErrorIs(t, errMoveFloatingWarming, errMoveFinalSourceFloating) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestNormalizeMoveRejectionDoesNotLeakInternalErrors(t *testing.T) { | |
| 43 | + | internal := errors.New("layout implementation detail") | |
| 44 | + | got := normalizeMoveRejection(internal) | |
| 45 | + | var rejection *moveRejection | |
| 46 | + | require.ErrorAs(t, got, &rejection) | |
| 47 | + | require.Equal(t, moveRejectionInvalid, rejection.Reason) | |
| 48 | + | require.Equal(t, "move request is invalid", got.Error()) | |
| 49 | + | require.ErrorIs(t, got, internal) | |
| 50 | + | require.ErrorIs(t, got, errMovePaneInvalid) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestMoveRejectionPresentationParity(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | err error | |
| 57 | + | reason moveRejectionReason | |
| 58 | + | paletteCode domain.NoticeCode | |
| 59 | + | paletteSeverity domain.NoticeSeverity | |
| 60 | + | paletteText string | |
| 61 | + | commandText string | |
| 62 | + | }{ | |
| 63 | + | {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "No destination available.", commandText: "no move destination available"}, | |
| 64 | + | {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Wait for the floating pane to finish opening.", commandText: "wait for the floating pane to finish opening"}, | |
| 65 | + | {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Close the floating pane or move the whole tab.", commandText: "close the floating pane or move the whole tab"}, | |
| 66 | + | {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Not enough space in destination tab.", commandText: "not enough space in destination tab"}, | |
| 67 | + | {name: "stale", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"}, | |
| 68 | + | {name: "lifecycle target", err: errMoveLifecycleUnavailable, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"}, | |
| 69 | + | {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Destination pane IDs are exhausted.", commandText: "destination pane IDs are exhausted"}, | |
| 70 | + | {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Move failed.", commandText: "move request is invalid"}, | |
| 71 | + | } | |
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | descriptor, ok := moveRejectionDescriptorFor(tt.err) | |
| 75 | + | require.True(t, ok) | |
| 76 | + | require.Equal(t, tt.reason, descriptor.Reason) | |
| 77 | + | ||
| 78 | + | paletteErr := movePickerUserError(tt.err) | |
| 79 | + | var userErr *domain.UserError | |
| 80 | + | require.ErrorAs(t, paletteErr, &userErr) | |
| 81 | + | require.Equal(t, descriptor.PaletteCode, userErr.Code) | |
| 82 | + | require.Equal(t, descriptor.PaletteSeverity, userErr.Severity) | |
| 83 | + | require.Equal(t, descriptor.PaletteText, userErr.Msg) | |
| 84 | + | require.Equal(t, tt.paletteCode, userErr.Code) | |
| 85 | + | require.Equal(t, tt.paletteSeverity, userErr.Severity) | |
| 86 | + | require.Equal(t, tt.paletteText, userErr.Msg) | |
| 87 | + | ||
| 88 | + | result := moveCommandFailure(tt.err) | |
| 89 | + | require.False(t, result.OK) | |
| 90 | + | require.Equal(t, descriptor.CommandCode, result.Code) | |
| 91 | + | require.Equal(t, descriptor.CommandText, result.Text) | |
| 92 | + | require.Equal(t, ports.ErrNoSuchTarget, result.Code) | |
| 93 | + | require.Equal(t, tt.commandText, result.Text) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
A internal/usecase/daemon/move_resize_fences.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "slices" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // moveResizeFences excludes concurrent layout publishers while a move | |
| 9 | + | // revalidates and publishes ownership. It is not an architecture lock. Fences | |
| 10 | + | // are always acquired session -> tab -> pane by immutable stable identity and | |
| 11 | + | // released in exact reverse order. | |
| 12 | + | type moveResizeFences struct { | |
| 13 | + | sessions []*session | |
| 14 | + | tabs []*tab | |
| 15 | + | panes []*pane | |
| 16 | + | held bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // newMovePaneResizeFences covers both sessions, both affected layouts, and | |
| 20 | + | // only the pane whose owner will change. Stable-identity deduplication also | |
| 21 | + | // handles moves between tabs in the same session. | |
| 22 | + | func newMovePaneResizeFences(source, destination *session, sourceTab, destinationTab *tab, moved *pane) *moveResizeFences { | |
| 23 | + | return newMoveResizeFences( | |
| 24 | + | []*session{source, destination}, | |
| 25 | + | []*tab{sourceTab, destinationTab}, | |
| 26 | + | []*pane{moved}, | |
| 27 | + | ) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // newMoveTabResizeFences snapshots every process-bearing pane transferred with | |
| 31 | + | // a tab. The acquire callback must revalidate this exact tiled membership and | |
| 32 | + | // installed floating slot after all fences are held, because this snapshot is | |
| 33 | + | // intentionally taken before waiting on any resize transaction. | |
| 34 | + | func newMoveTabResizeFences(source, destination *session, moved *tab) *moveResizeFences { | |
| 35 | + | if moved == nil { | |
| 36 | + | return newMoveResizeFences([]*session{source, destination}, nil, nil) | |
| 37 | + | } | |
| 38 | + | moved.mu.Lock() | |
| 39 | + | panes := make([]*pane, 0, len(moved.panes)+1) | |
| 40 | + | for _, p := range moved.panes { | |
| 41 | + | panes = append(panes, p) | |
| 42 | + | } | |
| 43 | + | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |
| 44 | + | panes = append(panes, moved.floating.pane) | |
| 45 | + | } | |
| 46 | + | moved.mu.Unlock() | |
| 47 | + | return newMoveResizeFences([]*session{source, destination}, []*tab{moved}, panes) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func newMoveResizeFences(sessions []*session, tabs []*tab, panes []*pane) *moveResizeFences { | |
| 51 | + | byID := make(map[string]*session, len(sessions)) | |
| 52 | + | for _, sess := range sessions { | |
| 53 | + | if sess == nil { | |
| 54 | + | continue | |
| 55 | + | } | |
| 56 | + | if _, exists := byID[string(sess.id)]; !exists { | |
| 57 | + | byID[string(sess.id)] = sess | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ordered := make([]*session, 0, len(byID)) | |
| 61 | + | for _, sess := range byID { | |
| 62 | + | ordered = append(ordered, sess) | |
| 63 | + | } | |
| 64 | + | slices.SortFunc(ordered, func(left, right *session) int { | |
| 65 | + | return cmp.Compare(left.id, right.id) | |
| 66 | + | }) | |
| 67 | + | ||
| 68 | + | tabsByID := make(map[string]*tab, len(tabs)) | |
| 69 | + | for _, tb := range tabs { | |
| 70 | + | if tb == nil { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | if _, exists := tabsByID[tb.stableID]; !exists { | |
| 74 | + | tabsByID[tb.stableID] = tb | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | orderedTabs := make([]*tab, 0, len(tabsByID)) | |
| 78 | + | for _, tb := range tabsByID { | |
| 79 | + | orderedTabs = append(orderedTabs, tb) | |
| 80 | + | } | |
| 81 | + | slices.SortFunc(orderedTabs, func(left, right *tab) int { | |
| 82 | + | return cmp.Compare(left.stableID, right.stableID) | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | panesByID := make(map[string]*pane, len(panes)) | |
| 86 | + | for _, p := range panes { | |
| 87 | + | if p == nil { | |
| 88 | + | continue | |
| 89 | + | } | |
| 90 | + | if _, exists := panesByID[p.stableID]; !exists { | |
| 91 | + | panesByID[p.stableID] = p | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | orderedPanes := make([]*pane, 0, len(panesByID)) | |
| 95 | + | for _, p := range panesByID { | |
| 96 | + | orderedPanes = append(orderedPanes, p) | |
| 97 | + | } | |
| 98 | + | slices.SortFunc(orderedPanes, func(left, right *pane) int { | |
| 99 | + | return cmp.Compare(left.stableID, right.stableID) | |
| 100 | + | }) | |
| 101 | + | return &moveResizeFences{sessions: ordered, tabs: orderedTabs, panes: orderedPanes} | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // acquire waits for every resize fence before invoking revalidateAndPublish. | |
| 105 | + | // Callers must enter with no daemon, session, tab, or pane architecture lock | |
| 106 | + | // held. The callback is the only architecture-lock window: under the canonical | |
| 107 | + | // architecture lock order it must revalidate the sessions, tabs, tiled pane | |
| 108 | + | // memberships, and installed floating slot captured before this wait; publish | |
| 109 | + | // every changed pane owner; and bump each affected tab's layoutGeneration. A | |
| 110 | + | // false result (or panic) releases every acquired fence in reverse order. A true | |
| 111 | + | // result retains all fences so no resize can observe half-published ownership; | |
| 112 | + | // the caller must Release after architecture locks are dropped and before PTY | |
| 113 | + | // I/O, applyTabLayout, or applySessionLayout. | |
| 114 | + | func (f *moveResizeFences) acquire(revalidateAndPublish func() bool) (accepted bool) { | |
| 115 | + | if f == nil || f.held { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | for _, sess := range f.sessions { | |
| 119 | + | sess.layoutApplyMu.Lock() | |
| 120 | + | } | |
| 121 | + | for _, tb := range f.tabs { | |
| 122 | + | tb.layoutApplyMu.Lock() | |
| 123 | + | } | |
| 124 | + | for _, p := range f.panes { | |
| 125 | + | p.resizeMu.Lock() | |
| 126 | + | } | |
| 127 | + | f.held = true | |
| 128 | + | defer func() { | |
| 129 | + | if !accepted { | |
| 130 | + | f.Release() | |
| 131 | + | } | |
| 132 | + | }() | |
| 133 | + | if revalidateAndPublish == nil { | |
| 134 | + | return false | |
| 135 | + | } | |
| 136 | + | accepted = revalidateAndPublish() | |
| 137 | + | return accepted | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (d *Daemon) observeBeforeResizeOwnerPostEffect(effect resizeOwnerPostEffect) { | |
| 141 | + | if d != nil && d.beforeResizeOwnerPostEffect != nil { | |
| 142 | + | d.beforeResizeOwnerPostEffect(effect) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // acquireResizeOwnerPostEffectFences acquires and validates the complete | |
| 147 | + | // canonical session/tab/pane fence set. It performs no callbacks or external | |
| 148 | + | // operations and returns the fences held on success. | |
| 149 | + | func acquireResizeOwnerPostEffectFences(members []resizeMember) *moveResizeFences { | |
| 150 | + | if len(members) == 0 { | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | sessions := make([]*session, 0, len(members)) | |
| 154 | + | tabs := make([]*tab, 0, len(members)) | |
| 155 | + | panes := make([]*pane, 0, len(members)) | |
| 156 | + | for i := range members { | |
| 157 | + | member := &members[i] | |
| 158 | + | sessions = append(sessions, member.session) | |
| 159 | + | tabs = append(tabs, member.tab) | |
| 160 | + | panes = append(panes, member.pane) | |
| 161 | + | } | |
| 162 | + | fences := newMoveResizeFences(sessions, tabs, panes) | |
| 163 | + | if !fences.acquire(func() bool { return resizeMembersOwnerCurrent(members) }) { | |
| 164 | + | return nil | |
| 165 | + | } | |
| 166 | + | return fences | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // publishResizeOwnerPostEffect linearizes an in-memory resize effect with pane | |
| 170 | + | // ownership publication. The deterministic seam runs before fence acquisition, | |
| 171 | + | // then the complete canonical session/tab/pane fence set is acquired and every | |
| 172 | + | // immutable owner lease is revalidated. The callback must publish in-memory | |
| 173 | + | // state only; timer, PTY, transport, repository, and renderer work belongs | |
| 174 | + | // after this method returns. | |
| 175 | + | func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect resizeOwnerPostEffect, publish func()) bool { | |
| 176 | + | if publish == nil { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | d.observeBeforeResizeOwnerPostEffect(effect) | |
| 180 | + | if len(members) == 0 { | |
| 181 | + | publish() | |
| 182 | + | return true | |
| 183 | + | } | |
| 184 | + | fences := acquireResizeOwnerPostEffectFences(members) | |
| 185 | + | if fences == nil { | |
| 186 | + | return false | |
| 187 | + | } | |
| 188 | + | defer fences.Release() | |
| 189 | + | publish() | |
| 190 | + | return true | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (f *moveResizeFences) Release() { | |
| 194 | + | if f == nil || !f.held { | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | for i := len(f.panes) - 1; i >= 0; i-- { | |
| 198 | + | f.panes[i].resizeMu.Unlock() | |
| 199 | + | } | |
| 200 | + | for i := len(f.tabs) - 1; i >= 0; i-- { | |
| 201 | + | f.tabs[i].layoutApplyMu.Unlock() | |
| 202 | + | } | |
| 203 | + | for i := len(f.sessions) - 1; i >= 0; i-- { | |
| 204 | + | f.sessions[i].layoutApplyMu.Unlock() | |
| 205 | + | } | |
| 206 | + | f.held = false | |
| 207 | + | } | |
A internal/usecase/daemon/move_resize_fences_test.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) { | |
| 14 | + | t.Helper() | |
| 15 | + | require.Eventually(t, func() bool { | |
| 16 | + | if !mu.TryLock() { | |
| 17 | + | return true | |
| 18 | + | } | |
| 19 | + | mu.Unlock() | |
| 20 | + | return false | |
| 21 | + | }, time.Second, time.Millisecond, message) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | |
| 25 | + | first := &session{id: "a-first"} | |
| 26 | + | second := &session{id: "z-second"} | |
| 27 | + | fences := newMoveResizeFences([]*session{second, first}, nil, nil) | |
| 28 | + | ||
| 29 | + | first.layoutApplyMu.Lock() | |
| 30 | + | acquired := make(chan bool, 1) | |
| 31 | + | go func() { | |
| 32 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 33 | + | }() | |
| 34 | + | ||
| 35 | + | require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID") | |
| 36 | + | second.layoutApplyMu.Unlock() | |
| 37 | + | first.layoutApplyMu.Unlock() | |
| 38 | + | ||
| 39 | + | require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire")) | |
| 40 | + | fences.Release() | |
| 41 | + | require.True(t, first.layoutApplyMu.TryLock()) | |
| 42 | + | first.layoutApplyMu.Unlock() | |
| 43 | + | require.True(t, second.layoutApplyMu.TryLock()) | |
| 44 | + | second.layoutApplyMu.Unlock() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | |
| 48 | + | sess := &session{id: "session"} | |
| 49 | + | first := &tab{stableID: "a-first"} | |
| 50 | + | second := &tab{stableID: "z-second"} | |
| 51 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil) | |
| 52 | + | ||
| 53 | + | first.layoutApplyMu.Lock() | |
| 54 | + | acquired := make(chan bool, 1) | |
| 55 | + | go func() { | |
| 56 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 57 | + | }() | |
| 58 | + | ||
| 59 | + | requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence") | |
| 60 | + | require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID") | |
| 61 | + | second.layoutApplyMu.Unlock() | |
| 62 | + | select { | |
| 63 | + | case <-acquired: | |
| 64 | + | t.Fatal("resize fences ignored the blocked first tab") | |
| 65 | + | default: | |
| 66 | + | } | |
| 67 | + | first.layoutApplyMu.Unlock() | |
| 68 | + | ||
| 69 | + | require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire")) | |
| 70 | + | fences.Release() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | |
| 74 | + | sess := &session{id: "session"} | |
| 75 | + | tb := &tab{stableID: "tab"} | |
| 76 | + | first := &pane{stableID: "a-first"} | |
| 77 | + | second := &pane{stableID: "z-second"} | |
| 78 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first}) | |
| 79 | + | ||
| 80 | + | first.resizeMu.Lock() | |
| 81 | + | acquired := make(chan bool, 1) | |
| 82 | + | go func() { | |
| 83 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 84 | + | }() | |
| 85 | + | ||
| 86 | + | requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait") | |
| 87 | + | requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait") | |
| 88 | + | require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID") | |
| 89 | + | second.resizeMu.Unlock() | |
| 90 | + | select { | |
| 91 | + | case <-acquired: | |
| 92 | + | t.Fatal("resize fences ignored the blocked first pane") | |
| 93 | + | default: | |
| 94 | + | } | |
| 95 | + | first.resizeMu.Unlock() | |
| 96 | + | ||
| 97 | + | require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire")) | |
| 98 | + | fences.Release() | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | |
| 102 | + | sess := &session{id: "session"} | |
| 103 | + | tb := &tab{stableID: "tab"} | |
| 104 | + | p := &pane{stableID: "pane"} | |
| 105 | + | ||
| 106 | + | fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p}) | |
| 107 | + | ||
| 108 | + | require.Len(t, fences.sessions, 1) | |
| 109 | + | require.Len(t, fences.tabs, 1) | |
| 110 | + | require.Len(t, fences.panes, 1) | |
| 111 | + | require.True(t, fences.acquire(func() bool { return true })) | |
| 112 | + | fences.Release() | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 116 | + | resolved := &session{id: "same"} | |
| 117 | + | collision := &session{id: "same"} | |
| 118 | + | ||
| 119 | + | fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil) | |
| 120 | + | ||
| 121 | + | require.Len(t, fences.sessions, 1) | |
| 122 | + | require.Same(t, resolved, fences.sessions[0]) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 126 | + | resolved := &tab{stableID: "same"} | |
| 127 | + | collision := &tab{stableID: "same"} | |
| 128 | + | ||
| 129 | + | fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil) | |
| 130 | + | ||
| 131 | + | require.Len(t, fences.tabs, 1) | |
| 132 | + | require.Same(t, resolved, fences.tabs[0]) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 136 | + | resolved := &pane{stableID: "same"} | |
| 137 | + | collision := &pane{stableID: "same"} | |
| 138 | + | ||
| 139 | + | fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision}) | |
| 140 | + | ||
| 141 | + | require.Len(t, fences.panes, 1) | |
| 142 | + | require.Same(t, resolved, fences.panes[0]) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | |
| 146 | + | sess := &session{id: "session"} | |
| 147 | + | tb := &tab{stableID: "tab"} | |
| 148 | + | p := &pane{stableID: "pane"} | |
| 149 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| 150 | + | ||
| 151 | + | membershipCurrent := true | |
| 152 | + | p.resizeMu.Lock() | |
| 153 | + | acquired := make(chan bool, 1) | |
| 154 | + | callbackCalled := make(chan struct{}) | |
| 155 | + | go func() { | |
| 156 | + | acquired <- fences.acquire(func() bool { | |
| 157 | + | close(callbackCalled) | |
| 158 | + | return membershipCurrent | |
| 159 | + | }) | |
| 160 | + | }() | |
| 161 | + | requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane") | |
| 162 | + | membershipCurrent = false | |
| 163 | + | p.resizeMu.Unlock() | |
| 164 | + | ||
| 165 | + | require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return")) | |
| 166 | + | awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition") | |
| 167 | + | for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} { | |
| 168 | + | require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence") | |
| 169 | + | lock.Unlock() | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | |
| 174 | + | sess := &session{id: "session"} | |
| 175 | + | tb := &tab{stableID: "tab"} | |
| 176 | + | p := &pane{stableID: "pane"} | |
| 177 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| 178 | + | ||
| 179 | + | require.Panics(t, func() { | |
| 180 | + | fences.acquire(func() bool { panic("publication failed") }) | |
| 181 | + | }) | |
| 182 | + | for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} { | |
| 183 | + | require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set") | |
| 184 | + | lock.Unlock() | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) { | |
| 189 | + | d := &Daemon{} | |
| 190 | + | sess := &session{id: "session"} | |
| 191 | + | tb := &tab{stableID: "tab"} | |
| 192 | + | p := &pane{stableID: "pane"} | |
| 193 | + | members := []resizeMember{{session: sess, tab: tb, pane: p}} | |
| 194 | + | ||
| 195 | + | var recovered any | |
| 196 | + | func() { | |
| 197 | + | defer func() { recovered = recover() }() | |
| 198 | + | d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() { | |
| 199 | + | panic("post effect failed") | |
| 200 | + | }) | |
| 201 | + | }() | |
| 202 | + | require.Equal(t, "post effect failed", recovered) | |
| 203 | + | ||
| 204 | + | for _, fence := range []struct { | |
| 205 | + | name string | |
| 206 | + | mu *sync.Mutex | |
| 207 | + | }{ | |
| 208 | + | {name: "session", mu: &sess.layoutApplyMu}, | |
| 209 | + | {name: "tab", mu: &tb.layoutApplyMu}, | |
| 210 | + | {name: "pane", mu: &p.resizeMu}, | |
| 211 | + | } { | |
| 212 | + | require.True(t, fence.mu.TryLock(), "%s fence leaked after panic recovery", fence.name) | |
| 213 | + | fence.mu.Unlock() | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | |
| 218 | + | d := &Daemon{} | |
| 219 | + | sess := &session{id: "session"} | |
| 220 | + | tb := &tab{stableID: "tab"} | |
| 221 | + | p := &pane{stableID: "pane"} | |
| 222 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| 223 | + | ||
| 224 | + | p.resizeMu.Lock() | |
| 225 | + | acquired := make(chan bool, 1) | |
| 226 | + | go func() { | |
| 227 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 228 | + | }() | |
| 229 | + | requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane") | |
| 230 | + | ||
| 231 | + | for name, lock := range map[string]*sync.Mutex{ | |
| 232 | + | "daemon": &d.mu, | |
| 233 | + | "session": &sess.mu, | |
| 234 | + | "tab": &tb.mu, | |
| 235 | + | } { | |
| 236 | + | require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name) | |
| 237 | + | lock.Unlock() | |
| 238 | + | } | |
| 239 | + | p.resizeMu.Unlock() | |
| 240 | + | ||
| 241 | + | require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released")) | |
| 242 | + | fences.Release() | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) { | |
| 246 | + | source := &session{id: "source"} | |
| 247 | + | destination := &session{id: "destination"} | |
| 248 | + | sourceTab := &tab{stableID: "a-source-tab"} | |
| 249 | + | destinationTab := &tab{stableID: "z-destination-tab"} | |
| 250 | + | p := &pane{stableID: "pane"} | |
| 251 | + | fences := newMoveResizeFences( | |
| 252 | + | []*session{source, destination}, | |
| 253 | + | []*tab{sourceTab, destinationTab}, | |
| 254 | + | []*pane{p}, | |
| 255 | + | ) | |
| 256 | + | ||
| 257 | + | require.True(t, fences.acquire(func() bool { | |
| 258 | + | // This callback is the move's architecture-lock window: membership is | |
| 259 | + | // revalidated before owner publication and both affected generations bump. | |
| 260 | + | sourceTab.mu.Lock() | |
| 261 | + | destinationTab.mu.Lock() | |
| 262 | + | sourceTab.bumpLayoutGenerationLocked() | |
| 263 | + | destinationTab.bumpLayoutGenerationLocked() | |
| 264 | + | destinationTab.mu.Unlock() | |
| 265 | + | sourceTab.mu.Unlock() | |
| 266 | + | return true | |
| 267 | + | })) | |
| 268 | + | require.Equal(t, uint64(1), sourceTab.layoutGeneration) | |
| 269 | + | require.Equal(t, uint64(1), destinationTab.layoutGeneration) | |
| 270 | + | for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} { | |
| 271 | + | require.False(t, lock.TryLock(), "publication returned without retaining every resize fence") | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // Move callers release here, before applyTabLayout or any other PTY I/O. | |
| 275 | + | fences.Release() | |
| 276 | + | for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} { | |
| 277 | + | require.True(t, lock.TryLock(), "publication fence did not release before layout application") | |
| 278 | + | lock.Unlock() | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) { | |
| 283 | + | source := &session{id: "source"} | |
| 284 | + | destination := &session{id: "destination"} | |
| 285 | + | sourceTab := &tab{stableID: "source-tab"} | |
| 286 | + | destinationTab := &tab{stableID: "destination-tab"} | |
| 287 | + | moved := &pane{stableID: "moved-pane"} | |
| 288 | + | ||
| 289 | + | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved) | |
| 290 | + | ||
| 291 | + | require.ElementsMatch(t, []*session{source, destination}, fences.sessions) | |
| 292 | + | require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs) | |
| 293 | + | require.Equal(t, []*pane{moved}, fences.panes) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) { | |
| 297 | + | for _, tc := range []struct { | |
| 298 | + | name string | |
| 299 | + | state floatingState | |
| 300 | + | }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} { | |
| 301 | + | t.Run(tc.name, func(t *testing.T) { | |
| 302 | + | source := &session{id: "source"} | |
| 303 | + | destination := &session{id: "destination"} | |
| 304 | + | first := &pane{stableID: "first"} | |
| 305 | + | second := &pane{stableID: "second"} | |
| 306 | + | floating := &pane{stableID: "floating"} | |
| 307 | + | movedTab := &tab{ | |
| 308 | + | stableID: "moved-tab", | |
| 309 | + | panes: map[layout.PaneID]*pane{ | |
| 310 | + | "pane-1": first, | |
| 311 | + | "pane-2": second, | |
| 312 | + | }, | |
| 313 | + | floating: floatingSlot{state: tc.state, pane: floating, generation: 7}, | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | fences := newMoveTabResizeFences(source, destination, movedTab) | |
| 317 | + | ||
| 318 | + | require.ElementsMatch(t, []*session{source, destination}, fences.sessions) | |
| 319 | + | require.Equal(t, []*tab{movedTab}, fences.tabs) | |
| 320 | + | require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes) | |
| 321 | + | }) | |
| 322 | + | } | |
| 323 | + | } | |
A internal/usecase/daemon/move_stale_destination_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) { | |
| 12 | + | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 13 | + | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 14 | + | defer releaseMoved() | |
| 15 | + | defer releaseDestination() | |
| 16 | + | ||
| 17 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 18 | + | sourceTab := source.tabs[0] | |
| 19 | + | sourceTab.stableID = "source-tab" | |
| 20 | + | moved := sourceTab.focusedPane() | |
| 21 | + | destination := &session{ | |
| 22 | + | id: "destination", name: "destination", ephemeral: true, | |
| 23 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |
| 24 | + | } | |
| 25 | + | destinationTab := destination.tabs[0] | |
| 26 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 27 | + | d.mu.Lock() | |
| 28 | + | d.sessions[destination.id] = destination | |
| 29 | + | d.mu.Unlock() | |
| 30 | + | ||
| 31 | + | sourceTab.mu.Lock() | |
| 32 | + | beforeSource := sourceTab.tree.Clone() | |
| 33 | + | sourceTab.mu.Unlock() | |
| 34 | + | destinationTab.mu.Lock() | |
| 35 | + | beforeDestination := destinationTab.tree.Clone() | |
| 36 | + | destinationTab.mu.Unlock() | |
| 37 | + | beforeOwner := moved.ownerSnapshot() | |
| 38 | + | staleIncarnation := destination.incarnation | |
| 39 | + | staleIncarnation[0]++ | |
| 40 | + | err := d.movePane(movePaneRequest{ | |
| 41 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 42 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 43 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 44 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: staleIncarnation}, | |
| 45 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 46 | + | }) | |
| 47 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 48 | + | sourceTab.mu.Lock() | |
| 49 | + | sourceAfter := sourceTab.tree.Clone() | |
| 50 | + | sourceMoved := sourceTab.panes[moved.id] | |
| 51 | + | sourceTab.mu.Unlock() | |
| 52 | + | destinationTab.mu.Lock() | |
| 53 | + | destinationAfter := destinationTab.tree.Clone() | |
| 54 | + | destinationTab.mu.Unlock() | |
| 55 | + | require.Equal(t, beforeSource, sourceAfter) | |
| 56 | + | require.Equal(t, beforeDestination, destinationAfter) | |
| 57 | + | require.Same(t, beforeOwner, moved.ownerSnapshot()) | |
| 58 | + | require.Same(t, moved, sourceMoved) | |
| 59 | + | } | |
A internal/usecase/daemon/move_tab.go +425 -0
| @@ -0,0 +1,425 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type moveTabAdmission struct { | |
| 11 | + | tab *tab | |
| 12 | + | sourceIndex int | |
| 13 | + | sourceClient *attachedClient | |
| 14 | + | sourceSnatched []*attachedClient | |
| 15 | + | layoutGeneration uint64 | |
| 16 | + | floatingState floatingState | |
| 17 | + | floatingPane *pane | |
| 18 | + | floatingGeneration uint64 | |
| 19 | + | panes []*pane | |
| 20 | + | destinationActive *tab | |
| 21 | + | destinationSize domain.Size | |
| 22 | + | finalSource bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type moveTabCommit struct { | |
| 26 | + | req moveTabRequest | |
| 27 | + | source *session | |
| 28 | + | destination *session | |
| 29 | + | admission moveTabAdmission | |
| 30 | + | handoffFrozen bool | |
| 31 | + | sourceRolesFrozen bool | |
| 32 | + | frozenRoles frozenRoleEffectGates | |
| 33 | + | handoffReq attachmentTransitionRequest | |
| 34 | + | handoffPublication *attachmentPublication | |
| 35 | + | handoffResult attachmentTransitionResult | |
| 36 | + | syncCleanup syncTimerCleanup | |
| 37 | + | sourceCleanupToken attachmentRoleToken | |
| 38 | + | oldTabCancel context.CancelFunc | |
| 39 | + | sourceEmpty bool | |
| 40 | + | retiredParked []parkedAttachmentRetirement | |
| 41 | + | retiredAttachments []detachedAttachmentSnapshot | |
| 42 | + | sourceMetadata domain.CatalogueMetadataUpdate | |
| 43 | + | sourceMetadataValid bool | |
| 44 | + | destMetadata domain.CatalogueMetadataUpdate | |
| 45 | + | destMetadataValid bool | |
| 46 | + | err error | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) { | |
| 50 | + | if sess == nil || len(admitted) == 0 { | |
| 51 | + | return nil, nil | |
| 52 | + | } | |
| 53 | + | d.mu.Lock() | |
| 54 | + | defer d.mu.Unlock() | |
| 55 | + | sess.mu.Lock() | |
| 56 | + | defer sess.mu.Unlock() | |
| 57 | + | clients := append([]*attachedClient(nil), admitted...) | |
| 58 | + | interrupts := make([]roleTransportInterrupt, 0, len(clients)) | |
| 59 | + | for _, ac := range clients { | |
| 60 | + | if ac == nil { | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | if transport := ac.transportSnapshot(); transport.transport != nil { | |
| 64 | + | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | return clients, interrupts | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |
| 71 | + | defer func() { result = normalizeMoveRejection(result) }() | |
| 72 | + | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| 73 | + | return errMovePaneInvalid | |
| 74 | + | } | |
| 75 | + | d.mu.Lock() | |
| 76 | + | source := moveSessionForLocatorLocked(d, req.Source) | |
| 77 | + | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 78 | + | d.mu.Unlock() | |
| 79 | + | if source == nil || destination == nil { | |
| 80 | + | return errMoveStaleTarget | |
| 81 | + | } | |
| 82 | + | if source == destination { | |
| 83 | + | return errMovePaneInvalid | |
| 84 | + | } | |
| 85 | + | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 86 | + | if err != nil { | |
| 87 | + | return errMoveStaleTarget | |
| 88 | + | } | |
| 89 | + | reservationHeld := true | |
| 90 | + | defer func() { | |
| 91 | + | if reservationHeld { | |
| 92 | + | reservation.Release() | |
| 93 | + | } | |
| 94 | + | }() | |
| 95 | + | if d.afterMoveLifecycleReserved != nil { | |
| 96 | + | d.afterMoveLifecycleReserved() | |
| 97 | + | } | |
| 98 | + | unlockDispatch := lockMoveDispatch(source, destination) | |
| 99 | + | dispatchHeld := true | |
| 100 | + | defer func() { | |
| 101 | + | if dispatchHeld { | |
| 102 | + | unlockDispatch() | |
| 103 | + | } | |
| 104 | + | }() | |
| 105 | + | ||
| 106 | + | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | |
| 107 | + | if err != nil { | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | if d.afterMoveTabSourceSnapshot != nil { | |
| 111 | + | d.afterMoveTabSourceSnapshot() | |
| 112 | + | } | |
| 113 | + | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |
| 114 | + | var frozen frozenRoleEffectGates | |
| 115 | + | if admission.finalSource && admission.sourceClient != nil { | |
| 116 | + | transport := admission.sourceClient.transportSnapshot() | |
| 117 | + | token := source.attachmentToken(admission.sourceClient, transport.transport) | |
| 118 | + | if token.role != attachmentActive || token.transport.transport == nil { | |
| 119 | + | return errMovePaneInvalid | |
| 120 | + | } | |
| 121 | + | commit.handoffReq = attachmentTransitionRequest{ | |
| 122 | + | source: source, target: destination, next: admission.sourceClient, | |
| 123 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 124 | + | expectedTransport: token.transport, sourceToken: &token, | |
| 125 | + | expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab", | |
| 126 | + | } | |
| 127 | + | var participants attachmentTransitionParticipants | |
| 128 | + | commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq) | |
| 129 | + | if err != nil { | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched) | |
| 133 | + | participants.clients = append(participants.clients, snatchedClients...) | |
| 134 | + | participants.interrupts = append(participants.interrupts, snatchedInterrupts...) | |
| 135 | + | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 136 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 137 | + | d.afterRoleEffectGateFrozen("move-tab", ac) | |
| 138 | + | } | |
| 139 | + | }}, participants.clients...) | |
| 140 | + | if !frozen.acquired || !frozen.drained { | |
| 141 | + | frozen.unfreeze() | |
| 142 | + | return errMovePaneInvalid | |
| 143 | + | } | |
| 144 | + | commit.handoffFrozen = true | |
| 145 | + | commit.sourceRolesFrozen = true | |
| 146 | + | commit.handoffReq.roleEffectsFrozen = true | |
| 147 | + | } else if admission.finalSource && len(admission.sourceSnatched) > 0 { | |
| 148 | + | clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched) | |
| 149 | + | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...) | |
| 150 | + | if !frozen.acquired || !frozen.drained { | |
| 151 | + | frozen.unfreeze() | |
| 152 | + | return errMovePaneInvalid | |
| 153 | + | } | |
| 154 | + | commit.sourceRolesFrozen = true | |
| 155 | + | } | |
| 156 | + | commit.frozenRoles = frozen | |
| 157 | + | rolesFrozen := commit.sourceRolesFrozen | |
| 158 | + | defer func() { | |
| 159 | + | if rolesFrozen { | |
| 160 | + | frozen.unfreeze() | |
| 161 | + | } | |
| 162 | + | }() | |
| 163 | + | ||
| 164 | + | fences := newMoveTabResizeFences(source, destination, admission.tab) | |
| 165 | + | if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) { | |
| 166 | + | commit.releasePublication() | |
| 167 | + | if commit.err != nil { | |
| 168 | + | return commit.err | |
| 169 | + | } | |
| 170 | + | return errMoveStaleTarget | |
| 171 | + | } | |
| 172 | + | fences.Release() | |
| 173 | + | postcommit := movePanePostcommitPlan{ | |
| 174 | + | source: source, | |
| 175 | + | destination: destination, | |
| 176 | + | sourceTab: admission.tab, | |
| 177 | + | destinationTab: admission.tab, | |
| 178 | + | movedPane: firstMovePane(admission.panes), | |
| 179 | + | movedPanes: admission.panes, | |
| 180 | + | sourceCleanupToken: commit.sourceCleanupToken, | |
| 181 | + | handoffResult: commit.handoffResult, | |
| 182 | + | syncCleanup: commit.syncCleanup, | |
| 183 | + | frozenRoles: frozen, | |
| 184 | + | rolesFrozen: rolesFrozen, | |
| 185 | + | unlockDispatch: unlockDispatch, | |
| 186 | + | reservation: reservation, | |
| 187 | + | oldTabCancel: commit.oldTabCancel, | |
| 188 | + | sourceTabWasActive: !commit.sourceEmpty, | |
| 189 | + | sourceTabRemoved: true, | |
| 190 | + | sourceEmpty: commit.sourceEmpty, | |
| 191 | + | retiredParked: commit.retiredParked, | |
| 192 | + | retiredAttachments: commit.retiredAttachments, | |
| 193 | + | sourceMetadata: commit.sourceMetadata, | |
| 194 | + | sourceMetadataValid: commit.sourceMetadataValid, | |
| 195 | + | destinationMetadata: commit.destMetadata, | |
| 196 | + | destinationMetadataValid: commit.destMetadataValid, | |
| 197 | + | } | |
| 198 | + | rolesFrozen = false | |
| 199 | + | dispatchHeld = false | |
| 200 | + | reservationHeld = false | |
| 201 | + | postcommit.execute(d) | |
| 202 | + | return nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) { | |
| 206 | + | d.mu.Lock() | |
| 207 | + | defer d.mu.Unlock() | |
| 208 | + | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 209 | + | return nil, errMoveStaleTarget | |
| 210 | + | } | |
| 211 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 212 | + | defer unlockSessions() | |
| 213 | + | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 214 | + | return nil, errMoveStaleTarget | |
| 215 | + | } | |
| 216 | + | moved := findMoveTabLocked(source, req.SourceTabID) | |
| 217 | + | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | |
| 218 | + | return nil, errMoveStaleTarget | |
| 219 | + | } | |
| 220 | + | destinationActive := destination.tabs[destination.active] | |
| 221 | + | destinationActive.mu.Lock() | |
| 222 | + | destinationSize := destinationActive.size | |
| 223 | + | destinationActive.mu.Unlock() | |
| 224 | + | moved.mu.Lock() | |
| 225 | + | defer moved.mu.Unlock() | |
| 226 | + | if !moved.floatingTransferableLocked() { | |
| 227 | + | return nil, errMoveFloatingWarming | |
| 228 | + | } | |
| 229 | + | if moved.tree == nil || moved.tree.Root == nil { | |
| 230 | + | return nil, errMovePaneInvalid | |
| 231 | + | } | |
| 232 | + | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | |
| 233 | + | return nil, errMoveTooSmall | |
| 234 | + | } | |
| 235 | + | panes := make([]*pane, 0, len(moved.panes)+1) | |
| 236 | + | for _, p := range moved.panes { | |
| 237 | + | panes = append(panes, p) | |
| 238 | + | } | |
| 239 | + | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |
| 240 | + | panes = append(panes, moved.floating.pane) | |
| 241 | + | } | |
| 242 | + | idx := indexMoveTabLocked(source, moved) | |
| 243 | + | return &moveTabAdmission{ | |
| 244 | + | tab: moved, sourceIndex: idx, | |
| 245 | + | sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 246 | + | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |
| 247 | + | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |
| 248 | + | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |
| 249 | + | finalSource: len(source.tabs) == 1, | |
| 250 | + | }, nil | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (c *moveTabCommit) releasePublication() { | |
| 254 | + | if c.handoffPublication != nil { | |
| 255 | + | c.handoffPublication.unlockCoordinators() | |
| 256 | + | c.handoffPublication = nil | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |
| 261 | + | d.mu.Lock() | |
| 262 | + | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| 263 | + | d.mu.Unlock() | |
| 264 | + | return false | |
| 265 | + | } | |
| 266 | + | d.notices.routingMu.Lock() | |
| 267 | + | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |
| 268 | + | defer unlockSessions() | |
| 269 | + | defer d.notices.routingMu.Unlock() | |
| 270 | + | defer d.mu.Unlock() | |
| 271 | + | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | moved := c.admission.tab | |
| 275 | + | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | |
| 276 | + | len(c.source.tabs) == 1 != c.admission.finalSource { | |
| 277 | + | return false | |
| 278 | + | } | |
| 279 | + | // Attachment validation inspects the expected source tab and therefore must | |
| 280 | + | // precede moved.mu. The resize fences and ordered session locks keep topology | |
| 281 | + | // stable until the tab/pane checks below complete. | |
| 282 | + | if c.handoffFrozen { | |
| 283 | + | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) { | |
| 284 | + | return false | |
| 285 | + | } | |
| 286 | + | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| 287 | + | if err != nil { | |
| 288 | + | c.err = err | |
| 289 | + | return false | |
| 290 | + | } | |
| 291 | + | c.handoffPublication = publication | |
| 292 | + | defer c.releasePublication() | |
| 293 | + | } | |
| 294 | + | var retirement frozenMoveAttachmentRetirement | |
| 295 | + | if c.admission.finalSource { | |
| 296 | + | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) || | |
| 297 | + | (c.admission.sourceClient != nil) != c.handoffFrozen { | |
| 298 | + | return false | |
| 299 | + | } | |
| 300 | + | var retirementOK bool | |
| 301 | + | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles) | |
| 302 | + | if !retirementOK { | |
| 303 | + | return false | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | |
| 307 | + | return false | |
| 308 | + | } | |
| 309 | + | c.admission.destinationActive.mu.Lock() | |
| 310 | + | destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize | |
| 311 | + | c.admission.destinationActive.mu.Unlock() | |
| 312 | + | if !destinationSizeCurrent { | |
| 313 | + | return false | |
| 314 | + | } | |
| 315 | + | moved.mu.Lock() | |
| 316 | + | defer moved.mu.Unlock() | |
| 317 | + | if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() || | |
| 318 | + | moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane || | |
| 319 | + | moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) { | |
| 320 | + | return false | |
| 321 | + | } | |
| 322 | + | for _, p := range fencedPanes { | |
| 323 | + | p.mu.Lock() | |
| 324 | + | defer p.mu.Unlock() | |
| 325 | + | owner := p.ownerSnapshot() | |
| 326 | + | if owner == nil || owner.session != c.source || owner.tab != moved { | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | idx := c.admission.sourceIndex | |
| 332 | + | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 333 | + | if len(c.source.tabs) == 0 { | |
| 334 | + | c.source.active = -1 | |
| 335 | + | } else if c.source.active > idx { | |
| 336 | + | c.source.active-- | |
| 337 | + | } else if c.source.active == idx { | |
| 338 | + | if idx >= len(c.source.tabs) { | |
| 339 | + | idx = len(c.source.tabs) - 1 | |
| 340 | + | } | |
| 341 | + | c.source.active = idx | |
| 342 | + | } | |
| 343 | + | c.destination.tabs = append(c.destination.tabs, moved) | |
| 344 | + | moved.size = c.admission.destinationSize | |
| 345 | + | moved.bumpLayoutGenerationLocked() | |
| 346 | + | c.oldTabCancel = moved.cancel | |
| 347 | + | parent := c.destination.ctx | |
| 348 | + | if parent == nil { | |
| 349 | + | parent = d.serveCtx | |
| 350 | + | } | |
| 351 | + | moved.ctx, moved.cancel = context.WithCancel(parent) | |
| 352 | + | type ownerChange struct { | |
| 353 | + | pane *pane | |
| 354 | + | oldOwner paneEffectLease | |
| 355 | + | newOwner paneEffectLease | |
| 356 | + | } | |
| 357 | + | ownerChanges := make([]ownerChange, 0, len(fencedPanes)) | |
| 358 | + | for _, p := range fencedPanes { | |
| 359 | + | floatingGeneration := uint64(0) | |
| 360 | + | if p == moved.floating.pane { | |
| 361 | + | floatingGeneration = moved.floating.generation | |
| 362 | + | } | |
| 363 | + | oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration) | |
| 364 | + | ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner}) | |
| 365 | + | } | |
| 366 | + | if d.beforeMoveTabCommit != nil { | |
| 367 | + | d.beforeMoveTabCommit() | |
| 368 | + | } | |
| 369 | + | if c.handoffPublication != nil { | |
| 370 | + | c.destination.active = len(c.destination.tabs) - 1 | |
| 371 | + | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |
| 372 | + | c.handoffPublication.unlockCoordinators() | |
| 373 | + | c.handoffPublication = nil | |
| 374 | + | } | |
| 375 | + | // Attachment publication retains source/destination coordinator locks. | |
| 376 | + | // Release those first, then migrate synchronized-output batches exactly as | |
| 377 | + | // Move pane does, while pane parsing remains fenced. | |
| 378 | + | for _, change := range ownerChanges { | |
| 379 | + | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | |
| 380 | + | } | |
| 381 | + | if c.handoffResult.published.ac != nil { | |
| 382 | + | c.sourceCleanupToken = c.handoffResult.published | |
| 383 | + | } else if c.source.client != nil { | |
| 384 | + | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |
| 385 | + | } | |
| 386 | + | if len(c.source.tabs) == 0 { | |
| 387 | + | delete(d.sessions, c.source.id) | |
| 388 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 389 | + | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 390 | + | } | |
| 391 | + | c.sourceEmpty = len(c.source.tabs) == 0 | |
| 392 | + | if !c.source.ephemeral { | |
| 393 | + | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |
| 394 | + | c.sourceMetadataValid = true | |
| 395 | + | } | |
| 396 | + | if !c.destination.ephemeral { | |
| 397 | + | c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |
| 398 | + | c.destMetadataValid = true | |
| 399 | + | } | |
| 400 | + | return true | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | |
| 404 | + | current := make(map[*pane]struct{}, len(tb.panes)+1) | |
| 405 | + | for _, p := range tb.panes { | |
| 406 | + | current[p] = struct{}{} | |
| 407 | + | } | |
| 408 | + | if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) { | |
| 409 | + | current[tb.floating.pane] = struct{}{} | |
| 410 | + | } | |
| 411 | + | if len(current) != len(admitted) || len(current) != len(fenced) { | |
| 412 | + | return false | |
| 413 | + | } | |
| 414 | + | for _, p := range admitted { | |
| 415 | + | if _, ok := current[p]; !ok { | |
| 416 | + | return false | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | for _, p := range fenced { | |
| 420 | + | if _, ok := current[p]; !ok { | |
| 421 | + | return false | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | return true | |
| 425 | + | } | |
A internal/usecase/daemon/move_tab_handoff_test.go +339 -0
| @@ -0,0 +1,339 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) { | |
| 14 | + | t.Helper() | |
| 15 | + | d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 16 | + | follower.replaceTransport(&closeTrackingTransport{}) | |
| 17 | + | moved := source.tabs[0] | |
| 18 | + | moved.stableID = "moved-tab" | |
| 19 | + | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 20 | + | ||
| 21 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 22 | + | displacedTransport := &closeTrackingTransport{} | |
| 23 | + | displaced := &attachedClient{ | |
| 24 | + | tr: displacedTransport, output: newOutputStateStream(), size: follower.size, | |
| 25 | + | } | |
| 26 | + | displaced.initOverlays() | |
| 27 | + | displaced.setSession(destination) | |
| 28 | + | destination.mu.Lock() | |
| 29 | + | destination.client = displaced | |
| 30 | + | destination.mu.Unlock() | |
| 31 | + | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 32 | + | ||
| 33 | + | return d, source, follower, destination, displaced, displacedTransport, moved | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) { | |
| 37 | + | t.Helper() | |
| 38 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 39 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 40 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 41 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 42 | + | })) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func detachedWithReason(frames []ports.Frame, reason uint8) bool { | |
| 46 | + | for _, frame := range frames { | |
| 47 | + | if frame.Type != ports.MsgDetached { | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | detached, err := ports.UnmarshalDetached(frame.Payload) | |
| 51 | + | if err == nil && detached.Reason == reason { | |
| 52 | + | return true | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) { | |
| 59 | + | d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t) | |
| 60 | + | ||
| 61 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 62 | + | d.attachmentCleanupWg.Wait() | |
| 63 | + | ||
| 64 | + | destination.mu.Lock() | |
| 65 | + | require.Same(t, follower, destination.client) | |
| 66 | + | require.Contains(t, destination.snatched, displaced) | |
| 67 | + | require.Same(t, moved, destination.tabs[destination.active]) | |
| 68 | + | destination.mu.Unlock() | |
| 69 | + | require.Same(t, destination, follower.currentSession()) | |
| 70 | + | require.Equal(t, attachmentActive, destination.attachmentRole(follower)) | |
| 71 | + | require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced)) | |
| 72 | + | ||
| 73 | + | frames := displacedTransport.Sends() | |
| 74 | + | require.Len(t, frames, 1) | |
| 75 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 76 | + | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 79 | + | require.False(t, detachedWithReason(frames, ports.ReasonReplaced)) | |
| 80 | + | require.False(t, displacedTransport.Closed()) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) { | |
| 84 | + | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 85 | + | ||
| 86 | + | waitingTransport := &closeTrackingTransport{} | |
| 87 | + | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size} | |
| 88 | + | waiting.initOverlays() | |
| 89 | + | waiting.setSession(destination) | |
| 90 | + | destination.mu.Lock() | |
| 91 | + | destination.addSnatchedLocked(waiting) | |
| 92 | + | destination.mu.Unlock() | |
| 93 | + | ||
| 94 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 95 | + | d.attachmentCleanupWg.Wait() | |
| 96 | + | ||
| 97 | + | destination.mu.Lock() | |
| 98 | + | require.Contains(t, destination.snatched, displaced) | |
| 99 | + | require.Contains(t, destination.snatched, waiting) | |
| 100 | + | require.Len(t, destination.snatched, 2) | |
| 101 | + | destination.mu.Unlock() | |
| 102 | + | requireSingleOwnerAndLease(t, destination, follower, displaced, waiting) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) { | |
| 106 | + | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 107 | + | ||
| 108 | + | parkedTransport := &closeTrackingTransport{} | |
| 109 | + | parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true} | |
| 110 | + | parked.initOverlays() | |
| 111 | + | require.True(t, d.parkAttachment(source, parked)) | |
| 112 | + | ||
| 113 | + | sourceSnatchedTransport := &closeTrackingTransport{} | |
| 114 | + | sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size} | |
| 115 | + | sourceSnatched.initOverlays() | |
| 116 | + | sourceSnatched.setSession(source) | |
| 117 | + | source.mu.Lock() | |
| 118 | + | source.addSnatchedLocked(sourceSnatched) | |
| 119 | + | source.mu.Unlock() | |
| 120 | + | ||
| 121 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 122 | + | d.attachmentCleanupWg.Wait() | |
| 123 | + | d.waitNotifies() | |
| 124 | + | ||
| 125 | + | d.mu.Lock() | |
| 126 | + | require.Empty(t, d.parked) | |
| 127 | + | d.mu.Unlock() | |
| 128 | + | require.True(t, parkedTransport.Closed()) | |
| 129 | + | require.True(t, sourceSnatchedTransport.Closed()) | |
| 130 | + | require.Nil(t, sourceSnatched.currentSession()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) { | |
| 134 | + | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 135 | + | movedPane := moved.focusedPane() | |
| 136 | + | ||
| 137 | + | follower.overlays.copyMu.Lock() | |
| 138 | + | follower.overlays.copyPane = movedPane | |
| 139 | + | follower.overlays.copyMode = &scopy.Mode{} | |
| 140 | + | follower.overlays.copyMu.Unlock() | |
| 141 | + | follower.sendMu.Lock() | |
| 142 | + | follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}} | |
| 143 | + | follower.sendMu.Unlock() | |
| 144 | + | ||
| 145 | + | otherPane := destination.tabs[0].focusedPane() | |
| 146 | + | waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size} | |
| 147 | + | waiting.initOverlays() | |
| 148 | + | waiting.setSession(destination) | |
| 149 | + | waiting.overlays.copyMu.Lock() | |
| 150 | + | waiting.overlays.copyPane = otherPane | |
| 151 | + | waiting.overlays.copyMode = &scopy.Mode{} | |
| 152 | + | waiting.overlays.copyMu.Unlock() | |
| 153 | + | waiting.sendMu.Lock() | |
| 154 | + | waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}} | |
| 155 | + | waiting.sendMu.Unlock() | |
| 156 | + | destination.mu.Lock() | |
| 157 | + | destination.addSnatchedLocked(waiting) | |
| 158 | + | destination.mu.Unlock() | |
| 159 | + | ||
| 160 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 161 | + | ||
| 162 | + | follower.overlays.copyMu.Lock() | |
| 163 | + | require.Nil(t, follower.overlays.copyMode) | |
| 164 | + | require.Nil(t, follower.overlays.copyPane) | |
| 165 | + | follower.overlays.copyMu.Unlock() | |
| 166 | + | follower.sendMu.Lock() | |
| 167 | + | followerCapture, followerCaptured := follower.captureFrames[movedPane] | |
| 168 | + | follower.sendMu.Unlock() | |
| 169 | + | if followerCaptured { | |
| 170 | + | require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | waiting.overlays.copyMu.Lock() | |
| 174 | + | require.NotNil(t, waiting.overlays.copyMode) | |
| 175 | + | require.Same(t, otherPane, waiting.overlays.copyPane) | |
| 176 | + | waiting.overlays.copyMu.Unlock() | |
| 177 | + | waiting.sendMu.Lock() | |
| 178 | + | _, waitingCaptured := waiting.captureFrames[otherPane] | |
| 179 | + | waiting.sendMu.Unlock() | |
| 180 | + | require.True(t, waitingCaptured) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) { | |
| 184 | + | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 185 | + | followerRebased := make(chan struct{}) | |
| 186 | + | follower.renderStages.handoffRebase = func() { close(followerRebased) } | |
| 187 | + | ||
| 188 | + | displaced.sendMu.Lock() | |
| 189 | + | panelCleanupStarted := make(chan struct{}) | |
| 190 | + | d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) } | |
| 191 | + | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 192 | + | ||
| 193 | + | moveDone := make(chan struct{}) | |
| 194 | + | go func() { | |
| 195 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 196 | + | close(moveDone) | |
| 197 | + | }() | |
| 198 | + | ||
| 199 | + | awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start") | |
| 200 | + | awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup") | |
| 201 | + | awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint") | |
| 202 | + | ||
| 203 | + | displaced.sendMu.Unlock() | |
| 204 | + | d.attachmentCleanupWg.Wait() | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) { | |
| 208 | + | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 209 | + | ||
| 210 | + | paintAdmitted := make(chan struct{}) | |
| 211 | + | releasePaint := make(chan struct{}) | |
| 212 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 213 | + | if token.ac == follower && token.role == attachmentActive { | |
| 214 | + | close(paintAdmitted) | |
| 215 | + | <-releasePaint | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 219 | + | ||
| 220 | + | moveDone := make(chan error, 1) | |
| 221 | + | go func() { | |
| 222 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 223 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 224 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 225 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 226 | + | }) | |
| 227 | + | }() | |
| 228 | + | awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted") | |
| 229 | + | ||
| 230 | + | displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport) | |
| 231 | + | reclaimDone := make(chan struct{}) | |
| 232 | + | go func() { | |
| 233 | + | d.handleSnatchedClientFrame(displacedToken, ports.Frame{ | |
| 234 | + | Type: ports.MsgInput, | |
| 235 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 236 | + | }) | |
| 237 | + | close(reclaimDone) | |
| 238 | + | }() | |
| 239 | + | ||
| 240 | + | close(releasePaint) | |
| 241 | + | require.NoError(t, <-moveDone) | |
| 242 | + | awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish") | |
| 243 | + | d.attachmentCleanupWg.Wait() | |
| 244 | + | ||
| 245 | + | requireSingleOwnerAndLease(t, destination, follower, displaced) | |
| 246 | + | destination.mu.Lock() | |
| 247 | + | require.Same(t, displaced, destination.client) | |
| 248 | + | destination.mu.Unlock() | |
| 249 | + | require.Equal(t, attachmentSnatched, destination.attachmentRole(follower)) | |
| 250 | + | require.Equal(t, attachmentActive, destination.attachmentRole(displaced)) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) { | |
| 254 | + | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 255 | + | ||
| 256 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 257 | + | if token.ac == follower { | |
| 258 | + | token.ac.roleGeneration.Add(1) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 262 | + | ||
| 263 | + | beforeTabs := append([]*tab(nil), destination.tabs...) | |
| 264 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 265 | + | ||
| 266 | + | require.Nil(t, source.tabs) | |
| 267 | + | require.Same(t, destination, follower.currentSession()) | |
| 268 | + | require.Same(t, follower, destination.client) | |
| 269 | + | require.Equal(t, append(beforeTabs, moved), destination.tabs) | |
| 270 | + | require.Same(t, moved, destination.tabs[destination.active]) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) { | |
| 274 | + | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 275 | + | ||
| 276 | + | commitEntered := make(chan struct{}) | |
| 277 | + | releaseCommit := make(chan struct{}) | |
| 278 | + | d.beforeMoveTabCommit = func() { | |
| 279 | + | close(commitEntered) | |
| 280 | + | <-releaseCommit | |
| 281 | + | } | |
| 282 | + | defer func() { d.beforeMoveTabCommit = nil }() | |
| 283 | + | ||
| 284 | + | moveDone := make(chan error, 1) | |
| 285 | + | go func() { | |
| 286 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 287 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 288 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 289 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 290 | + | }) | |
| 291 | + | }() | |
| 292 | + | <-commitEntered | |
| 293 | + | ||
| 294 | + | type observation struct { | |
| 295 | + | sourceRegistered bool | |
| 296 | + | movedInSource bool | |
| 297 | + | movedInDestination bool | |
| 298 | + | followerOnDestination bool | |
| 299 | + | displacedSnatched bool | |
| 300 | + | movedActive bool | |
| 301 | + | } | |
| 302 | + | read := func() observation { | |
| 303 | + | d.mu.Lock() | |
| 304 | + | _, sourceLive := d.sessions[source.id] | |
| 305 | + | d.mu.Unlock() | |
| 306 | + | source.mu.Lock() | |
| 307 | + | movedInSource := indexMoveTabLocked(source, moved) >= 0 | |
| 308 | + | source.mu.Unlock() | |
| 309 | + | destination.mu.Lock() | |
| 310 | + | obs := observation{ | |
| 311 | + | sourceRegistered: sourceLive, | |
| 312 | + | movedInSource: movedInSource, | |
| 313 | + | movedInDestination: indexMoveTabLocked(destination, moved) >= 0, | |
| 314 | + | followerOnDestination: destination.client == follower, | |
| 315 | + | displacedSnatched: false, | |
| 316 | + | movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved, | |
| 317 | + | } | |
| 318 | + | if displaced != nil { | |
| 319 | + | _, obs.displacedSnatched = destination.snatched[displaced] | |
| 320 | + | } | |
| 321 | + | destination.mu.Unlock() | |
| 322 | + | return obs | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication") | |
| 326 | + | require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication") | |
| 327 | + | require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication") | |
| 328 | + | ||
| 329 | + | close(releaseCommit) | |
| 330 | + | require.NoError(t, <-moveDone) | |
| 331 | + | ||
| 332 | + | final := read() | |
| 333 | + | require.False(t, final.sourceRegistered) | |
| 334 | + | require.False(t, final.movedInSource) | |
| 335 | + | require.True(t, final.movedInDestination) | |
| 336 | + | require.True(t, final.followerOnDestination) | |
| 337 | + | require.True(t, final.displacedSnatched) | |
| 338 | + | require.True(t, final.movedActive) | |
| 339 | + | } | |
A internal/usecase/daemon/move_tab_test.go +584 -0
| @@ -0,0 +1,584 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |
| 18 | + | for _, destinationEphemeral := range []bool{false, true} { | |
| 19 | + | t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) { | |
| 20 | + | movedPTY := newQuietPTY() | |
| 21 | + | remainingPTY := newQuietPTY() | |
| 22 | + | destinationPTY := newQuietPTY() | |
| 23 | + | floatingPTY := newQuietPTY() | |
| 24 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 25 | + | moved := source.tabs[0] | |
| 26 | + | moved.stableID = "moved-tab" | |
| 27 | + | moved.name = "preserved-name" | |
| 28 | + | remaining := source.tabs[1] | |
| 29 | + | remaining.stableID = "remaining-tab" | |
| 30 | + | source.active = 0 | |
| 31 | + | ||
| 32 | + | destinationCtx, destinationCancel := context.WithCancel(d.serveCtx) | |
| 33 | + | t.Cleanup(destinationCancel) | |
| 34 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | |
| 35 | + | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |
| 36 | + | destination := &session{ | |
| 37 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral, | |
| 38 | + | ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0, | |
| 39 | + | } | |
| 40 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 41 | + | ||
| 42 | + | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 43 | + | moved.mu.Lock() | |
| 44 | + | moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7} | |
| 45 | + | moved.mu.Unlock() | |
| 46 | + | publishPaneOwner(floating, source, moved, 7) | |
| 47 | + | ||
| 48 | + | d.mu.Lock() | |
| 49 | + | d.sessions[destination.id] = destination | |
| 50 | + | d.mu.Unlock() | |
| 51 | + | ||
| 52 | + | tiled := moved.focusedPane() | |
| 53 | + | tiledScreen := tiled.screen | |
| 54 | + | tiledOwnerGeneration := tiled.ownerGeneration | |
| 55 | + | floatingOwnerGeneration := floating.ownerGeneration | |
| 56 | + | oldWorkerCtx := moved.ctx | |
| 57 | + | oldTree := moved.tree | |
| 58 | + | oldPanes := moved.panes | |
| 59 | + | beforeDestinationActive := destination.tabs[destination.active] | |
| 60 | + | ||
| 61 | + | err := d.moveTab(moveTabRequest{ | |
| 62 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 63 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 64 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 65 | + | }) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | ||
| 68 | + | require.Equal(t, []*tab{remaining}, source.tabs) | |
| 69 | + | require.Zero(t, source.active) | |
| 70 | + | require.Same(t, remaining, source.tabs[source.active]) | |
| 71 | + | require.Equal(t, []*tab{destinationTab, moved}, destination.tabs) | |
| 72 | + | require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background") | |
| 73 | + | require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative") | |
| 74 | + | require.Equal(t, "preserved-name", moved.name) | |
| 75 | + | require.Same(t, oldTree, moved.tree) | |
| 76 | + | require.Equal(t, oldPanes, moved.panes) | |
| 77 | + | require.Same(t, movedPTY, tiled.pty) | |
| 78 | + | require.Same(t, tiledScreen, tiled.screen) | |
| 79 | + | require.Same(t, floating, moved.floating.pane) | |
| 80 | + | require.Same(t, floatingPTY, floating.pty) | |
| 81 | + | require.Equal(t, floatingHidden, moved.floating.state) | |
| 82 | + | require.Equal(t, uint64(7), moved.floating.generation) | |
| 83 | + | require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration) | |
| 84 | + | require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration) | |
| 85 | + | require.Same(t, destination, tiled.ownerSnapshot().session) | |
| 86 | + | require.Same(t, destination, floating.ownerSnapshot().session) | |
| 87 | + | require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled") | |
| 88 | + | require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination") | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) { | |
| 94 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 95 | + | moved := source.tabs[0] | |
| 96 | + | moved.stableID = "moved-tab" | |
| 97 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 98 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 99 | + | ||
| 100 | + | floatingPTY := newQuietPTY() | |
| 101 | + | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 102 | + | floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx) | |
| 103 | + | t.Cleanup(floatingCancel) | |
| 104 | + | floating.ctx, floating.cancel = floatingCtx, floatingCancel | |
| 105 | + | moved.mu.Lock() | |
| 106 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |
| 107 | + | moved.mu.Unlock() | |
| 108 | + | publishPaneOwner(floating, source, moved, 7) | |
| 109 | + | d.applyTabLayout(source, moved) | |
| 110 | + | ||
| 111 | + | beforeScreen := floating.screen | |
| 112 | + | beforeHistory := floating.history | |
| 113 | + | beforeContext := floating.ctx | |
| 114 | + | beforeOwnerGeneration := floating.ownerGeneration | |
| 115 | + | ||
| 116 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 117 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 118 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 119 | + | })) | |
| 120 | + | ||
| 121 | + | require.Same(t, moved, destination.tabs[destination.active]) | |
| 122 | + | require.Same(t, client, destination.client) | |
| 123 | + | require.Same(t, destination, client.currentSession()) | |
| 124 | + | moved.mu.Lock() | |
| 125 | + | require.Equal(t, floatingVisible, moved.floating.state) | |
| 126 | + | require.True(t, moved.floating.desiredVisible) | |
| 127 | + | require.Equal(t, uint64(7), moved.floating.generation) | |
| 128 | + | require.Same(t, floating, moved.floating.pane) | |
| 129 | + | moved.mu.Unlock() | |
| 130 | + | expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig()) | |
| 131 | + | floating.mu.Lock() | |
| 132 | + | require.Same(t, floatingPTY, floating.pty) | |
| 133 | + | require.Same(t, beforeScreen, floating.screen) | |
| 134 | + | require.Same(t, beforeHistory, floating.history) | |
| 135 | + | require.Equal(t, beforeContext, floating.ctx) | |
| 136 | + | require.Equal(t, expectedGeometry, floating.popupGeometry) | |
| 137 | + | require.Equal(t, expectedGeometry.Inner, floating.rect) | |
| 138 | + | require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration) | |
| 139 | + | floating.mu.Unlock() | |
| 140 | + | owner := floating.ownerSnapshot() | |
| 141 | + | require.Same(t, destination, owner.session) | |
| 142 | + | require.Same(t, moved, owner.tab) | |
| 143 | + | require.Equal(t, floating.ownerGeneration, owner.generation) | |
| 144 | + | require.Equal(t, uint64(7), owner.floatingSlotGeneration) | |
| 145 | + | require.NoError(t, floating.ctx.Err(), "floating process context remains live") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |
| 149 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 150 | + | moved := source.tabs[0] | |
| 151 | + | moved.stableID = "moved-tab" | |
| 152 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 153 | + | rebased := false | |
| 154 | + | client.renderStages.handoffRebase = func() { rebased = true } | |
| 155 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 156 | + | oldDestinationActive := destination.tabs[0] | |
| 157 | + | ||
| 158 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 159 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 160 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 161 | + | })) | |
| 162 | + | ||
| 163 | + | require.Nil(t, source.tabs) | |
| 164 | + | require.Same(t, destination, client.currentSession()) | |
| 165 | + | require.Same(t, client, destination.client) | |
| 166 | + | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 167 | + | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | |
| 168 | + | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | |
| 169 | + | require.True(t, rebased) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |
| 173 | + | tests := []struct { | |
| 174 | + | name string | |
| 175 | + | mutate func(source, destination *session, moved *tab, req *moveTabRequest) | |
| 176 | + | wantErr error | |
| 177 | + | }{ | |
| 178 | + | {name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { | |
| 179 | + | req.Destination = req.Source | |
| 180 | + | }}, | |
| 181 | + | {name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { | |
| 182 | + | req.Destination.Incarnation[0]++ | |
| 183 | + | }}, | |
| 184 | + | {name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { | |
| 185 | + | source.tabs = source.tabs[1:] | |
| 186 | + | _ = moved | |
| 187 | + | }}, | |
| 188 | + | {name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { | |
| 189 | + | moved.floating.state = floatingWarming | |
| 190 | + | moved.floating.generation = 3 | |
| 191 | + | }}, | |
| 192 | + | {name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { | |
| 193 | + | destination.teardownMu.Lock() | |
| 194 | + | destination.teardownActive = true | |
| 195 | + | destination.teardownMu.Unlock() | |
| 196 | + | }}, | |
| 197 | + | } | |
| 198 | + | for _, tt := range tests { | |
| 199 | + | t.Run(tt.name, func(t *testing.T) { | |
| 200 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 201 | + | moved := source.tabs[0] | |
| 202 | + | moved.stableID = "moved-tab" | |
| 203 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 204 | + | req := moveTabRequest{ | |
| 205 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 206 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 207 | + | } | |
| 208 | + | tt.mutate(source, destination, moved, &req) | |
| 209 | + | beforeDestination := append([]*tab(nil), destination.tabs...) | |
| 210 | + | beforeOwner := moved.focusedPane().ownerSnapshot() | |
| 211 | + | ||
| 212 | + | err := d.moveTab(req) | |
| 213 | + | destination.teardownMu.Lock() | |
| 214 | + | destination.teardownActive = false | |
| 215 | + | destination.teardownMu.Unlock() | |
| 216 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 217 | + | var rejection *moveRejection | |
| 218 | + | require.ErrorAs(t, err, &rejection) | |
| 219 | + | require.Equal(t, beforeDestination, destination.tabs) | |
| 220 | + | require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot()) | |
| 221 | + | }) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) { | |
| 226 | + | for _, tt := range []struct { | |
| 227 | + | name string | |
| 228 | + | mutate func(*tab) | |
| 229 | + | }{ | |
| 230 | + | {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }}, | |
| 231 | + | {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }}, | |
| 232 | + | } { | |
| 233 | + | t.Run(tt.name, func(t *testing.T) { | |
| 234 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 235 | + | moved := source.tabs[0] | |
| 236 | + | moved.stableID = "moved-tab" | |
| 237 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 238 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 239 | + | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 240 | + | moved.mu.Lock() | |
| 241 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |
| 242 | + | moved.mu.Unlock() | |
| 243 | + | publishPaneOwner(floating, source, moved, 7) | |
| 244 | + | ||
| 245 | + | source.layoutApplyMu.Lock() | |
| 246 | + | admitted := make(chan struct{}) | |
| 247 | + | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 248 | + | if action == "move-tab" && ac == client { | |
| 249 | + | close(admitted) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | moveDone := make(chan error, 1) | |
| 253 | + | go func() { | |
| 254 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 255 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 256 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 257 | + | }) | |
| 258 | + | }() | |
| 259 | + | awaitTestCompletion(t, admitted, "move-tab did not finish admission") | |
| 260 | + | moved.mu.Lock() | |
| 261 | + | tt.mutate(moved) | |
| 262 | + | moved.mu.Unlock() | |
| 263 | + | source.layoutApplyMu.Unlock() | |
| 264 | + | ||
| 265 | + | require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return")) | |
| 266 | + | require.NotContains(t, destination.tabs, moved) | |
| 267 | + | require.Same(t, source, floating.ownerSnapshot().session) | |
| 268 | + | d.afterRoleEffectGateFrozen = nil | |
| 269 | + | }) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) { | |
| 274 | + | pty := &transactionalResizePTY{} | |
| 275 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY()) | |
| 276 | + | moved := source.tabs[0] | |
| 277 | + | moved.stableID = "moved-tab" | |
| 278 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 279 | + | destination.tabs[0].mu.Lock() | |
| 280 | + | destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30} | |
| 281 | + | destination.tabs[0].mu.Unlock() | |
| 282 | + | moved.mu.Lock() | |
| 283 | + | moved.size = domain.Size{Cols: 90, Rows: 20} | |
| 284 | + | moved.bumpLayoutGenerationLocked() | |
| 285 | + | moved.mu.Unlock() | |
| 286 | + | ||
| 287 | + | resizeEntered := make(chan struct{}) | |
| 288 | + | releaseResize := make(chan struct{}) | |
| 289 | + | var once sync.Once | |
| 290 | + | pty.onResize = func() { | |
| 291 | + | once.Do(func() { | |
| 292 | + | close(resizeEntered) | |
| 293 | + | <-releaseResize | |
| 294 | + | }) | |
| 295 | + | } | |
| 296 | + | layoutDone := make(chan bool, 1) | |
| 297 | + | go func() { layoutDone <- d.applyTabLayout(source, moved) }() | |
| 298 | + | <-resizeEntered | |
| 299 | + | reserved := make(chan struct{}) | |
| 300 | + | d.afterMoveLifecycleReserved = func() { close(reserved) } | |
| 301 | + | defer func() { d.afterMoveLifecycleReserved = nil }() | |
| 302 | + | moveDone := make(chan error, 1) | |
| 303 | + | go func() { | |
| 304 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 305 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 306 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 307 | + | }) | |
| 308 | + | }() | |
| 309 | + | <-reserved | |
| 310 | + | select { | |
| 311 | + | case err := <-moveDone: | |
| 312 | + | t.Fatalf("move crossed blocked source resize: %v", err) | |
| 313 | + | default: | |
| 314 | + | } | |
| 315 | + | close(releaseResize) | |
| 316 | + | <-layoutDone // the old transaction may lose owner publication after its PTY call | |
| 317 | + | require.NoError(t, <-moveDone) | |
| 318 | + | ||
| 319 | + | pty.mu.Lock() | |
| 320 | + | sizes := append([]domain.Size(nil), pty.sizes...) | |
| 321 | + | pty.mu.Unlock() | |
| 322 | + | require.GreaterOrEqual(t, len(sizes), 2) | |
| 323 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize") | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) { | |
| 327 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 328 | + | moved := source.tabs[0] | |
| 329 | + | moved.stableID = "moved-tab" | |
| 330 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 331 | + | p := moved.focusedPane() | |
| 332 | + | sourceCoordinator := d.ensureRenderCoordinator(source) | |
| 333 | + | destinationCoordinator := d.ensureRenderCoordinator(destination) | |
| 334 | + | ||
| 335 | + | p.mu.Lock() | |
| 336 | + | p.screen.Write([]byte(renderer.SyncStartCSI)) | |
| 337 | + | generation := source.syncGen.Add(1) | |
| 338 | + | p.syncGen = generation | |
| 339 | + | oldLease := p.effectLeaseLocked() | |
| 340 | + | p.resizeRetry = true | |
| 341 | + | p.mu.Unlock() | |
| 342 | + | sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil) | |
| 343 | + | oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}}) | |
| 344 | + | ||
| 345 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 346 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 347 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 348 | + | })) | |
| 349 | + | ||
| 350 | + | require.False(t, oldLease.Current(), "source effects are revoked at owner publication") | |
| 351 | + | require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer") | |
| 352 | + | require.Same(t, destination, p.ownerSnapshot().session) | |
| 353 | + | sourceCoordinator.mu.Lock() | |
| 354 | + | require.NotContains(t, sourceCoordinator.syncBatches, p) | |
| 355 | + | sourceCoordinator.mu.Unlock() | |
| 356 | + | destinationCoordinator.mu.Lock() | |
| 357 | + | require.Contains(t, destinationCoordinator.syncBatches, p) | |
| 358 | + | destinationCoordinator.mu.Unlock() | |
| 359 | + | require.Equal(t, destination.syncGen.Load(), p.syncGen) | |
| 360 | + | p.mu.Lock() | |
| 361 | + | require.False(t, p.resizeRetry, "required resize work is completed under the destination owner") | |
| 362 | + | p.mu.Unlock() | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func TestMoveTabExitReapsOnlyDestination(t *testing.T) { | |
| 366 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 367 | + | moved := source.tabs[0] | |
| 368 | + | moved.stableID = "moved-tab" | |
| 369 | + | remaining := source.tabs[1] | |
| 370 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 371 | + | p := moved.focusedPane() | |
| 372 | + | ||
| 373 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 374 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 375 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 376 | + | })) | |
| 377 | + | d.reapPaneOwner(p) | |
| 378 | + | ||
| 379 | + | require.Equal(t, []*tab{remaining}, source.tabs) | |
| 380 | + | require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner") | |
| 381 | + | require.Nil(t, p.ownerSnapshot()) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) { | |
| 385 | + | d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 386 | + | left.tabs[0].stableID = "left-moved" | |
| 387 | + | right := addMoveTabTestSession(d, "right", "right-stays") | |
| 388 | + | rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 389 | + | rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx) | |
| 390 | + | publishTiledPaneOwners(right, rightMoved) | |
| 391 | + | right.tabs = append(right.tabs, rightMoved) | |
| 392 | + | ||
| 393 | + | start := make(chan struct{}) | |
| 394 | + | done := make(chan error, 2) | |
| 395 | + | go func() { | |
| 396 | + | <-start | |
| 397 | + | done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}}) | |
| 398 | + | }() | |
| 399 | + | go func() { | |
| 400 | + | <-start | |
| 401 | + | done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}}) | |
| 402 | + | }() | |
| 403 | + | close(start) | |
| 404 | + | for range 2 { | |
| 405 | + | select { | |
| 406 | + | case err := <-done: | |
| 407 | + | require.NoError(t, err) | |
| 408 | + | case <-time.After(5 * time.Second): | |
| 409 | + | t.Fatal("opposite-direction move tabs deadlocked") | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | type moveTabPurgeCatalogue struct { | |
| 415 | + | mu sync.Mutex | |
| 416 | + | records map[string]domain.CatalogueRecord | |
| 417 | + | events []string | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func (c *moveTabPurgeCatalogue) Records() ([]domain.CatalogueRecord, error) { | |
| 421 | + | c.mu.Lock() | |
| 422 | + | defer c.mu.Unlock() | |
| 423 | + | records := make([]domain.CatalogueRecord, 0, len(c.records)) | |
| 424 | + | for _, record := range c.records { | |
| 425 | + | records = append(records, record) | |
| 426 | + | } | |
| 427 | + | return records, nil | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |
| 431 | + | c.mu.Lock() | |
| 432 | + | defer c.mu.Unlock() | |
| 433 | + | record, ok := c.records[name] | |
| 434 | + | return record, ok, nil | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func (c *moveTabPurgeCatalogue) Create(domain.CatalogueRecord) error { | |
| 438 | + | return errors.New("move tab purge catalogue: Create is unsupported") | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func (c *moveTabPurgeCatalogue) Replace(string, domain.CatalogueRecord) error { | |
| 442 | + | return errors.New("move tab purge catalogue: Replace is unsupported") | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (c *moveTabPurgeCatalogue) Rename(string, domain.CatalogueRecord) error { | |
| 446 | + | return errors.New("move tab purge catalogue: Rename is unsupported") | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func (c *moveTabPurgeCatalogue) Delete(name string) error { | |
| 450 | + | c.mu.Lock() | |
| 451 | + | defer c.mu.Unlock() | |
| 452 | + | delete(c.records, name) | |
| 453 | + | c.events = append(c.events, "catalogue-delete:"+name) | |
| 454 | + | return nil | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error { | |
| 458 | + | c.mu.Lock() | |
| 459 | + | defer c.mu.Unlock() | |
| 460 | + | c.events = append(c.events, "metadata:"+update.Name) | |
| 461 | + | return nil | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func (c *moveTabPurgeCatalogue) Sync() error { return nil } | |
| 465 | + | func (c *moveTabPurgeCatalogue) Close() error { return nil } | |
| 466 | + | ||
| 467 | + | type moveTabPurgeRepository struct { | |
| 468 | + | noOpSnapshotRepository | |
| 469 | + | d *Daemon | |
| 470 | + | source *session | |
| 471 | + | destination *session | |
| 472 | + | moved *tab | |
| 473 | + | pane *pane | |
| 474 | + | client *attachedClient | |
| 475 | + | catalogue *moveTabPurgeCatalogue | |
| 476 | + | err error | |
| 477 | + | deleted []domain.IncarnationID | |
| 478 | + | outside bool | |
| 479 | + | dirty bool | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |
| 483 | + | r.deleted = append(r.deleted, id) | |
| 484 | + | daemonUnlocked := r.d.mu.TryLock() | |
| 485 | + | routingUnlocked := r.d.notices.routingMu.TryLock() | |
| 486 | + | sourceUnlocked := r.source.mu.TryLock() | |
| 487 | + | destinationUnlocked := r.destination.mu.TryLock() | |
| 488 | + | tabUnlocked := r.moved.mu.TryLock() | |
| 489 | + | paneUnlocked := r.pane.mu.TryLock() | |
| 490 | + | sourceFenceUnlocked := r.source.layoutApplyMu.TryLock() | |
| 491 | + | destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock() | |
| 492 | + | tabFenceUnlocked := r.moved.layoutApplyMu.TryLock() | |
| 493 | + | roleUnlocked := r.client.roleEffects.mu.TryLock() | |
| 494 | + | roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable | |
| 495 | + | r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked && | |
| 496 | + | sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable | |
| 497 | + | if roleUnlocked { | |
| 498 | + | r.client.roleEffects.mu.Unlock() | |
| 499 | + | } | |
| 500 | + | if tabFenceUnlocked { | |
| 501 | + | r.moved.layoutApplyMu.Unlock() | |
| 502 | + | } | |
| 503 | + | if destinationFenceUnlocked { | |
| 504 | + | r.destination.layoutApplyMu.Unlock() | |
| 505 | + | } | |
| 506 | + | if sourceFenceUnlocked { | |
| 507 | + | r.source.layoutApplyMu.Unlock() | |
| 508 | + | } | |
| 509 | + | if paneUnlocked { | |
| 510 | + | r.pane.mu.Unlock() | |
| 511 | + | } | |
| 512 | + | if tabUnlocked { | |
| 513 | + | r.moved.mu.Unlock() | |
| 514 | + | } | |
| 515 | + | if destinationUnlocked { | |
| 516 | + | r.destination.mu.Unlock() | |
| 517 | + | } | |
| 518 | + | if sourceUnlocked { | |
| 519 | + | r.source.mu.Unlock() | |
| 520 | + | } | |
| 521 | + | if routingUnlocked { | |
| 522 | + | r.d.notices.routingMu.Unlock() | |
| 523 | + | } | |
| 524 | + | if daemonUnlocked { | |
| 525 | + | r.d.mu.Unlock() | |
| 526 | + | } | |
| 527 | + | r.destination.snapshotMu.Lock() | |
| 528 | + | r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0 | |
| 529 | + | r.destination.snapshotMu.Unlock() | |
| 530 | + | r.catalogue.mu.Lock() | |
| 531 | + | r.catalogue.events = append(r.catalogue.events, "repository-delete") | |
| 532 | + | r.catalogue.mu.Unlock() | |
| 533 | + | return r.err | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) { | |
| 537 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 538 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 539 | + | source.incarnation = domain.IncarnationID{4} | |
| 540 | + | moved := source.tabs[0] | |
| 541 | + | moved.stableID = "moved-tab" | |
| 542 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 543 | + | destination.ephemeral = false | |
| 544 | + | destination.snapEligible.Store(true) | |
| 545 | + | ||
| 546 | + | catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{ | |
| 547 | + | source.name: {Name: source.name, IncarnationID: source.incarnation}, | |
| 548 | + | destination.name: {Name: destination.name, IncarnationID: destination.incarnation}, | |
| 549 | + | }} | |
| 550 | + | failure := errors.New("repository delete failed") | |
| 551 | + | repository := &moveTabPurgeRepository{ | |
| 552 | + | d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client, | |
| 553 | + | catalogue: catalogue, err: failure, | |
| 554 | + | } | |
| 555 | + | WithCatalogue(catalogue, nil)(d) | |
| 556 | + | WithSnapshotRepository(repository)(d) | |
| 557 | + | WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d) | |
| 558 | + | ||
| 559 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 560 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 561 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 562 | + | })) | |
| 563 | + | ||
| 564 | + | require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted) | |
| 565 | + | require.True(t, repository.outside, "repository purge must run outside architecture and owner locks") | |
| 566 | + | require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge") | |
| 567 | + | catalogue.mu.Lock() | |
| 568 | + | require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events) | |
| 569 | + | catalogue.mu.Unlock() | |
| 570 | + | require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership") | |
| 571 | + | require.Same(t, destination, moved.focusedPane().ownerSnapshot().session) | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session { | |
| 575 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 576 | + | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 577 | + | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 578 | + | sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | |
| 579 | + | publishTiledPaneOwners(sess, tb) | |
| 580 | + | d.mu.Lock() | |
| 581 | + | d.sessions[sess.id] = sess | |
| 582 | + | d.mu.Unlock() | |
| 583 | + | return sess | |
| 584 | + | } | |
A internal/usecase/daemon/move_test.go +392 -0
| @@ -0,0 +1,392 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestMovePaneMutationSameSession(t *testing.T) { | |
| 16 | + | p1, release1 := newBlockingPTY(t) | |
| 17 | + | p2, release2 := newBlockingPTY(t) | |
| 18 | + | defer release1() | |
| 19 | + | defer release2() | |
| 20 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 21 | + | sourceTab, destinationTab := sess.tabs[0], sess.tabs[1] | |
| 22 | + | sourceTab.stableID = "source-tab" | |
| 23 | + | destinationTab.stableID = "destination-tab" | |
| 24 | + | moved := sourceTab.focusedPane() | |
| 25 | + | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | |
| 26 | + | moved.ctx, moved.cancel = movedCtx, movedCancel | |
| 27 | + | ||
| 28 | + | err := d.movePane(movePaneRequest{ | |
| 29 | + | Source: moveSessionLocator{ID: sess.id}, | |
| 30 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 31 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 32 | + | Destination: moveSessionLocator{ID: sess.id}, | |
| 33 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 34 | + | }) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 37 | + | require.Equal(t, layout.PaneID("pane-2"), moved.id) | |
| 38 | + | require.Equal(t, layout.PaneID("pane-2"), destinationTab.tree.Focus) | |
| 39 | + | require.NotContains(t, sourceTab.panes, layout.PaneID("pane-1")) | |
| 40 | + | require.Len(t, sess.tabs, 1) | |
| 41 | + | require.Same(t, destinationTab, sess.tabs[0]) | |
| 42 | + | require.NoError(t, moved.ctx.Err(), "a same-session move must not retire the moved pane process") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestMovePaneMutationCrossSession(t *testing.T) { | |
| 46 | + | p1, release1 := newBlockingPTY(t) | |
| 47 | + | p2, release2 := newBlockingPTY(t) | |
| 48 | + | defer release1() | |
| 49 | + | defer release2() | |
| 50 | + | d, source, _, _ := newManualSessionWithPTYs(t, p1) | |
| 51 | + | source.mu.Lock() | |
| 52 | + | source.client = nil | |
| 53 | + | source.mu.Unlock() | |
| 54 | + | sourceTab := source.tabs[0] | |
| 55 | + | sourceTab.stableID = "source-tab" | |
| 56 | + | moved := sourceTab.focusedPane() | |
| 57 | + | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | |
| 58 | + | moved.ctx, moved.cancel = movedCtx, movedCancel | |
| 59 | + | ||
| 60 | + | destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 61 | + | destinationTab := destination.tabs[0] | |
| 62 | + | destinationTab.stableID = "destination-tab" | |
| 63 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 64 | + | d.mu.Lock() | |
| 65 | + | d.sessions[destination.id] = destination | |
| 66 | + | d.mu.Unlock() | |
| 67 | + | ||
| 68 | + | err := d.movePane(movePaneRequest{ | |
| 69 | + | Source: moveSessionLocator{ID: source.id}, | |
| 70 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 71 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 72 | + | Destination: moveSessionLocator{ID: destination.id}, | |
| 73 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 74 | + | }) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.Nil(t, source.tabs) | |
| 77 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 78 | + | require.Equal(t, destinationTab, destination.tabs[0]) | |
| 79 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 80 | + | require.Same(t, destinationTab, moved.ownerSnapshot().tab) | |
| 81 | + | require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |
| 85 | + | p1, release1 := newBlockingPTY(t) | |
| 86 | + | p2, release2 := newBlockingPTY(t) | |
| 87 | + | defer release1() | |
| 88 | + | defer release2() | |
| 89 | + | d, source, client, _ := newManualSessionWithPTYs(t, p1) | |
| 90 | + | sourceTab := source.tabs[0] | |
| 91 | + | sourceTab.stableID = "source-tab" | |
| 92 | + | moved := sourceTab.focusedPane() | |
| 93 | + | movedTabCtx, movedTabCancel := context.WithCancel(d.paneProcessCtx) | |
| 94 | + | moved.ctx, moved.cancel = movedTabCtx, movedTabCancel | |
| 95 | + | ||
| 96 | + | rc := d.attachCoordinator(source, nil, client, true) | |
| 97 | + | require.NotNil(t, rc) | |
| 98 | + | var rebased atomic.Bool | |
| 99 | + | client.renderStages.handoffRebase = func() { rebased.Store(true) } | |
| 100 | + | destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 101 | + | destinationTab := destination.tabs[0] | |
| 102 | + | destinationTab.stableID = "destination-tab" | |
| 103 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 104 | + | d.mu.Lock() | |
| 105 | + | d.sessions[destination.id] = destination | |
| 106 | + | d.mu.Unlock() | |
| 107 | + | ||
| 108 | + | err := d.movePane(movePaneRequest{ | |
| 109 | + | Source: moveSessionLocator{ID: source.id}, | |
| 110 | + | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 111 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 112 | + | Destination: moveSessionLocator{ID: destination.id}, | |
| 113 | + | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 114 | + | }) | |
| 115 | + | require.NoError(t, err) | |
| 116 | + | require.Nil(t, source.tabs) | |
| 117 | + | require.Same(t, destination, client.currentSession()) | |
| 118 | + | require.Same(t, client, destination.client) | |
| 119 | + | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 120 | + | require.Same(t, destinationTab, destination.tabs[destination.active]) | |
| 121 | + | require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint") | |
| 122 | + | require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) { | |
| 126 | + | movedPTY := newQuietPTY() | |
| 127 | + | d, source, active, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 128 | + | clip, err := os.CreateTemp(t.TempDir(), "move-clip-") | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.NoError(t, clip.Close()) | |
| 131 | + | source.mu.Lock() | |
| 132 | + | source.client = nil | |
| 133 | + | source.clipFiles = []string{clip.Name()} | |
| 134 | + | source.mu.Unlock() | |
| 135 | + | active.setSession(nil) | |
| 136 | + | moved := source.tabs[0].focusedPane() | |
| 137 | + | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | |
| 138 | + | moved.ctx, moved.cancel = movedCtx, movedCancel | |
| 139 | + | movedTab := source.tabs[0] | |
| 140 | + | movedTab.stableID = "source-tab" | |
| 141 | + | ||
| 142 | + | destinationPTY := newQuietPTY() | |
| 143 | + | destination := &session{ | |
| 144 | + | id: "destination", name: "destination", ephemeral: true, | |
| 145 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 146 | + | active: 0, | |
| 147 | + | } | |
| 148 | + | publishTiledPaneOwners(destination, destination.tabs[0]) | |
| 149 | + | d.mu.Lock() | |
| 150 | + | d.sessions[destination.id] = destination | |
| 151 | + | d.mu.Unlock() | |
| 152 | + | ||
| 153 | + | err = d.movePane(movePaneRequest{ | |
| 154 | + | Source: moveSessionLocator{ID: source.id}, | |
| 155 | + | SourceTabID: domain.TabStableID(movedTab.stableID), | |
| 156 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 157 | + | Destination: moveSessionLocator{ID: destination.id}, | |
| 158 | + | DestinationTabID: domain.TabStableID(destination.tabs[0].stableID), | |
| 159 | + | }) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | select { | |
| 162 | + | case <-movedPTY.done: | |
| 163 | + | t.Fatal("retiring the source closed the transferred PTY") | |
| 164 | + | default: | |
| 165 | + | } | |
| 166 | + | select { | |
| 167 | + | case <-moved.ctx.Done(): | |
| 168 | + | t.Fatal("retiring the source cancelled the transferred pane process") | |
| 169 | + | default: | |
| 170 | + | } | |
| 171 | + | select { | |
| 172 | + | case <-source.ctx.Done(): | |
| 173 | + | default: | |
| 174 | + | t.Fatal("empty source lifecycle was not cancelled") | |
| 175 | + | } | |
| 176 | + | _, err = os.Stat(clip.Name()) | |
| 177 | + | require.ErrorIs(t, err, os.ErrNotExist, "retired source clipboard resources must be removed") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |
| 181 | + | movedPTY := newQuietPTY() | |
| 182 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 183 | + | source.mu.Lock() | |
| 184 | + | source.client = nil | |
| 185 | + | source.mu.Unlock() | |
| 186 | + | moved := source.tabs[0].focusedPane() | |
| 187 | + | movedTab := source.tabs[0] | |
| 188 | + | movedTab.stableID = "source-tab" | |
| 189 | + | ||
| 190 | + | transport := &closeTrackingTransport{} | |
| 191 | + | parked := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, resumeCapable: true} | |
| 192 | + | parked.initOverlays() | |
| 193 | + | require.True(t, d.parkAttachment(source, parked)) | |
| 194 | + | destinationPTY := newQuietPTY() | |
| 195 | + | destination := &session{ | |
| 196 | + | id: "destination", name: "destination", ephemeral: true, | |
| 197 | + | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 198 | + | active: 0, | |
| 199 | + | } | |
| 200 | + | publishTiledPaneOwners(destination, destination.tabs[0]) | |
| 201 | + | d.mu.Lock() | |
| 202 | + | d.sessions[destination.id] = destination | |
| 203 | + | d.mu.Unlock() | |
| 204 | + | ||
| 205 | + | require.NoError(t, d.movePane(movePaneRequest{ | |
| 206 | + | Source: moveSessionLocator{ID: source.id}, | |
| 207 | + | SourceTabID: domain.TabStableID(movedTab.stableID), | |
| 208 | + | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 209 | + | Destination: moveSessionLocator{ID: destination.id}, | |
| 210 | + | DestinationTabID: domain.TabStableID(destination.tabs[0].stableID), | |
| 211 | + | })) | |
| 212 | + | d.mu.Lock() | |
| 213 | + | require.Empty(t, d.parked, "retired source parked clients must leave the daemon registry") | |
| 214 | + | d.mu.Unlock() | |
| 215 | + | require.True(t, transport.Closed(), "retired source parked transport was not closed") | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestPrepareMovePaneCandidateIsPure(t *testing.T) { | |
| 219 | + | t.Parallel() | |
| 220 | + | ||
| 221 | + | newPaneRef := func(id layout.PaneID, stableID string) *pane { | |
| 222 | + | return &pane{id: id, stableID: stableID} | |
| 223 | + | } | |
| 224 | + | horizontal := func(ids ...layout.PaneID) *layout.Node { | |
| 225 | + | children := make([]*layout.Node, 0, len(ids)) | |
| 226 | + | for _, id := range ids { | |
| 227 | + | children = append(children, layout.NewLeaf(id)) | |
| 228 | + | } | |
| 229 | + | return &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: children} | |
| 230 | + | } | |
| 231 | + | stack := func(ids ...layout.PaneID) *layout.Node { | |
| 232 | + | children := make([]*layout.Node, 0, len(ids)) | |
| 233 | + | for _, id := range ids { | |
| 234 | + | children = append(children, layout.NewLeaf(id)) | |
| 235 | + | } | |
| 236 | + | return &layout.Node{Kind: layout.Stack, Children: children, Expanded: ids[0]} | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | tests := []struct { | |
| 240 | + | name string | |
| 241 | + | sourceTree *layout.Tree | |
| 242 | + | sourceFloating floatingState | |
| 243 | + | destinationTree *layout.Tree | |
| 244 | + | destinationSize domain.Size | |
| 245 | + | destinationNext int | |
| 246 | + | wantErr error | |
| 247 | + | wantRemoveTab bool | |
| 248 | + | wantSourceFocus layout.PaneID | |
| 249 | + | wantDestination layout.PaneID | |
| 250 | + | wantDestinationNext int | |
| 251 | + | wantDestinationIDs []layout.PaneID | |
| 252 | + | }{ | |
| 253 | + | { | |
| 254 | + | name: "split source closes on clone and destination inserts right of focus", | |
| 255 | + | sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"}, | |
| 256 | + | destinationTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-1"}, | |
| 257 | + | destinationSize: domain.Size{Cols: 100, Rows: 4}, | |
| 258 | + | destinationNext: 2, | |
| 259 | + | wantSourceFocus: "pane-1", | |
| 260 | + | wantDestination: "pane-3", | |
| 261 | + | wantDestinationNext: 4, | |
| 262 | + | wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3", "pane-2"}, | |
| 263 | + | }, | |
| 264 | + | { | |
| 265 | + | name: "stack source remains valid after close", | |
| 266 | + | sourceTree: &layout.Tree{Root: stack("pane-2", "pane-1"), Focus: "pane-2"}, | |
| 267 | + | destinationTree: layout.NewTree("pane-1"), | |
| 268 | + | destinationSize: domain.Size{Cols: 41, Rows: 4}, | |
| 269 | + | destinationNext: 2, | |
| 270 | + | wantSourceFocus: "pane-1", | |
| 271 | + | wantDestination: "pane-3", | |
| 272 | + | wantDestinationNext: 4, | |
| 273 | + | wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"}, | |
| 274 | + | }, | |
| 275 | + | { | |
| 276 | + | name: "final tiled pane requests source tab removal", | |
| 277 | + | sourceTree: layout.NewTree("pane-2"), | |
| 278 | + | destinationTree: layout.NewTree("pane-1"), | |
| 279 | + | destinationSize: domain.Size{Cols: 41, Rows: 2}, | |
| 280 | + | destinationNext: 2, | |
| 281 | + | wantRemoveTab: true, | |
| 282 | + | wantDestination: "pane-3", | |
| 283 | + | wantDestinationNext: 4, | |
| 284 | + | wantDestinationIDs: []layout.PaneID{"pane-1", "pane-3"}, | |
| 285 | + | }, | |
| 286 | + | { | |
| 287 | + | name: "final tiled pane rejects warming floating sibling", | |
| 288 | + | sourceTree: layout.NewTree("pane-2"), | |
| 289 | + | sourceFloating: floatingWarming, | |
| 290 | + | destinationTree: layout.NewTree("pane-1"), | |
| 291 | + | destinationSize: domain.Size{Cols: 41, Rows: 2}, | |
| 292 | + | destinationNext: 2, | |
| 293 | + | wantErr: errMoveFloatingWarming, | |
| 294 | + | }, | |
| 295 | + | { | |
| 296 | + | name: "final tiled pane rejects hidden floating sibling", | |
| 297 | + | sourceTree: layout.NewTree("pane-2"), | |
| 298 | + | sourceFloating: floatingHidden, | |
| 299 | + | destinationTree: layout.NewTree("pane-1"), | |
| 300 | + | destinationSize: domain.Size{Cols: 41, Rows: 2}, | |
| 301 | + | destinationNext: 2, | |
| 302 | + | wantErr: errMovePaneFloatingSibling, | |
| 303 | + | }, | |
| 304 | + | { | |
| 305 | + | name: "final tiled pane rejects visible floating sibling", | |
| 306 | + | sourceTree: layout.NewTree("pane-2"), | |
| 307 | + | sourceFloating: floatingVisible, | |
| 308 | + | destinationTree: layout.NewTree("pane-1"), | |
| 309 | + | destinationSize: domain.Size{Cols: 41, Rows: 2}, | |
| 310 | + | destinationNext: 2, | |
| 311 | + | wantErr: errMovePaneFloatingSibling, | |
| 312 | + | }, | |
| 313 | + | { | |
| 314 | + | name: "destination too small rejects candidate", | |
| 315 | + | sourceTree: &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"}, | |
| 316 | + | destinationTree: layout.NewTree("pane-1"), | |
| 317 | + | destinationSize: domain.Size{Cols: 40, Rows: 2}, | |
| 318 | + | destinationNext: 2, | |
| 319 | + | wantErr: layout.ErrTooSmall, | |
| 320 | + | }, | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | for _, tt := range tests { | |
| 324 | + | t.Run(tt.name, func(t *testing.T) { | |
| 325 | + | moved := newPaneRef("pane-2", "stable-moved") | |
| 326 | + | sourcePanes := map[layout.PaneID]*pane{"pane-2": moved} | |
| 327 | + | for _, id := range layout.LeafIDs(tt.sourceTree.Root) { | |
| 328 | + | if id != moved.id { | |
| 329 | + | sourcePanes[id] = newPaneRef(id, "source-"+string(id)) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | destinationPanes := make(map[layout.PaneID]*pane) | |
| 333 | + | for _, id := range layout.LeafIDs(tt.destinationTree.Root) { | |
| 334 | + | destinationPanes[id] = newPaneRef(id, "destination-"+string(id)) | |
| 335 | + | } | |
| 336 | + | source := &tab{ | |
| 337 | + | tree: tt.sourceTree, | |
| 338 | + | panes: sourcePanes, | |
| 339 | + | nextPaneID: 17, | |
| 340 | + | size: domain.Size{Cols: 80, Rows: 4}, | |
| 341 | + | floating: floatingSlot{state: tt.sourceFloating, generation: 9}, | |
| 342 | + | } | |
| 343 | + | destination := &tab{ | |
| 344 | + | tree: tt.destinationTree, | |
| 345 | + | panes: destinationPanes, | |
| 346 | + | nextPaneID: tt.destinationNext, | |
| 347 | + | size: tt.destinationSize, | |
| 348 | + | } | |
| 349 | + | sourceBefore := source.tree.Clone() | |
| 350 | + | destinationBefore := destination.tree.Clone() | |
| 351 | + | sourceNextBefore, destinationNextBefore := source.nextPaneID, destination.nextPaneID | |
| 352 | + | movedIDBefore, movedStableIDBefore := moved.id, moved.stableID | |
| 353 | + | ||
| 354 | + | candidate, err := prepareMovePaneCandidate(source, destination, moved) | |
| 355 | + | ||
| 356 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 357 | + | require.Equal(t, sourceBefore, source.tree, "preparation must not mutate the source tree") | |
| 358 | + | require.Equal(t, destinationBefore, destination.tree, "preparation must not mutate the destination tree") | |
| 359 | + | require.Equal(t, sourceNextBefore, source.nextPaneID) | |
| 360 | + | require.Equal(t, destinationNextBefore, destination.nextPaneID, "preparation must not advance the destination counter") | |
| 361 | + | require.Equal(t, movedIDBefore, moved.id, "preparation must not publish the new local ID") | |
| 362 | + | require.Equal(t, movedStableIDBefore, moved.stableID) | |
| 363 | + | require.Same(t, moved, source.panes[movedIDBefore]) | |
| 364 | + | for id, p := range destinationPanes { | |
| 365 | + | require.Same(t, p, destination.panes[id]) | |
| 366 | + | } | |
| 367 | + | if tt.wantErr != nil { | |
| 368 | + | require.Nil(t, candidate) | |
| 369 | + | return | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | require.Same(t, moved, candidate.pane) | |
| 373 | + | require.Equal(t, movedIDBefore, candidate.sourceID) | |
| 374 | + | require.Equal(t, tt.wantDestination, candidate.destinationID) | |
| 375 | + | require.NotEqual(t, candidate.sourceID, candidate.destinationID) | |
| 376 | + | require.Equal(t, tt.wantRemoveTab, candidate.removeSourceTab) | |
| 377 | + | require.Equal(t, tt.wantDestinationNext, candidate.destinationNextPaneID, "candidate counter should follow the allocated ID") | |
| 378 | + | require.Equal(t, tt.wantDestinationIDs, layout.LeafIDs(candidate.destinationTree.Root)) | |
| 379 | + | require.Equal(t, tt.wantDestination, candidate.destinationTree.Focus) | |
| 380 | + | require.NotEmpty(t, candidate.destinationPlacements) | |
| 381 | + | if tt.wantRemoveTab { | |
| 382 | + | require.Nil(t, candidate.sourceTree) | |
| 383 | + | require.Nil(t, candidate.sourcePlacements) | |
| 384 | + | } else { | |
| 385 | + | require.Equal(t, tt.wantSourceFocus, candidate.sourceTree.Focus) | |
| 386 | + | require.True(t, layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus)) | |
| 387 | + | require.NotEmpty(t, candidate.sourcePlacements) | |
| 388 | + | } | |
| 389 | + | require.Equal(t, "stable-moved", candidate.pane.stableID) | |
| 390 | + | }) | |
| 391 | + | } | |
| 392 | + | } | |
A internal/usecase/daemon/navigation_role_handoff_test.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | action string | |
| 19 | + | run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error | |
| 20 | + | check func(*testing.T, *Daemon, *session, *session) | |
| 21 | + | }{ | |
| 22 | + | { | |
| 23 | + | name: "picker selection", action: "picker-select", | |
| 24 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 25 | + | d.enterPicker(source, ac) | |
| 26 | + | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "palette session", action: "palette-session", | |
| 31 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 32 | + | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 33 | + | }, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "palette recent session", action: "palette-recent-session", | |
| 37 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 38 | + | return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1) | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "session overflow", action: "overflow-session", | |
| 43 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 44 | + | source.mu.Lock() | |
| 45 | + | source.name = "alpha" | |
| 46 | + | source.mu.Unlock() | |
| 47 | + | target.mu.Lock() | |
| 48 | + | target.name = "charlie" | |
| 49 | + | target.mu.Unlock() | |
| 50 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |
| 51 | + | return d.focusDir(source, ac, layout.Down, effect) | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "stopped session resume", action: "stopped-session", | |
| 56 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 57 | + | d.mu.Lock() | |
| 58 | + | delete(d.sessions, target.id) | |
| 59 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped} | |
| 60 | + | d.mu.Unlock() | |
| 61 | + | expectedCreatedAt := int64(9) | |
| 62 | + | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session") | |
| 63 | + | }, | |
| 64 | + | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 65 | + | d.mu.Lock() | |
| 66 | + | _, stopped := d.stopped["stopped"] | |
| 67 | + | resumed := d.findByNameLocked("stopped") | |
| 68 | + | d.mu.Unlock() | |
| 69 | + | require.True(t, stopped) | |
| 70 | + | require.Nil(t, resumed) | |
| 71 | + | }, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "transition prompt creation", action: "create-session", | |
| 75 | + | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 76 | + | return d.createSessionAndSwitchForRole(effect.roleToken(), "created") | |
| 77 | + | }, | |
| 78 | + | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 79 | + | d.mu.Lock() | |
| 80 | + | created := d.findByNameLocked("created") | |
| 81 | + | d.mu.Unlock() | |
| 82 | + | require.Nil(t, created, "stale prompt submission created a target session") | |
| 83 | + | }, | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | for _, tt := range tests { | |
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |
| 89 | + | d, source, old, _, releases := newManualTabSession(t, 2) | |
| 90 | + | defer releaseAll(releases) | |
| 91 | + | d.ptys = newFactorySeq(t) | |
| 92 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 93 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 94 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 95 | + | }} | |
| 96 | + | d.mu.Lock() | |
| 97 | + | d.sessions[target.id] = target | |
| 98 | + | d.mu.Unlock() | |
| 99 | + | ||
| 100 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 101 | + | token := source.attachmentToken(old, old.transport()) | |
| 102 | + | token.lease = rc.attachmentLease(old) | |
| 103 | + | old.publishRoleCapability(token) | |
| 104 | + | effect, admitted := old.beginRoleEffect(token) | |
| 105 | + | require.True(t, admitted) | |
| 106 | + | ||
| 107 | + | admissionEnded := make(chan struct{}) | |
| 108 | + | releaseAction := make(chan struct{}) | |
| 109 | + | var admissionEndedOnce sync.Once | |
| 110 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 111 | + | if action == tt.action { | |
| 112 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 113 | + | <-releaseAction | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | actionDone := make(chan error, 1) | |
| 117 | + | go func() { actionDone <- tt.run(d, source, target, old, effect) }() | |
| 118 | + | select { | |
| 119 | + | case <-admissionEnded: | |
| 120 | + | case <-time.After(time.Second): | |
| 121 | + | t.Fatal("handoff did not release its role ticket") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 125 | + | source: source, target: source, next: old, | |
| 126 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 127 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 128 | + | }) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | close(releaseAction) | |
| 131 | + | require.Error(t, <-actionDone) | |
| 132 | + | ||
| 133 | + | require.Same(t, source, old.currentSession()) | |
| 134 | + | source.mu.Lock() | |
| 135 | + | require.Same(t, old, source.client) | |
| 136 | + | source.mu.Unlock() | |
| 137 | + | target.mu.Lock() | |
| 138 | + | require.Nil(t, target.client, "stale handoff attached to the target") | |
| 139 | + | require.Zero(t, target.active, "stale handoff changed target tab focus") | |
| 140 | + | target.mu.Unlock() | |
| 141 | + | if tt.check != nil { | |
| 142 | + | tt.check(t, d, source, target) | |
| 143 | + | } | |
| 144 | + | d.deferAttachmentTransitionCleanups(replacement) | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) { | |
| 150 | + | d, source, old, _ := newManualSessionWithPTYs(t, nil) | |
| 151 | + | d.mu.Lock() | |
| 152 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped} | |
| 153 | + | d.mu.Unlock() | |
| 154 | + | ||
| 155 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 156 | + | token := source.attachmentToken(old, old.transport()) | |
| 157 | + | token.lease = rc.attachmentLease(old) | |
| 158 | + | old.publishRoleCapability(token) | |
| 159 | + | effect, admitted := old.beginRoleEffect(token) | |
| 160 | + | require.True(t, admitted) | |
| 161 | + | ||
| 162 | + | ended := make(chan struct{}) | |
| 163 | + | release := make(chan struct{}) | |
| 164 | + | var endedOnce sync.Once | |
| 165 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 166 | + | if action == "picker-stopped" { | |
| 167 | + | endedOnce.Do(func() { close(ended) }) | |
| 168 | + | <-release | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | expectedCreatedAt := int64(7) | |
| 172 | + | done := make(chan error, 1) | |
| 173 | + | go func() { | |
| 174 | + | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped") | |
| 175 | + | }() | |
| 176 | + | select { | |
| 177 | + | case <-ended: | |
| 178 | + | case <-time.After(time.Second): | |
| 179 | + | t.Fatal("stopped-session handoff did not release its role ticket") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 183 | + | next.initOverlays() | |
| 184 | + | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 185 | + | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 186 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 187 | + | }) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | close(release) | |
| 190 | + | require.Error(t, <-done) | |
| 191 | + | ||
| 192 | + | d.mu.Lock() | |
| 193 | + | _, stillStopped := d.stopped["stopped"] | |
| 194 | + | resumed := d.findByNameLocked("stopped") | |
| 195 | + | d.mu.Unlock() | |
| 196 | + | require.True(t, stillStopped) | |
| 197 | + | require.Nil(t, resumed) | |
| 198 | + | d.deferAttachmentTransitionCleanups(replacement) | |
| 199 | + | } | |
A internal/usecase/daemon/navigation_role_incarnation_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | rebind func(*testing.T, *Daemon, *session, *attachedClient) | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "role generation and lease replaced on same attachment", | |
| 18 | + | rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) { | |
| 19 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 20 | + | source: source, target: source, next: ac, | |
| 21 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 22 | + | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 23 | + | }) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | d.deferAttachmentTransitionCleanups(result) | |
| 26 | + | }, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "transport incarnation rebound on same attachment", | |
| 30 | + | rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) { | |
| 31 | + | ac.replaceTransport(&closeTrackingTransport{}) | |
| 32 | + | current := source.attachmentToken(ac, ac.transport()) | |
| 33 | + | current.lease = source.renderCoordinator().attachmentLease(ac) | |
| 34 | + | ac.publishRoleCapability(current) | |
| 35 | + | }, | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | for _, tt := range tests { | |
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |
| 41 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 42 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 43 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 44 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 45 | + | }} | |
| 46 | + | d.mu.Lock() | |
| 47 | + | d.sessions[target.id] = target | |
| 48 | + | d.mu.Unlock() | |
| 49 | + | ||
| 50 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 51 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 52 | + | token.lease = rc.attachmentLease(ac) | |
| 53 | + | ac.publishRoleCapability(token) | |
| 54 | + | effect, admitted := ac.beginRoleEffect(token) | |
| 55 | + | require.True(t, admitted) | |
| 56 | + | ||
| 57 | + | ended := make(chan struct{}) | |
| 58 | + | release := make(chan struct{}) | |
| 59 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 60 | + | if action == "test-handoff" { | |
| 61 | + | close(ended) | |
| 62 | + | <-release | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | done := make(chan error, 1) | |
| 66 | + | go func() { | |
| 67 | + | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff") | |
| 68 | + | }() | |
| 69 | + | awaitTestCompletion(t, ended, "test handoff did not release its role ticket") | |
| 70 | + | tt.rebind(t, d, source, ac) | |
| 71 | + | close(release) | |
| 72 | + | require.ErrorIs(t, awaitTestValue(t, done, "test handoff did not finish"), errAttachmentTransition) | |
| 73 | + | ||
| 74 | + | require.Same(t, source, ac.currentSession()) | |
| 75 | + | target.mu.Lock() | |
| 76 | + | require.Nil(t, target.client) | |
| 77 | + | require.Zero(t, target.active) | |
| 78 | + | target.mu.Unlock() | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
M internal/usecase/daemon/notify.go +5 -6
| @@ -443,24 +443,23 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain. | |||
| 443 | 443 | ||
| 444 | 444 | // dismissToast removes only the specified visible toast. In particular, a | |
| 445 | 445 | // link-connected event must not clear clipboard or other notice state. | |
| 446 | - | func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) { | |
| 446 | + | func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool { | |
| 447 | 447 | if ac == nil || ac.overlays == nil { | |
| 448 | - | return | |
| 448 | + | return false | |
| 449 | 449 | } | |
| 450 | 450 | rt := ac.overlays | |
| 451 | 451 | rt.noticeMu.Lock() | |
| 452 | + | defer rt.noticeMu.Unlock() | |
| 452 | 453 | i := rt.indexOfToastLocked(code, sid) | |
| 453 | 454 | if i < 0 { | |
| 454 | - | rt.noticeMu.Unlock() | |
| 455 | - | return | |
| 455 | + | return false | |
| 456 | 456 | } | |
| 457 | 457 | rt.noticeToasts[i].timer.stop() | |
| 458 | 458 | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |
| 459 | 459 | if len(rt.noticeToasts) == 0 { | |
| 460 | 460 | rt.noticeOverflow = 0 | |
| 461 | 461 | } | |
| 462 | - | rt.noticeMu.Unlock() | |
| 463 | - | d.repaintForNotice(ac) | |
| 462 | + | return true | |
| 464 | 463 | } | |
| 465 | 464 | ||
| 466 | 465 | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |
M internal/usecase/daemon/notify_test.go +20 -24
| @@ -71,23 +71,23 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||
| 71 | 71 | tr, _ := newCapturingTransport(t) | |
| 72 | 72 | replaced := make(chan *attachedClient, 1) | |
| 73 | 73 | go func() { | |
| 74 | - | current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 74 | + | current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 75 | 75 | replaced <- current | |
| 76 | 76 | }() | |
| 77 | 77 | ||
| 78 | 78 | // 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. | |
| 81 | 81 | close(releaseMutation) | |
| 82 | 82 | <-noticeDone | |
| 83 | 83 | current := <-replaced | |
| 84 | + | d.attachmentCleanupWg.Wait() | |
| 84 | 85 | ||
| 85 | 86 | history := d.notices.history() | |
| 86 | 87 | require.Len(t, history, 1) | |
| 87 | 88 | require.Equal(t, domain.NoticeClipboard, history[0].Code) | |
| 88 | 89 | oldToasts, _ := visibleToasts(old) | |
| 89 | - | require.Len(t, oldToasts, 1) | |
| 90 | - | require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code) | |
| 90 | + | require.Empty(t, oldToasts) | |
| 91 | 91 | newToasts, _ := visibleToasts(current) | |
| 92 | 92 | require.Empty(t, newToasts) | |
| 93 | 93 | sess.mu.Lock() | |
| @@ -97,10 +97,9 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |||
| 97 | 97 | ||
| 98 | 98 | func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |
| 99 | 99 | 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 | |
| 104 | 103 | }{ | |
| 105 | 104 | { | |
| 106 | 105 | name: "record", | |
| @@ -111,28 +110,23 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |||
| 111 | 110 | setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) { | |
| 112 | 111 | d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid)) | |
| 113 | 112 | }, | |
| 114 | - | notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}, | |
| 115 | - | wantToast: domain.NoticeConnection, | |
| 113 | + | notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}, | |
| 116 | 114 | }, | |
| 117 | 115 | } { | |
| 118 | 116 | t.Run(tt.name, func(t *testing.T) { | |
| 119 | 117 | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |
| 120 | - | tr, _ := newCapturingTransport(t) | |
| 121 | - | current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 122 | 118 | if tt.setup != nil { | |
| 123 | 119 | tt.setup(d, old, sess.id) | |
| 124 | 120 | } | |
| 121 | + | tr, _ := newCapturingTransport(t) | |
| 122 | + | current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 123 | + | d.attachmentCleanupWg.Wait() | |
| 125 | 124 | ||
| 126 | 125 | d.handleClientNotice(sess, old, tt.notice) | |
| 127 | 126 | ||
| 128 | 127 | require.Empty(t, d.notices.history(), "a replaced client must not record notices") | |
| 129 | 128 | 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") | |
| 136 | 130 | currentToasts, _ := visibleToasts(current) | |
| 137 | 131 | require.Empty(t, currentToasts) | |
| 138 | 132 | }) | |
| @@ -140,7 +134,8 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |||
| 140 | 134 | } | |
| 141 | 135 | ||
| 142 | 136 | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |
| 143 | - | d, _, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 137 | + | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 138 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 144 | 139 | tr, ok := ac.tr.(*portsmocks.MockTransport) | |
| 145 | 140 | require.True(t, ok, "attached client transport must be a MockTransport") | |
| 146 | 141 | frames := []ports.Frame{ | |
| @@ -757,17 +752,18 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||
| 757 | 752 | tr, _ := newCapturingTransport(t) | |
| 758 | 753 | replaced := make(chan *attachedClient, 1) | |
| 759 | 754 | go func() { | |
| 760 | - | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 755 | + | ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 761 | 756 | replaced <- ac | |
| 762 | 757 | }() | |
| 763 | 758 | close(releaseRoute) | |
| 764 | 759 | <-globalDone | |
| 765 | 760 | current := <-replaced | |
| 761 | + | d.attachmentCleanupWg.Wait() | |
| 766 | 762 | ||
| 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. | |
| 769 | 765 | oldToasts, _ := visibleToasts(old) | |
| 770 | - | require.Len(t, oldToasts, 1) | |
| 766 | + | require.Empty(t, oldToasts) | |
| 771 | 767 | newToasts, _ := visibleToasts(current) | |
| 772 | 768 | require.Empty(t, newToasts) | |
| 773 | 769 | sess.mu.Lock() | |
M internal/usecase/daemon/overlay_runtime.go +117 -8
| @@ -18,6 +18,8 @@ type overlayRuntime struct { | |||
| 18 | 18 | ||
| 19 | 19 | pickerMu sync.Mutex | |
| 20 | 20 | picker *picker.Model | |
| 21 | + | pickerIntent pickerIntent | |
| 22 | + | pickerSource moveSourceLocator | |
| 21 | 23 | pickerPreview *tab | |
| 22 | 24 | pickerPreviewSession *session | |
| 23 | 25 | pickerPreviewGeneration uint64 | |
| @@ -32,10 +34,11 @@ type overlayRuntime struct { | |||
| 32 | 34 | paletteFeedback string | |
| 33 | 35 | palettePending []byte | |
| 34 | 36 | ||
| 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 | |
| 39 | 42 | ||
| 40 | 43 | resizeMu sync.Mutex | |
| 41 | 44 | resizeActive bool | |
| @@ -228,21 +231,127 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 228 | 231 | return active || prePublication | |
| 229 | 232 | } | |
| 230 | 233 | ||
| 231 | - | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |
| 234 | + | // clearForSnatch clears each overlay family independently. Every family | |
| 235 | + | // revalidates the displaced role generation while holding only its own mutex, | |
| 236 | + | // so cleanup can neither participate in cross-family lock inversion nor erase | |
| 237 | + | // state published by a later promotion. | |
| 238 | + | func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool { | |
| 239 | + | if token.ac == nil || token.ac.overlays == nil { | |
| 240 | + | return false | |
| 241 | + | } | |
| 242 | + | rt := token.ac.overlays | |
| 243 | + | current := func() bool { | |
| 244 | + | return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport) | |
| 245 | + | } | |
| 246 | + | afterFamily := func(family string) { | |
| 247 | + | if d.afterSnatchOverlayFamily != nil { | |
| 248 | + | d.afterSnatchOverlayFamily(family) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | rt.pickerMu.Lock() | |
| 253 | + | if !current() { | |
| 254 | + | rt.pickerMu.Unlock() | |
| 255 | + | return false | |
| 256 | + | } | |
| 257 | + | previewSession := rt.pickerPreviewSession | |
| 258 | + | previewGeneration := rt.pickerPreviewGeneration | |
| 259 | + | rt.picker = nil | |
| 260 | + | rt.pickerIntent = pickerNavigate | |
| 261 | + | rt.pickerSource = moveSourceLocator{} | |
| 262 | + | rt.pickerPending = nil | |
| 263 | + | rt.pickerESC.stop() | |
| 264 | + | rt.pickerPreviewGeneration++ | |
| 265 | + | rt.pickerPreview = nil | |
| 266 | + | rt.pickerPreviewSession = nil | |
| 267 | + | rt.pickerMu.Unlock() | |
| 268 | + | afterFamily("picker") | |
| 269 | + | // Subscription teardown may enter a coordinator and therefore runs outside | |
| 270 | + | // every overlay-family lock. | |
| 271 | + | d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration) | |
| 272 | + | ||
| 273 | + | rt.paletteMu.Lock() | |
| 274 | + | if !current() { | |
| 275 | + | rt.paletteMu.Unlock() | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | token.ac.clearPaletteLocked() | |
| 279 | + | rt.paletteMu.Unlock() | |
| 280 | + | afterFamily("palette") | |
| 281 | + | ||
| 282 | + | rt.promptMu.Lock() | |
| 283 | + | if !current() { | |
| 284 | + | rt.promptMu.Unlock() | |
| 285 | + | return false | |
| 286 | + | } | |
| 287 | + | rt.prompt = nil | |
| 288 | + | rt.promptSubmit = nil | |
| 289 | + | rt.promptTransitionSubmit = nil | |
| 290 | + | rt.promptPending = nil | |
| 291 | + | rt.promptMu.Unlock() | |
| 292 | + | afterFamily("prompt") | |
| 293 | + | ||
| 294 | + | rt.resizeMu.Lock() | |
| 295 | + | if !current() { | |
| 296 | + | rt.resizeMu.Unlock() | |
| 297 | + | return false | |
| 298 | + | } | |
| 299 | + | rt.resizeActive = false | |
| 300 | + | rt.resizePending = nil | |
| 301 | + | rt.resizeESC.stop() | |
| 302 | + | rt.resizeMu.Unlock() | |
| 303 | + | afterFamily("resize") | |
| 304 | + | ||
| 305 | + | rt.copyMu.Lock() | |
| 306 | + | if !current() { | |
| 307 | + | rt.copyMu.Unlock() | |
| 308 | + | return false | |
| 309 | + | } | |
| 310 | + | d.stopCopyPendingTimerLocked(token.ac) | |
| 311 | + | rt.copyPending = nil | |
| 312 | + | rt.clearCopyModeLocked() | |
| 313 | + | rt.statusFeedback = "" | |
| 314 | + | rt.copyMu.Unlock() | |
| 315 | + | afterFamily("copy") | |
| 316 | + | ||
| 317 | + | rt.noticeMu.Lock() | |
| 318 | + | if !current() { | |
| 319 | + | rt.noticeMu.Unlock() | |
| 320 | + | return false | |
| 321 | + | } | |
| 322 | + | for i := range rt.noticeToasts { | |
| 323 | + | rt.noticeToasts[i].timer.stop() | |
| 324 | + | } | |
| 325 | + | rt.noticeToasts = nil | |
| 326 | + | rt.noticeOverflow = 0 | |
| 327 | + | rt.noticeSeq++ | |
| 328 | + | rt.noticesOverlay = nil | |
| 329 | + | rt.noticesPending = nil | |
| 330 | + | rt.noticesESC.stop() | |
| 331 | + | rt.noticeMu.Unlock() | |
| 332 | + | afterFamily("notice") | |
| 333 | + | return true | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool { | |
| 232 | 337 | if rt == nil || rt.ac == nil { | |
| 233 | 338 | return false | |
| 234 | 339 | } | |
| 235 | 340 | ac := rt.ac | |
| 341 | + | var effect *roleEffectTicket | |
| 342 | + | if len(effects) != 0 { | |
| 343 | + | effect = effects[0] | |
| 344 | + | } | |
| 236 | 345 | if rt.promptActive() { | |
| 237 | - | d.handlePromptInput(ac, data) | |
| 346 | + | d.handlePromptInput(ac, data, effect) | |
| 238 | 347 | return true | |
| 239 | 348 | } | |
| 240 | 349 | if rt.paletteActive() { | |
| 241 | - | d.handlePaletteInput(ac, data) | |
| 350 | + | d.handlePaletteInput(ac, data, effect) | |
| 242 | 351 | return true | |
| 243 | 352 | } | |
| 244 | 353 | if rt.pickerActive() { | |
| 245 | - | d.handlePickerInput(ac, data) | |
| 354 | + | d.handlePickerInput(ac, data, effect) | |
| 246 | 355 | return true | |
| 247 | 356 | } | |
| 248 | 357 | if rt.noticesActive() { | |
M internal/usecase/daemon/palette.go +97 -9
| @@ -148,7 +148,7 @@ func (d *Daemon) recordPaletteUse(code string) { | |||
| 148 | 148 | d.paletteRecent = recent | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | - | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |
| 151 | + | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 152 | 152 | sess := ac.currentSession() | |
| 153 | 153 | if sess == nil { | |
| 154 | 154 | return | |
| @@ -161,6 +161,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 161 | 161 | var generation uint64 | |
| 162 | 162 | var rawQuery string | |
| 163 | 163 | changed, cancel, execute := false, false, false | |
| 164 | + | var effect *roleEffectTicket | |
| 165 | + | if len(effects) != 0 { | |
| 166 | + | effect = effects[0] | |
| 167 | + | } | |
| 164 | 168 | ||
| 165 | 169 | ac.overlays.paletteMu.Lock() | |
| 166 | 170 | if ac.overlays.palette == nil { | |
| @@ -271,7 +275,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 271 | 275 | } else { | |
| 272 | 276 | target.Stopped = true | |
| 273 | 277 | } | |
| 274 | - | if err := d.switchToTarget(sess, ac, target); err != nil { | |
| 278 | + | var err error | |
| 279 | + | if effect != nil { | |
| 280 | + | err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session") | |
| 281 | + | } else { | |
| 282 | + | err = d.switchToTarget(sess, ac, target) | |
| 283 | + | } | |
| 284 | + | if effect != nil && errors.Is(err, errAttachmentTransition) { | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | if err != nil { | |
| 275 | 288 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | |
| 276 | 289 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 277 | 290 | return | |
| @@ -289,8 +302,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 289 | 302 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 290 | 303 | return | |
| 291 | 304 | } | |
| 292 | - | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | |
| 305 | + | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect} | |
| 293 | 306 | if err := exec.JumpRecentSession(rank); err != nil { | |
| 307 | + | if errors.Is(err, errAttachmentTransition) { | |
| 308 | + | return | |
| 309 | + | } | |
| 294 | 310 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | |
| 295 | 311 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 296 | 312 | return | |
| @@ -301,20 +317,45 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 301 | 317 | } | |
| 302 | 318 | return | |
| 303 | 319 | } | |
| 304 | - | if !ac.closeExecutedPalette(generation, rawQuery) { | |
| 320 | + | roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 321 | + | if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | |
| 305 | 322 | return | |
| 306 | 323 | } | |
| 307 | 324 | sess.dispatchMu.Lock() | |
| 308 | - | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args) | |
| 325 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) | |
| 309 | 326 | sess.dispatchMu.Unlock() | |
| 327 | + | if roleHandoff { | |
| 328 | + | if current := ac.currentSession(); current != nil { | |
| 329 | + | currentToken := current.attachmentToken(ac, ac.transport()) | |
| 330 | + | fresh, admitted := ac.beginRoleEffect(currentToken) | |
| 331 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 332 | + | d.invalidateRender(current, ac, true, "palette.go") | |
| 333 | + | } | |
| 334 | + | if admitted { | |
| 335 | + | fresh.End() | |
| 336 | + | } | |
| 337 | + | } else { | |
| 338 | + | ac.closeExecutedPalette(generation, rawQuery) | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | if errors.Is(err, errAttachmentTransition) { | |
| 342 | + | return | |
| 343 | + | } | |
| 310 | 344 | if err != nil { | |
| 311 | 345 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| 312 | - | d.reportError(sess, err) | |
| 346 | + | d.reportError(sess, paletteCommandNoticeError(cmd, err)) | |
| 313 | 347 | } else { | |
| 314 | 348 | d.recordPaletteUse(cmd.Code) | |
| 315 | 349 | } | |
| 316 | 350 | } | |
| 317 | 351 | ||
| 352 | + | func paletteCommandNoticeError(cmd command.Command, err error) error { | |
| 353 | + | if cmd.Scope == command.CommandScopeCrossSession { | |
| 354 | + | return movePickerUserError(err) | |
| 355 | + | } | |
| 356 | + | return err | |
| 357 | + | } | |
| 358 | + | ||
| 318 | 359 | func paletteArgs(query string, cmd command.Command) []string { | |
| 319 | 360 | action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) | |
| 320 | 361 | if !ok { | |
| @@ -357,6 +398,7 @@ type paletteExec struct { | |||
| 357 | 398 | ac *attachedClient | |
| 358 | 399 | recent []recentSession | |
| 359 | 400 | actions daemonActionRunner | |
| 401 | + | effect *roleEffectTicket | |
| 360 | 402 | redrawClosedPalette bool | |
| 361 | 403 | } | |
| 362 | 404 | ||
| @@ -389,8 +431,11 @@ func (e paletteExec) CreateTab() error { | |||
| 389 | 431 | } | |
| 390 | 432 | ||
| 391 | 433 | 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) | |
| 394 | 439 | }) | |
| 395 | 440 | return nil | |
| 396 | 441 | } | |
| @@ -429,6 +474,31 @@ func (e paletteExec) ClosePane() error { | |||
| 429 | 474 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |
| 430 | 475 | } | |
| 431 | 476 | ||
| 477 | + | func (e paletteExec) OpenMovePanePicker() error { | |
| 478 | + | target := resolveDaemonActionTarget(e.sess) | |
| 479 | + | if target.tab == nil || target.pane == nil { | |
| 480 | + | return errMovePaneInvalid | |
| 481 | + | } | |
| 482 | + | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{ | |
| 483 | + | Session: sessionMoveLocator(e.sess), | |
| 484 | + | TabID: domain.TabStableID(target.tab.stableID), | |
| 485 | + | PaneID: domain.PaneStableID(target.pane.stableID), | |
| 486 | + | Client: e.ac, | |
| 487 | + | }) | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | func (e paletteExec) OpenMoveTabPicker() error { | |
| 491 | + | target := resolveDaemonActionTarget(e.sess) | |
| 492 | + | if target.tab == nil { | |
| 493 | + | return errMovePaneInvalid | |
| 494 | + | } | |
| 495 | + | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{ | |
| 496 | + | Session: sessionMoveLocator(e.sess), | |
| 497 | + | TabID: domain.TabStableID(target.tab.stableID), | |
| 498 | + | Client: e.ac, | |
| 499 | + | }) | |
| 500 | + | } | |
| 501 | + | ||
| 432 | 502 | func (e paletteExec) focus(direction layout.Direction) error { | |
| 433 | 503 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |
| 434 | 504 | } | |
| @@ -463,11 +533,20 @@ func (e paletteExec) ToggleFloatingPane() error { | |||
| 463 | 533 | } | |
| 464 | 534 | ||
| 465 | 535 | func (e paletteExec) BackSession() error { | |
| 536 | + | if e.effect != nil { | |
| 537 | + | return e.d.backSessionForRole(e.effect.roleToken()) | |
| 538 | + | } | |
| 466 | 539 | e.d.backSession(e.sess, e.ac) | |
| 467 | 540 | return nil | |
| 468 | 541 | } | |
| 469 | 542 | ||
| 470 | 543 | func (e paletteExec) Detach() error { | |
| 544 | + | if e.effect != nil { | |
| 545 | + | if !e.d.clientGoneForRole(e.effect.roleToken(), true) { | |
| 546 | + | return errAttachmentTransition | |
| 547 | + | } | |
| 548 | + | return nil | |
| 549 | + | } | |
| 471 | 550 | e.d.clientGone(e.sess, e.ac, e.ac.transport(), true) | |
| 472 | 551 | return nil | |
| 473 | 552 | } | |
| @@ -535,7 +614,16 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 535 | 614 | if e.d.beforeRecentSessionHandoff != nil { | |
| 536 | 615 | e.d.beforeRecentSessionHandoff() | |
| 537 | 616 | } | |
| 538 | - | if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { | |
| 617 | + | var err error | |
| 618 | + | if e.effect != nil { | |
| 619 | + | err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | |
| 620 | + | } else { | |
| 621 | + | err = e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 622 | + | } | |
| 623 | + | if e.effect != nil && errors.Is(err, errAttachmentTransition) { | |
| 624 | + | return err | |
| 625 | + | } | |
| 626 | + | if err != nil { | |
| 539 | 627 | return command.ErrInvalidArguments | |
| 540 | 628 | } | |
| 541 | 629 | return nil | |
M internal/usecase/daemon/palette_test.go +123 -6
| @@ -143,6 +143,58 @@ func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | |||
| 143 | 143 | require.Equal(t, domain.NoticeTabSpawn, history[0].Code) | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | + | func TestPaletteCommandNoticeErrorPreservesTypedErrorsAndMapsMoveFailures(t *testing.T) { | |
| 147 | + | typedTabSpawn := domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab", errors.New("open failed")) | |
| 148 | + | tests := []struct { | |
| 149 | + | name, slug, message string | |
| 150 | + | err error | |
| 151 | + | code domain.NoticeCode | |
| 152 | + | severity domain.NoticeSeverity | |
| 153 | + | wantSame bool | |
| 154 | + | }{ | |
| 155 | + | {name: "CNT typed command error", slug: "new-tab", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true}, | |
| 156 | + | {name: "MPN no destination", slug: "move-pane", err: errNoMoveDestination, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "No destination available."}, | |
| 157 | + | {name: "MTB warming floating", slug: "move-tab", err: errMoveFloatingWarming, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Wait for the floating pane to finish opening."}, | |
| 158 | + | {name: "MPN final pane with floating slot", slug: "move-pane", err: errMoveFinalSourceFloating, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Close the floating pane or move the whole tab."}, | |
| 159 | + | {name: "MPN destination too small", slug: "move-pane", err: errMoveTooSmall, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Not enough space in destination tab."}, | |
| 160 | + | {name: "MTB stale destination", slug: "move-tab", err: errMoveStaleTarget, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Destination is no longer available."}, | |
| 161 | + | {name: "MPN generic invalid", slug: "move-pane", err: errMovePaneInvalid, code: domain.NoticeInternal, severity: domain.NoticeError, message: "Move failed."}, | |
| 162 | + | {name: "MPN typed application error", slug: "move-pane", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true}, | |
| 163 | + | } | |
| 164 | + | for _, tt := range tests { | |
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |
| 166 | + | cmd, ok := command.BySlug(tt.slug) | |
| 167 | + | require.True(t, ok) | |
| 168 | + | got := paletteCommandNoticeError(cmd, tt.err) | |
| 169 | + | if tt.wantSame { | |
| 170 | + | require.Same(t, tt.err, got) | |
| 171 | + | } | |
| 172 | + | var userErr *domain.UserError | |
| 173 | + | require.ErrorAs(t, got, &userErr) | |
| 174 | + | require.Equal(t, tt.code, userErr.Code) | |
| 175 | + | require.Equal(t, tt.severity, userErr.Severity) | |
| 176 | + | require.Equal(t, tt.message, userErr.Msg) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestPaletteCommandNoticeErrorUsesCommandScope(t *testing.T) { | |
| 182 | + | t.Run("future cross-session command inherits move rejection presentation", func(t *testing.T) { | |
| 183 | + | cmd := command.Command{Slug: "future-transfer", Scope: command.CommandScopeCrossSession} | |
| 184 | + | got := paletteCommandNoticeError(cmd, errMoveStaleTarget) | |
| 185 | + | var userErr *domain.UserError | |
| 186 | + | require.ErrorAs(t, got, &userErr) | |
| 187 | + | require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code) | |
| 188 | + | require.Equal(t, "Destination is no longer available.", userErr.Msg) | |
| 189 | + | }) | |
| 190 | + | ||
| 191 | + | t.Run("move-like slug without metadata is unchanged", func(t *testing.T) { | |
| 192 | + | err := errors.New("ordinary failure") | |
| 193 | + | got := paletteCommandNoticeError(command.Command{Slug: "move-pane"}, err) | |
| 194 | + | require.Same(t, err, got) | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | ||
| 146 | 198 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |
| 147 | 199 | p, release := newBlockingPTY(t) | |
| 148 | 200 | defer release() | |
| @@ -287,12 +339,19 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||
| 287 | 339 | ||
| 288 | 340 | d.handleInput(current, ac, []byte("\x1b ")) | |
| 289 | 341 | awaitFrame(t, sends, ports.MsgOutput) | |
| 342 | + | generation := ac.roleGeneration.Load() | |
| 290 | 343 | d.handleInput(current, ac, []byte("stopped\r")) | |
| 291 | 344 | ||
| 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") | |
| 294 | 350 | require.False(t, ac.overlays.paletteActive()) | |
| 295 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 351 | + | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 352 | + | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | require.Zero(t, firstOutput.BaseStateNum, "stopped-session first paint must reset moving output state") | |
| 296 | 355 | awaitFrame(t, sends, ports.MsgOutput) | |
| 297 | 356 | } | |
| 298 | 357 | ||
| @@ -394,6 +453,58 @@ func TestPaletteJRSThenBSKReversesJump(t *testing.T) { | |||
| 394 | 453 | require.Same(t, current, ac.currentSession()) | |
| 395 | 454 | } | |
| 396 | 455 | ||
| 456 | + | func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |
| 457 | + | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 458 | + | defer releaseAll(releases) | |
| 459 | + | ||
| 460 | + | // Establish a valid back-session target, then remove that target immediately | |
| 461 | + | // after the command releases its role admission. | |
| 462 | + | target := d.sessions[domain.SessionID("recent")] | |
| 463 | + | ac.previousSession.Set(target) | |
| 464 | + | invalidations := installPaletteInvalidationObserver(current) | |
| 465 | + | d.enterPalette(current, ac) | |
| 466 | + | d.handlePaletteInput(ac, []byte("BSK")) | |
| 467 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 468 | + | if action == "back-session" { | |
| 469 | + | d.mu.Lock() | |
| 470 | + | delete(d.sessions, target.id) | |
| 471 | + | d.mu.Unlock() | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | token := current.attachmentToken(ac, ac.transport()) | |
| 476 | + | effect, admitted := ac.beginRoleEffect(token) | |
| 477 | + | require.True(t, admitted) | |
| 478 | + | d.handlePaletteInput(ac, []byte("\r"), effect) | |
| 479 | + | ||
| 480 | + | require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette") | |
| 481 | + | require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment") | |
| 482 | + | invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering") | |
| 483 | + | require.True(t, invalidation.reset) | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) { | |
| 487 | + | p, release := newBlockingPTY(t) | |
| 488 | + | defer release() | |
| 489 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 490 | + | invalidations := installPaletteInvalidationObserver(sess) | |
| 491 | + | ||
| 492 | + | d.enterPalette(sess, ac) | |
| 493 | + | d.handlePaletteInput(ac, []byte("BSK")) | |
| 494 | + | // Make the attachment token detached while retaining currentSession. The | |
| 495 | + | // no-op BSK command succeeds, but its post-execution beginRoleEffect is | |
| 496 | + | // deterministically denied. | |
| 497 | + | sess.mu.Lock() | |
| 498 | + | sess.client = nil | |
| 499 | + | sess.mu.Unlock() | |
| 500 | + | ||
| 501 | + | d.handlePaletteInput(ac, []byte("\r")) | |
| 502 | + | ||
| 503 | + | require.False(t, ac.overlays.paletteActive(), "denied cleanup admission must still close the executed palette") | |
| 504 | + | invalidation := awaitTestValue(t, invalidations, "denied palette cleanup did not invalidate rendering") | |
| 505 | + | require.True(t, invalidation.reset) | |
| 506 | + | } | |
| 507 | + | ||
| 397 | 508 | func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |
| 398 | 509 | p, release := newBlockingPTY(t) | |
| 399 | 510 | defer release() | |
| @@ -538,11 +649,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 538 | 649 | require.True(t, ac.overlays.promptActive()) | |
| 539 | 650 | require.Contains(t, string(promptOutput.Data), "Create session") | |
| 540 | 651 | ||
| 652 | + | generation := ac.roleGeneration.Load() | |
| 541 | 653 | d.handleInput(sess, ac, []byte("scratch\r")) | |
| 542 | 654 | // The submit first paints the newly attached session while the prompt is | |
| 543 | 655 | // still open, then handlePromptInput closes the prompt and repaints the | |
| 544 | 656 | // client's current session. The final frame must be for the new session. | |
| 545 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 657 | + | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 658 | + | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | |
| 659 | + | require.NoError(t, err) | |
| 660 | + | require.Zero(t, firstOutput.BaseStateNum, "new-session first paint must reset moving output state") | |
| 546 | 661 | finalRepaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 547 | 662 | finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload) | |
| 548 | 663 | require.NoError(t, err) | |
| @@ -555,6 +670,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 555 | 670 | require.Equal(t, "scratch", newSess.name) | |
| 556 | 671 | require.False(t, newSess.ephemeral) | |
| 557 | 672 | require.Same(t, ac, newSess.client) | |
| 673 | + | require.Equal(t, attachmentActive, newSess.attachmentRole(ac)) | |
| 674 | + | require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | |
| 558 | 675 | require.Contains(t, string(finalOutput.Data), "scratch") | |
| 559 | 676 | require.NotContains(t, string(finalOutput.Data), "Create session") | |
| 560 | 677 | } | |
| @@ -625,12 +742,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 625 | 742 | for i, cmd := range commands { | |
| 626 | 743 | codes[i] = cmd.Code | |
| 627 | 744 | } | |
| 628 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 745 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 629 | 746 | } | |
| 630 | 747 | ||
| 631 | 748 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 632 | 749 | d := &Daemon{} | |
| 633 | - | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 750 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 634 | 751 | ||
| 635 | 752 | var wg sync.WaitGroup | |
| 636 | 753 | for range 50 { | |
M internal/usecase/daemon/pane.go +132 -17
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| @@ -10,6 +11,97 @@ import ( | |||
| 10 | 11 | "github.com/bnema/vev/pkg/vt" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 14 | + | // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon | |
| 15 | + | // parent lets an installed pane move between sessions without changing process | |
| 16 | + | // lifetime. Temporary opening parents bound an unpublished Open; publish | |
| 17 | + | // atomically detaches those parents before transferring cancellation to pane. | |
| 18 | + | type paneProcessLifetime struct { | |
| 19 | + | ctx context.Context | |
| 20 | + | cancel context.CancelFunc | |
| 21 | + | ||
| 22 | + | mu sync.Mutex | |
| 23 | + | opening bool | |
| 24 | + | openingParents []context.Context | |
| 25 | + | stopOpening []func() bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (d *Daemon) newPaneProcessLifetime(openingParents ...context.Context) *paneProcessLifetime { | |
| 29 | + | parent := d.paneProcessCtx | |
| 30 | + | if parent == nil { | |
| 31 | + | parent = context.Background() | |
| 32 | + | } | |
| 33 | + | ctx, rawCancel := context.WithCancel(parent) | |
| 34 | + | var cancelOnce sync.Once | |
| 35 | + | lifetime := &paneProcessLifetime{ | |
| 36 | + | ctx: ctx, | |
| 37 | + | cancel: func() { cancelOnce.Do(rawCancel) }, | |
| 38 | + | opening: true, | |
| 39 | + | openingParents: append([]context.Context(nil), openingParents...), | |
| 40 | + | } | |
| 41 | + | for _, openingParent := range lifetime.openingParents { | |
| 42 | + | if openingParent == nil { | |
| 43 | + | continue | |
| 44 | + | } | |
| 45 | + | lifetime.stopOpening = append(lifetime.stopOpening, context.AfterFunc(openingParent, func() { | |
| 46 | + | lifetime.mu.Lock() | |
| 47 | + | if lifetime.opening { | |
| 48 | + | lifetime.cancel() | |
| 49 | + | } | |
| 50 | + | lifetime.mu.Unlock() | |
| 51 | + | })) | |
| 52 | + | } | |
| 53 | + | return lifetime | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // publish transfers a successfully opened process to p. A cancellation that | |
| 57 | + | // wins before this call prevents publication; one that starts afterward no | |
| 58 | + | // longer belongs to the source session or tab. | |
| 59 | + | func (l *paneProcessLifetime) publish(p *pane) bool { | |
| 60 | + | if l == nil || p == nil { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | l.mu.Lock() | |
| 64 | + | defer l.mu.Unlock() | |
| 65 | + | for _, parent := range l.openingParents { | |
| 66 | + | if parent != nil && parent.Err() != nil { | |
| 67 | + | l.opening = false | |
| 68 | + | l.stopOpeningParentsLocked() | |
| 69 | + | l.cancel() | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | if l.ctx.Err() != nil { | |
| 74 | + | l.opening = false | |
| 75 | + | l.stopOpeningParentsLocked() | |
| 76 | + | l.cancel() | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | l.opening = false | |
| 80 | + | l.stopOpeningParentsLocked() | |
| 81 | + | p.ctx = l.ctx | |
| 82 | + | p.cancel = l.cancel | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (l *paneProcessLifetime) abort() { | |
| 87 | + | if l == nil { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | l.mu.Lock() | |
| 91 | + | l.opening = false | |
| 92 | + | l.stopOpeningParentsLocked() | |
| 93 | + | l.cancel() | |
| 94 | + | l.mu.Unlock() | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (l *paneProcessLifetime) stopOpeningParentsLocked() { | |
| 98 | + | for _, stop := range l.stopOpening { | |
| 99 | + | stop() | |
| 100 | + | } | |
| 101 | + | l.stopOpening = nil | |
| 102 | + | l.openingParents = nil | |
| 103 | + | } | |
| 104 | + | ||
| 13 | 105 | // pane owns one PTY and its terminal screen state. | |
| 14 | 106 | // Lock order when multiple locks are held is: | |
| 15 | 107 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| @@ -18,31 +110,54 @@ type pane struct { | |||
| 18 | 110 | id layout.PaneID | |
| 19 | 111 | stableID string | |
| 20 | 112 | 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 | |
| 27 | 123 | // resizeApplying gates VT parsing across PTY.Resize. The reader continues | |
| 28 | 124 | // draining into resizePending so output is replayed against the target (or | |
| 29 | 125 | // 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 | |
| 41 | 137 | // onExit is set before the reader starts and never changed. Floating panes | |
| 42 | 138 | // use it to reap their independent slot without touching the layout tree. | |
| 43 | 139 | onExit func() | |
| 44 | 140 | } | |
| 45 | 141 | ||
| 142 | + | func closePaneProcess(p *pane) { | |
| 143 | + | if p == nil { | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | p.processCloseOnce.Do(func() { | |
| 147 | + | // Revocation shares pane.mu with VT parsing and owner publication, so no | |
| 148 | + | // effect lease remains current once terminal teardown begins. | |
| 149 | + | p.mu.Lock() | |
| 150 | + | p.clearOwnerLocked() | |
| 151 | + | p.mu.Unlock() | |
| 152 | + | if p.cancel != nil { | |
| 153 | + | p.cancel() | |
| 154 | + | } | |
| 155 | + | if p.pty != nil { | |
| 156 | + | _ = p.pty.Close() | |
| 157 | + | } | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | ||
| 46 | 161 | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |
| 47 | 162 | return newPaneWithStableID(id, fallbackStableID("p"), pty, sz) | |
| 48 | 163 | } | |
M internal/usecase/daemon/pane_actions.go +122 -14
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "errors" | |
| 6 | 5 | "fmt" | |
| 6 | + | "slices" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -80,24 +80,35 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 80 | 80 | return fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 81 | 81 | } | |
| 82 | 82 | command, args := d.ptyCommand(env) | |
| 83 | - | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |
| 83 | + | lifetime := d.newPaneProcessLifetime(tb.ctx) | |
| 84 | + | pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |
| 84 | 85 | if err != nil { | |
| 86 | + | lifetime.abort() | |
| 87 | + | if pty != nil { | |
| 88 | + | _ = pty.Close() | |
| 89 | + | } | |
| 85 | 90 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 86 | 91 | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | |
| 87 | 92 | } | |
| 88 | 93 | ||
| 89 | - | pctx, cancel := context.WithCancel(tb.ctx) | |
| 90 | 94 | p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect)) | |
| 91 | - | p.ctx, p.cancel = pctx, cancel | |
| 92 | 95 | p.rect = newRect | |
| 93 | 96 | ||
| 94 | 97 | tb.mu.Lock() | |
| 95 | - | if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | |
| 98 | + | if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) || tb.ctx != nil && tb.ctx.Err() != nil { | |
| 96 | 99 | tb.mu.Unlock() | |
| 97 | - | cancel() | |
| 100 | + | lifetime.abort() | |
| 98 | 101 | _ = pty.Close() | |
| 99 | 102 | return layout.ErrNotFound | |
| 100 | 103 | } | |
| 104 | + | if !lifetime.publish(p) { | |
| 105 | + | tb.mu.Unlock() | |
| 106 | + | _ = pty.Close() | |
| 107 | + | return layout.ErrNotFound | |
| 108 | + | } | |
| 109 | + | // The tab lock excludes membership observers while pane.mu publishes the | |
| 110 | + | // initial owner generation. | |
| 111 | + | publishPaneOwner(p, sess, tb, 0) | |
| 101 | 112 | tb.tree = candidate | |
| 102 | 113 | tb.panes[newID] = p | |
| 103 | 114 | tb.bumpLayoutGenerationLocked() | |
| @@ -193,11 +204,110 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | |||
| 193 | 204 | return d.closePane(sess, tb, id, ac, true) | |
| 194 | 205 | } | |
| 195 | 206 | ||
| 207 | + | // reapPane retains the explicit-owner entry point for focused close tests. PTY | |
| 208 | + | // readers use reapPaneOwner so an owner publication cannot redirect exit to a | |
| 209 | + | // replacement that reused the same tab-local pane ID. | |
| 196 | 210 | func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) { | |
| 197 | 211 | if p == nil { | |
| 198 | 212 | return | |
| 199 | 213 | } | |
| 200 | - | _ = d.closePane(sess, tb, p.id, nil, true) | |
| 214 | + | lease := p.effectLease() | |
| 215 | + | if lease.owner == nil { | |
| 216 | + | _ = d.closePane(sess, tb, p.id, nil, true) | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | if lease.owner.session != sess || lease.owner.tab != tb { | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | d.reapPaneOwner(p) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries | |
| 226 | + | // the immutable owner pointer; a successful attempt revokes owner publication | |
| 227 | + | // under pane.mu before releasing membership locks, so a competing close or | |
| 228 | + | // move can never reap the pane from a second owner. | |
| 229 | + | func (d *Daemon) reapPaneOwner(p *pane) { | |
| 230 | + | for p != nil { | |
| 231 | + | lease := p.effectLease() | |
| 232 | + | if lease.owner == nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | if d.reapTiledPaneLease(lease) { | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |
| 242 | + | p, owner := lease.pane, lease.owner | |
| 243 | + | if p == nil || owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration != 0 { | |
| 244 | + | return true | |
| 245 | + | } | |
| 246 | + | sess, tb := owner.session, owner.tab | |
| 247 | + | sess.mu.Lock() | |
| 248 | + | ownedTab := slices.Contains(sess.tabs, tb) | |
| 249 | + | if p.owner.Load() != owner { | |
| 250 | + | sess.mu.Unlock() | |
| 251 | + | return false | |
| 252 | + | } | |
| 253 | + | if !ownedTab { | |
| 254 | + | sess.mu.Unlock() | |
| 255 | + | return true | |
| 256 | + | } | |
| 257 | + | ac := sess.client | |
| 258 | + | sessionName := sess.name | |
| 259 | + | tb.mu.Lock() | |
| 260 | + | if p.owner.Load() != owner { | |
| 261 | + | tb.mu.Unlock() | |
| 262 | + | sess.mu.Unlock() | |
| 263 | + | return false | |
| 264 | + | } | |
| 265 | + | if tb.panes[p.id] != p || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, p.id) { | |
| 266 | + | tb.mu.Unlock() | |
| 267 | + | sess.mu.Unlock() | |
| 268 | + | return true | |
| 269 | + | } | |
| 270 | + | p.mu.Lock() | |
| 271 | + | if p.owner.Load() != owner { | |
| 272 | + | p.mu.Unlock() | |
| 273 | + | tb.mu.Unlock() | |
| 274 | + | sess.mu.Unlock() | |
| 275 | + | return false | |
| 276 | + | } | |
| 277 | + | finalPane := len(layout.LeafIDs(tb.tree.Root)) <= 1 | |
| 278 | + | if !finalPane { | |
| 279 | + | if err := tb.tree.Close(p.id); err != nil { | |
| 280 | + | p.mu.Unlock() | |
| 281 | + | tb.mu.Unlock() | |
| 282 | + | sess.mu.Unlock() | |
| 283 | + | return true | |
| 284 | + | } | |
| 285 | + | delete(tb.panes, p.id) | |
| 286 | + | tb.bumpLayoutGenerationLocked() | |
| 287 | + | } | |
| 288 | + | p.clearOwnerLocked() | |
| 289 | + | p.mu.Unlock() | |
| 290 | + | tb.mu.Unlock() | |
| 291 | + | sess.mu.Unlock() | |
| 292 | + | ||
| 293 | + | if finalPane { | |
| 294 | + | _ = d.closeTab(sess, tb, true) | |
| 295 | + | return true | |
| 296 | + | } | |
| 297 | + | d.applyTabLayout(sess, tb) | |
| 298 | + | if ac != nil { | |
| 299 | + | ac.overlays.clearCopyModeForPane(p) | |
| 300 | + | ac.pruneCaptureFrames(p) | |
| 301 | + | } | |
| 302 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 303 | + | rc.noteSyncPaneRemoved(p) | |
| 304 | + | } | |
| 305 | + | closePaneProcess(p) | |
| 306 | + | d.log.Info("pane closed", "session", sessionName, "pane", p.id) | |
| 307 | + | if ac != nil { | |
| 308 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 309 | + | } | |
| 310 | + | return true | |
| 201 | 311 | } | |
| 202 | 312 | ||
| 203 | 313 | func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error { | |
| @@ -239,12 +349,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 239 | 349 | if rc := sess.renderCoordinator(); rc != nil { | |
| 240 | 350 | rc.noteSyncPaneRemoved(p) | |
| 241 | 351 | } | |
| 242 | - | if p.cancel != nil { | |
| 243 | - | p.cancel() | |
| 244 | - | } | |
| 245 | - | if p.pty != nil { | |
| 246 | - | _ = p.pty.Close() | |
| 247 | - | } | |
| 352 | + | closePaneProcess(p) | |
| 248 | 353 | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 249 | 354 | if repaint { | |
| 250 | 355 | if ac != nil { | |
| @@ -254,7 +359,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 254 | 359 | return nil | |
| 255 | 360 | } | |
| 256 | 361 | ||
| 257 | - | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 362 | + | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error { | |
| 258 | 363 | target := resolveDaemonActionTarget(sess) | |
| 259 | 364 | oldFocus := layout.PaneID("") | |
| 260 | 365 | if target.pane != nil { | |
| @@ -279,6 +384,9 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 279 | 384 | if ac == nil { | |
| 280 | 385 | return errNoNeighbor | |
| 281 | 386 | } | |
| 387 | + | if effect != nil { | |
| 388 | + | return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session") | |
| 389 | + | } | |
| 282 | 390 | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |
| 283 | 391 | } | |
| 284 | 392 | ||
M internal/usecase/daemon/pane_actions_test.go +2 -2
| @@ -625,7 +625,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||
| 625 | 625 | ac.initOverlays() | |
| 626 | 626 | ac.overlays.copyMode = &scopy.Mode{} | |
| 627 | 627 | ||
| 628 | - | require.NoError(t, d.focusDir(sess, ac, layout.Right)) | |
| 628 | + | require.NoError(t, d.focusDir(sess, ac, layout.Right, nil)) | |
| 629 | 629 | ||
| 630 | 630 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 631 | 631 | require.Nil(t, ac.overlays.copyMode) | |
| @@ -726,7 +726,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 726 | 726 | require.NoError(t, d.stackPane(sess, nil)) | |
| 727 | 727 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 728 | 728 | ||
| 729 | - | require.NoError(t, d.focusDir(sess, nil, layout.Up)) | |
| 729 | + | require.NoError(t, d.focusDir(sess, nil, layout.Up, nil)) | |
| 730 | 730 | ||
| 731 | 731 | tb := sess.activeTab() | |
| 732 | 732 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
A internal/usecase/daemon/pane_lifetime_test.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type lifetimePTY struct { | |
| 20 | + | ctx context.Context | |
| 21 | + | closed chan struct{} | |
| 22 | + | closeOnce sync.Once | |
| 23 | + | closes atomic.Int32 | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func newLifetimePTY() *lifetimePTY { | |
| 27 | + | return &lifetimePTY{closed: make(chan struct{})} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (p *lifetimePTY) Read([]byte) (int, error) { | |
| 31 | + | select { | |
| 32 | + | case <-p.ctx.Done(): | |
| 33 | + | return 0, p.ctx.Err() | |
| 34 | + | case <-p.closed: | |
| 35 | + | return 0, io.EOF | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 40 | + | func (*lifetimePTY) Resize(domain.Size) error { return nil } | |
| 41 | + | func (*lifetimePTY) Pid() int { return 1 } | |
| 42 | + | func (*lifetimePTY) ForegroundPgid() (int, error) { | |
| 43 | + | return 1, nil | |
| 44 | + | } | |
| 45 | + | func (p *lifetimePTY) Close() error { | |
| 46 | + | p.closes.Add(1) | |
| 47 | + | p.closeOnce.Do(func() { close(p.closed) }) | |
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type lifetimePTYFactory struct { | |
| 52 | + | mu sync.Mutex | |
| 53 | + | ptys []*lifetimePTY | |
| 54 | + | contexts []context.Context | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 58 | + | p := newLifetimePTY() | |
| 59 | + | p.ctx = ctx | |
| 60 | + | f.mu.Lock() | |
| 61 | + | f.ptys = append(f.ptys, p) | |
| 62 | + | f.contexts = append(f.contexts, ctx) | |
| 63 | + | f.mu.Unlock() | |
| 64 | + | return p, nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (f *lifetimePTYFactory) opened(index int) (*lifetimePTY, context.Context) { | |
| 68 | + | f.mu.Lock() | |
| 69 | + | defer f.mu.Unlock() | |
| 70 | + | return f.ptys[index], f.contexts[index] | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |
| 74 | + | type openCase struct { | |
| 75 | + | name string | |
| 76 | + | wantCode domain.NoticeCode | |
| 77 | + | run func(*testing.T, *Daemon) ([]*tab, func(), error) | |
| 78 | + | } | |
| 79 | + | tests := []openCase{ | |
| 80 | + | { | |
| 81 | + | name: "initial pane", | |
| 82 | + | wantCode: domain.NoticeSessionSpawn, | |
| 83 | + | run: func(_ *testing.T, d *Daemon) ([]*tab, func(), error) { | |
| 84 | + | sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 85 | + | if sess != nil { | |
| 86 | + | return sess.tabs, func() { closeTabs(sess.tabs) }, err | |
| 87 | + | } | |
| 88 | + | return nil, d.serveCancel, err | |
| 89 | + | }, | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | name: "created tab", | |
| 93 | + | wantCode: domain.NoticeTabSpawn, | |
| 94 | + | run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) { | |
| 95 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 96 | + | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 97 | + | base.ctx, base.cancel = context.WithCancel(sessCtx) | |
| 98 | + | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 99 | + | d.mu.Lock() | |
| 100 | + | d.sessions[sess.id] = sess | |
| 101 | + | d.mu.Unlock() | |
| 102 | + | err := d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) | |
| 103 | + | return sess.tabs, func() { | |
| 104 | + | cancelSession() | |
| 105 | + | closeTabs(sess.tabs) | |
| 106 | + | }, err | |
| 107 | + | }, | |
| 108 | + | }, | |
| 109 | + | { | |
| 110 | + | name: "split pane", | |
| 111 | + | wantCode: domain.NoticePaneSpawn, | |
| 112 | + | run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) { | |
| 113 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 114 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | |
| 115 | + | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 116 | + | sess := &session{id: "work", name: "work", cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 117 | + | err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | |
| 118 | + | return []*tab{tb}, func() { | |
| 119 | + | cancelSession() | |
| 120 | + | tb.closeAllPanes() | |
| 121 | + | }, err | |
| 122 | + | }, | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "restored pane", | |
| 126 | + | run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) { | |
| 127 | + | restoreCtx, cancelRestore := context.WithCancel(t.Context()) | |
| 128 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 129 | + | tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work")) | |
| 130 | + | return tabs, func() { | |
| 131 | + | cancelRestore() | |
| 132 | + | cancelSession() | |
| 133 | + | closeRestoredTabs(tabs) | |
| 134 | + | }, err | |
| 135 | + | }, | |
| 136 | + | }, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | for _, tt := range tests { | |
| 140 | + | t.Run(tt.name, func(t *testing.T) { | |
| 141 | + | cause := errors.New("open failed") | |
| 142 | + | pty := newFailedOpenPTY() | |
| 143 | + | cancelCallbacks := atomic.Int32{} | |
| 144 | + | cancelled := make(chan struct{}) | |
| 145 | + | openFactory := paneErrorFactory{ | |
| 146 | + | pty: pty, | |
| 147 | + | err: cause, | |
| 148 | + | onOpen: func(ctx context.Context) { | |
| 149 | + | pty.ctx = ctx | |
| 150 | + | context.AfterFunc(ctx, func() { | |
| 151 | + | cancelCallbacks.Add(1) | |
| 152 | + | close(cancelled) | |
| 153 | + | }) | |
| 154 | + | }, | |
| 155 | + | } | |
| 156 | + | d := newTestDaemon(t, &openFactory, stubClock{}) | |
| 157 | + | ||
| 158 | + | tabs, cleanup, err := tt.run(t, d) | |
| 159 | + | require.ErrorIs(t, err, cause) | |
| 160 | + | if tt.wantCode != 0 { | |
| 161 | + | var userErr *domain.UserError | |
| 162 | + | require.ErrorAs(t, err, &userErr) | |
| 163 | + | require.Equal(t, tt.wantCode, userErr.Code) | |
| 164 | + | } | |
| 165 | + | select { | |
| 166 | + | case <-cancelled: | |
| 167 | + | case <-time.After(time.Second): | |
| 168 | + | t.Fatal("failed pane Open process context was not cancelled") | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | for _, tb := range tabs { | |
| 172 | + | if tb == nil { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | tb.mu.Lock() | |
| 176 | + | floating := tb.floating.pane | |
| 177 | + | var floatingPTY ports.PTY | |
| 178 | + | if floating != nil { | |
| 179 | + | floating.mu.Lock() | |
| 180 | + | floatingPTY = floating.pty | |
| 181 | + | floating.mu.Unlock() | |
| 182 | + | } | |
| 183 | + | publishedFailedPTY := false | |
| 184 | + | for _, published := range tb.panes { | |
| 185 | + | publishedFailedPTY = publishedFailedPTY || published.pty == pty | |
| 186 | + | } | |
| 187 | + | tb.mu.Unlock() | |
| 188 | + | require.NotEqual(t, pty, floatingPTY) | |
| 189 | + | require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner") | |
| 190 | + | } | |
| 191 | + | require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader") | |
| 192 | + | require.Equal(t, int32(1), pty.closes.Load(), "failed Open PTY must close exactly once") | |
| 193 | + | require.Equal(t, int32(1), cancelCallbacks.Load(), "failed Open context must cancel exactly once") | |
| 194 | + | ||
| 195 | + | cleanup() | |
| 196 | + | require.Equal(t, int32(1), pty.closes.Load(), "later teardown must not close an unpublished PTY again") | |
| 197 | + | require.Equal(t, int32(1), cancelCallbacks.Load(), "later teardown must not recancel an unpublished process") | |
| 198 | + | }) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | type paneErrorFactory struct { | |
| 203 | + | pty ports.PTY | |
| 204 | + | err error | |
| 205 | + | onOpen func(context.Context) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 209 | + | if f.onOpen != nil { | |
| 210 | + | f.onOpen(ctx) | |
| 211 | + | } | |
| 212 | + | return f.pty, f.err | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |
| 216 | + | type setupFunc func(*testing.T, *Daemon) (*pane, func(), func()) | |
| 217 | + | tests := []struct { | |
| 218 | + | name string | |
| 219 | + | setup setupFunc | |
| 220 | + | }{ | |
| 221 | + | { | |
| 222 | + | name: "initial pane", | |
| 223 | + | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 224 | + | sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | return sess.activeTab().focusedPane(), sess.cancel, func() { | |
| 227 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 228 | + | } | |
| 229 | + | }, | |
| 230 | + | }, | |
| 231 | + | { | |
| 232 | + | name: "created tab", | |
| 233 | + | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 234 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 235 | + | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 236 | + | base.ctx, base.cancel = context.WithCancel(sessCtx) | |
| 237 | + | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 238 | + | d.mu.Lock() | |
| 239 | + | d.sessions[sess.id] = sess | |
| 240 | + | d.mu.Unlock() | |
| 241 | + | require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24})) | |
| 242 | + | created := sess.activeTab() | |
| 243 | + | return created.focusedPane(), cancelSession, created.closeAllPanes | |
| 244 | + | }, | |
| 245 | + | }, | |
| 246 | + | { | |
| 247 | + | name: "split pane", | |
| 248 | + | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 249 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 250 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 251 | + | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 252 | + | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 253 | + | target := tb.focusedPane() | |
| 254 | + | require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right)) | |
| 255 | + | tb.mu.Lock() | |
| 256 | + | p := tb.panes["pane-2"] | |
| 257 | + | tb.mu.Unlock() | |
| 258 | + | require.NotNil(t, p) | |
| 259 | + | return p, cancelSession, tb.closeAllPanes | |
| 260 | + | }, | |
| 261 | + | }, | |
| 262 | + | { | |
| 263 | + | name: "floating pane", | |
| 264 | + | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 265 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 266 | + | tb := newFloatingTestTab(t) | |
| 267 | + | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 268 | + | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 269 | + | tb.mu.Lock() | |
| 270 | + | generation := tb.beginFloatingWarmLocked(true) | |
| 271 | + | tb.mu.Unlock() | |
| 272 | + | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | |
| 273 | + | sessionName: "work", size: domain.Size{Cols: 20, Rows: 8}, | |
| 274 | + | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |
| 275 | + | paneStableID: "p_floating", parentCtx: tb.ctx, | |
| 276 | + | }, generation) | |
| 277 | + | tb.mu.Lock() | |
| 278 | + | p := tb.floating.pane | |
| 279 | + | tb.mu.Unlock() | |
| 280 | + | require.NotNil(t, p) | |
| 281 | + | return p, cancelSession, func() { d.teardownFloating(tb, nil) } | |
| 282 | + | }, | |
| 283 | + | }, | |
| 284 | + | { | |
| 285 | + | name: "restored pane", | |
| 286 | + | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 287 | + | restoreCtx, cancelRestore := context.WithCancel(t.Context()) | |
| 288 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 289 | + | tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work")) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | require.Len(t, tabs, 1) | |
| 292 | + | return tabs[0].focusedPane(), cancelSession, func() { | |
| 293 | + | cancelRestore() | |
| 294 | + | closeRestoredTabs(tabs) | |
| 295 | + | } | |
| 296 | + | }, | |
| 297 | + | }, | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | for _, tt := range tests { | |
| 301 | + | t.Run(tt.name, func(t *testing.T) { | |
| 302 | + | factory := &lifetimePTYFactory{} | |
| 303 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 304 | + | p, cancelSession, teardown := tt.setup(t, d) | |
| 305 | + | defer func() { | |
| 306 | + | if teardown != nil { | |
| 307 | + | teardown() | |
| 308 | + | } | |
| 309 | + | }() | |
| 310 | + | ||
| 311 | + | require.NotNil(t, p) | |
| 312 | + | pty, openCtx := factory.opened(0) | |
| 313 | + | require.NotNil(t, p.ctx) | |
| 314 | + | require.Same(t, openCtx, p.ctx) | |
| 315 | + | require.NotNil(t, p.cancel) | |
| 316 | + | ||
| 317 | + | cancelSession() | |
| 318 | + | select { | |
| 319 | + | case <-openCtx.Done(): | |
| 320 | + | t.Fatal("session cancellation killed a published pane process") | |
| 321 | + | default: | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | teardown() | |
| 325 | + | teardown = nil | |
| 326 | + | select { | |
| 327 | + | case <-openCtx.Done(): | |
| 328 | + | case <-time.After(time.Second): | |
| 329 | + | t.Fatal("teardown did not cancel the pane process") | |
| 330 | + | } | |
| 331 | + | d.sessWg.Wait() | |
| 332 | + | require.Equal(t, int32(1), pty.closes.Load()) | |
| 333 | + | }) | |
| 334 | + | } | |
| 335 | + | } | |
A internal/usecase/daemon/pane_owner.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // paneOwner is an immutable routing snapshot for one published pane. A zero | |
| 4 | + | // floatingSlotGeneration identifies a tiled pane; installed floating panes | |
| 5 | + | // carry the tab slot generation that owns them. | |
| 6 | + | type paneOwner struct { | |
| 7 | + | session *session | |
| 8 | + | tab *tab | |
| 9 | + | generation uint64 | |
| 10 | + | floatingSlotGeneration uint64 | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // paneEffectLease binds an effect to the exact immutable owner generation that | |
| 14 | + | // existed while the pane was parsed or otherwise inspected under pane.mu. | |
| 15 | + | type paneEffectLease struct { | |
| 16 | + | pane *pane | |
| 17 | + | owner *paneOwner | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Current reports whether the lease still names the published pane owner. For | |
| 21 | + | // floating panes it also verifies that the same tab slot and slot generation | |
| 22 | + | // remain installed. Callers must not hold pane.mu or tab.mu. | |
| 23 | + | func (l paneEffectLease) Current() bool { | |
| 24 | + | return l.pane != nil && l.pane.ownerLeaseCurrent(l) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ownerSnapshot returns the immutable owner visible to lock-free routing | |
| 28 | + | // readers. Ownership publication itself is serialized under pane.mu. | |
| 29 | + | func (p *pane) ownerSnapshot() *paneOwner { | |
| 30 | + | if p == nil { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | return p.owner.Load() | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // effectLeaseLocked captures the current owner while pane.mu linearizes VT | |
| 37 | + | // parsing against transfer. The caller must hold pane.mu. | |
| 38 | + | func (p *pane) effectLeaseLocked() paneEffectLease { | |
| 39 | + | if p == nil { | |
| 40 | + | return paneEffectLease{} | |
| 41 | + | } | |
| 42 | + | return paneEffectLease{pane: p, owner: p.owner.Load()} | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // effectLease captures an owner generation outside an existing pane critical | |
| 46 | + | // section. Callers use effectLeaseLocked when parsing already owns pane.mu. | |
| 47 | + | func (p *pane) effectLease() paneEffectLease { | |
| 48 | + | if p == nil { | |
| 49 | + | return paneEffectLease{} | |
| 50 | + | } | |
| 51 | + | p.mu.Lock() | |
| 52 | + | lease := p.effectLeaseLocked() | |
| 53 | + | p.mu.Unlock() | |
| 54 | + | return lease | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // paneEffectSessionName snapshots log metadata only while the lease remains | |
| 58 | + | // current. Holding session.mu across the final pointer check linearizes the | |
| 59 | + | // snapshot with owner publication, whose move commit also owns the session. | |
| 60 | + | func paneEffectSessionName(lease paneEffectLease) (string, bool) { | |
| 61 | + | if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || !lease.Current() { | |
| 62 | + | return "", false | |
| 63 | + | } | |
| 64 | + | lease.owner.session.mu.Lock() | |
| 65 | + | current := lease.pane.owner.Load() == lease.owner | |
| 66 | + | name := lease.owner.session.name | |
| 67 | + | lease.owner.session.mu.Unlock() | |
| 68 | + | return name, current | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // publishOwnerLocked publishes a new immutable owner generation. The caller | |
| 72 | + | // must hold pane.mu, and must also hold tab.mu when publishing a floating owner. | |
| 73 | + | func (p *pane) publishOwnerLocked(sess *session, tb *tab, floatingSlotGeneration uint64) (paneEffectLease, paneEffectLease) { | |
| 74 | + | if p == nil { | |
| 75 | + | return paneEffectLease{}, paneEffectLease{} | |
| 76 | + | } | |
| 77 | + | old := paneEffectLease{pane: p, owner: p.owner.Load()} | |
| 78 | + | p.ownerGeneration++ | |
| 79 | + | next := &paneOwner{ | |
| 80 | + | session: sess, | |
| 81 | + | tab: tb, | |
| 82 | + | generation: p.ownerGeneration, | |
| 83 | + | floatingSlotGeneration: floatingSlotGeneration, | |
| 84 | + | } | |
| 85 | + | p.owner.Store(next) | |
| 86 | + | return old, paneEffectLease{pane: p, owner: next} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // clearOwnerLocked revokes the published owner during terminal teardown. The | |
| 90 | + | // caller must hold pane.mu. The returned lease identifies the retired owner. | |
| 91 | + | func (p *pane) clearOwnerLocked() paneEffectLease { | |
| 92 | + | if p == nil { | |
| 93 | + | return paneEffectLease{} | |
| 94 | + | } | |
| 95 | + | old := paneEffectLease{pane: p, owner: p.owner.Load()} | |
| 96 | + | if old.owner != nil { | |
| 97 | + | p.ownerGeneration++ | |
| 98 | + | p.owner.Store(nil) | |
| 99 | + | } | |
| 100 | + | return old | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (p *pane) ownerLeaseCurrent(lease paneEffectLease) bool { | |
| 104 | + | if p == nil || lease.pane != p || lease.owner == nil || p.owner.Load() != lease.owner { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | owner := lease.owner | |
| 108 | + | if owner.session == nil || owner.tab == nil { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | if owner.floatingSlotGeneration == 0 { | |
| 112 | + | return true | |
| 113 | + | } | |
| 114 | + | owner.tab.mu.Lock() | |
| 115 | + | current := owner.tab.floating.pane == p && | |
| 116 | + | owner.tab.floating.generation == owner.floatingSlotGeneration && | |
| 117 | + | (owner.tab.floating.state == floatingHidden || owner.tab.floating.state == floatingVisible) | |
| 118 | + | owner.tab.mu.Unlock() | |
| 119 | + | return current && p.owner.Load() == owner | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func publishPaneOwner(p *pane, sess *session, tb *tab, floatingSlotGeneration uint64) paneEffectLease { | |
| 123 | + | if p == nil { | |
| 124 | + | return paneEffectLease{} | |
| 125 | + | } | |
| 126 | + | p.mu.Lock() | |
| 127 | + | _, lease := p.publishOwnerLocked(sess, tb, floatingSlotGeneration) | |
| 128 | + | p.mu.Unlock() | |
| 129 | + | return lease | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // publishTiledPaneOwners initializes every pane in an unpublished tab before | |
| 133 | + | // that tab is made visible or any reader starts. | |
| 134 | + | func publishTiledPaneOwners(sess *session, tb *tab) { | |
| 135 | + | if sess == nil || tb == nil { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | tb.mu.Lock() | |
| 139 | + | for _, p := range tb.panes { | |
| 140 | + | publishPaneOwner(p, sess, tb, 0) | |
| 141 | + | } | |
| 142 | + | tb.mu.Unlock() | |
| 143 | + | } | |
A internal/usecase/daemon/pane_owner_reader_test.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "sync/atomic" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type ownerRoutingPTY struct { | |
| 15 | + | steps chan channelPTYStep | |
| 16 | + | processed chan struct{} | |
| 17 | + | activeReaders atomic.Int32 | |
| 18 | + | maxReaders atomic.Int32 | |
| 19 | + | reads atomic.Int32 | |
| 20 | + | closes atomic.Int32 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newOwnerRoutingPTY() *ownerRoutingPTY { | |
| 24 | + | return &ownerRoutingPTY{ | |
| 25 | + | steps: make(chan channelPTYStep), | |
| 26 | + | processed: make(chan struct{}, 4), | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (p *ownerRoutingPTY) Read(buf []byte) (int, error) { | |
| 31 | + | active := p.activeReaders.Add(1) | |
| 32 | + | defer p.activeReaders.Add(-1) | |
| 33 | + | for { | |
| 34 | + | maximum := p.maxReaders.Load() | |
| 35 | + | if active <= maximum || p.maxReaders.CompareAndSwap(maximum, active) { | |
| 36 | + | break | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | if p.reads.Add(1) > 1 { | |
| 40 | + | p.processed <- struct{}{} | |
| 41 | + | } | |
| 42 | + | step := <-p.steps | |
| 43 | + | return copy(buf, step.data), step.err | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 47 | + | func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil } | |
| 48 | + | func (*ownerRoutingPTY) Resize(domain.Size) error { return nil } | |
| 49 | + | func (*ownerRoutingPTY) Pid() int { return 0 } | |
| 50 | + | func (*ownerRoutingPTY) ForegroundPgid() (int, error) { | |
| 51 | + | return 0, nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func installTiledPaneOwnerForTest(sess *session, tb *tab, p *pane) { | |
| 55 | + | tb.mu.Lock() | |
| 56 | + | old := tb.panes[layout.PaneID("pane-1")] | |
| 57 | + | delete(tb.panes, layout.PaneID("pane-1")) | |
| 58 | + | if old != nil { | |
| 59 | + | old.clearOwnerForTest() | |
| 60 | + | } | |
| 61 | + | p.id = layout.PaneID("pane-1") | |
| 62 | + | tb.panes[p.id] = p | |
| 63 | + | tb.tree = &layout.Tree{Root: layout.NewLeaf(p.id), Focus: p.id} | |
| 64 | + | publishPaneOwner(p, sess, tb, 0) | |
| 65 | + | tb.mu.Unlock() | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (p *pane) clearOwnerForTest() { | |
| 69 | + | p.mu.Lock() | |
| 70 | + | p.clearOwnerLocked() | |
| 71 | + | p.mu.Unlock() | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) { | |
| 75 | + | pty := newOwnerRoutingPTY() | |
| 76 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |
| 77 | + | pane := source.activeTab().focusedPane() | |
| 78 | + | pane.onExit = func() {} | |
| 79 | + | source.snapEligible.Store(true) | |
| 80 | + | ||
| 81 | + | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 82 | + | destination := &session{ | |
| 83 | + | id: domain.SessionID("destination"), | |
| 84 | + | name: "destination", | |
| 85 | + | tabs: []*tab{destinationTab}, | |
| 86 | + | client: &attachedClient{}, | |
| 87 | + | snapEligible: atomic.Bool{}, | |
| 88 | + | } | |
| 89 | + | destination.snapEligible.Store(true) | |
| 90 | + | destination.client.initOverlays() | |
| 91 | + | destination.client.setSession(destination) | |
| 92 | + | d.sessions[destination.id] = destination | |
| 93 | + | ||
| 94 | + | sourceInvalidations := make(chan renderInvalidation, 2) | |
| 95 | + | destinationInvalidations := make(chan renderInvalidation, 2) | |
| 96 | + | sourceCoordinator := d.attachCoordinator(source, nil, source.client, true) | |
| 97 | + | sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation } | |
| 98 | + | destinationCoordinator := d.attachCoordinator(destination, nil, destination.client, true) | |
| 99 | + | destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation } | |
| 100 | + | ||
| 101 | + | d.startPaneGoroutines(source, source.activeTab(), pane) | |
| 102 | + | pty.steps <- channelPTYStep{data: []byte("source\a")} | |
| 103 | + | <-pty.processed | |
| 104 | + | require.Len(t, sourceInvalidations, 1) | |
| 105 | + | require.Empty(t, destinationInvalidations) | |
| 106 | + | require.Equal(t, uint64(1), source.snapshotGeneration) | |
| 107 | + | require.Zero(t, destination.snapshotGeneration) | |
| 108 | + | require.True(t, source.activeTab().attention) | |
| 109 | + | ||
| 110 | + | installTiledPaneOwnerForTest(destination, destinationTab, pane) | |
| 111 | + | pty.steps <- channelPTYStep{data: []byte("destination\a")} | |
| 112 | + | <-pty.processed | |
| 113 | + | ||
| 114 | + | require.Len(t, sourceInvalidations, 1, "output after owner publication must not invalidate the source") | |
| 115 | + | require.Len(t, destinationInvalidations, 1, "output after owner publication must invalidate the destination") | |
| 116 | + | require.Equal(t, uint64(1), source.snapshotGeneration, "source snapshot must not be dirtied after transfer") | |
| 117 | + | require.Equal(t, uint64(1), destination.snapshotGeneration) | |
| 118 | + | require.True(t, destinationTab.attention) | |
| 119 | + | require.Equal(t, int32(1), pty.maxReaders.Load(), "owner publication must not start another PTY reader") | |
| 120 | + | ||
| 121 | + | pty.steps <- channelPTYStep{err: io.EOF} | |
| 122 | + | d.sessWg.Wait() | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) { | |
| 126 | + | pty := newOwnerRoutingPTY() | |
| 127 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |
| 128 | + | sourceTab := source.activeTab() | |
| 129 | + | moved := sourceTab.focusedPane() | |
| 130 | + | ||
| 131 | + | destinationTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 132 | + | other := newPane(layout.PaneID("pane-2"), newQuietPTY(), domain.Size{Cols: 40, Rows: 23}) | |
| 133 | + | destinationTab.mu.Lock() | |
| 134 | + | require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23})) | |
| 135 | + | destinationTab.panes[other.id] = other | |
| 136 | + | destinationTab.mu.Unlock() | |
| 137 | + | destination := &session{id: domain.SessionID("destination"), name: "destination", tabs: []*tab{destinationTab}} | |
| 138 | + | d.sessions[destination.id] = destination | |
| 139 | + | ||
| 140 | + | // Model a completed owner commit while the one reader remains blocked in | |
| 141 | + | // Read. The source immediately reuses the old local ID, which makes stale | |
| 142 | + | // ID-only exit routing observably close the wrong pane. | |
| 143 | + | replacement := newPane(layout.PaneID("pane-1"), newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 144 | + | sourceTab.mu.Lock() | |
| 145 | + | sourceTab.panes[replacement.id] = replacement | |
| 146 | + | publishPaneOwner(replacement, source, sourceTab, 0) | |
| 147 | + | sourceTab.mu.Unlock() | |
| 148 | + | ||
| 149 | + | destinationTab.mu.Lock() | |
| 150 | + | displaced := destinationTab.panes[layout.PaneID("pane-1")] | |
| 151 | + | displaced.clearOwnerForTest() | |
| 152 | + | destinationTab.panes[moved.id] = moved | |
| 153 | + | publishPaneOwner(moved, destination, destinationTab, 0) | |
| 154 | + | destinationTab.mu.Unlock() | |
| 155 | + | ||
| 156 | + | d.sessWg.Add(1) | |
| 157 | + | go d.ptyReader(source, sourceTab, moved) | |
| 158 | + | pty.steps <- channelPTYStep{err: io.EOF} | |
| 159 | + | d.sessWg.Wait() | |
| 160 | + | ||
| 161 | + | d.mu.Lock() | |
| 162 | + | require.Same(t, source, d.sessions[source.id], "stale exit must not close the source replacement") | |
| 163 | + | require.Same(t, destination, d.sessions[destination.id]) | |
| 164 | + | d.mu.Unlock() | |
| 165 | + | sourceTab.mu.Lock() | |
| 166 | + | require.Same(t, replacement, sourceTab.panes[replacement.id]) | |
| 167 | + | sourceTab.mu.Unlock() | |
| 168 | + | destinationTab.mu.Lock() | |
| 169 | + | require.NotContains(t, destinationTab.panes, moved.id, "the exited pane must be removed from its current owner") | |
| 170 | + | require.Same(t, other, destinationTab.panes[other.id]) | |
| 171 | + | destinationTab.mu.Unlock() | |
| 172 | + | require.Nil(t, moved.ownerSnapshot()) | |
| 173 | + | require.True(t, replacement.effectLease().Current()) | |
| 174 | + | require.Equal(t, int32(1), pty.maxReaders.Load()) | |
| 175 | + | ||
| 176 | + | // A stale second reap is a no-op and cannot close either surviving owner. | |
| 177 | + | d.reapPaneOwner(moved) | |
| 178 | + | sourceTab.mu.Lock() | |
| 179 | + | require.Same(t, replacement, sourceTab.panes[replacement.id]) | |
| 180 | + | sourceTab.mu.Unlock() | |
| 181 | + | destinationTab.mu.Lock() | |
| 182 | + | require.Same(t, other, destinationTab.panes[other.id]) | |
| 183 | + | destinationTab.mu.Unlock() | |
| 184 | + | require.Equal(t, int32(1), pty.closes.Load(), "exit must release the process exactly once") | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | var _ ports.PTY = (*ownerRoutingPTY)(nil) | |
A internal/usecase/daemon/pane_owner_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) { | |
| 12 | + | p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 13 | + | firstSession := &session{id: "first"} | |
| 14 | + | firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 15 | + | secondSession := &session{id: "second"} | |
| 16 | + | secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 17 | + | ||
| 18 | + | p.mu.Lock() | |
| 19 | + | oldLease, _ := p.publishOwnerLocked(firstSession, firstTab, 0) | |
| 20 | + | firstLease := p.effectLeaseLocked() | |
| 21 | + | p.mu.Unlock() | |
| 22 | + | require.False(t, oldLease.Current()) | |
| 23 | + | require.True(t, firstLease.Current()) | |
| 24 | + | require.Equal(t, uint64(1), firstLease.owner.generation) | |
| 25 | + | require.Same(t, firstSession, firstLease.owner.session) | |
| 26 | + | require.Same(t, firstTab, firstLease.owner.tab) | |
| 27 | + | ||
| 28 | + | p.mu.Lock() | |
| 29 | + | retiredLease, _ := p.publishOwnerLocked(secondSession, secondTab, 0) | |
| 30 | + | secondLease := p.effectLeaseLocked() | |
| 31 | + | p.mu.Unlock() | |
| 32 | + | require.Equal(t, firstLease, retiredLease) | |
| 33 | + | require.False(t, firstLease.Current()) | |
| 34 | + | require.True(t, secondLease.Current()) | |
| 35 | + | require.Equal(t, uint64(2), secondLease.owner.generation) | |
| 36 | + | require.Equal(t, "p_stable", p.stableID, "owner publication must preserve stable pane identity") | |
| 37 | + | ||
| 38 | + | p.mu.Lock() | |
| 39 | + | clearedLease := p.clearOwnerLocked() | |
| 40 | + | p.mu.Unlock() | |
| 41 | + | require.Equal(t, secondLease, clearedLease) | |
| 42 | + | require.False(t, secondLease.Current()) | |
| 43 | + | require.Nil(t, p.ownerSnapshot()) | |
| 44 | + | require.Equal(t, "p_stable", p.stableID, "terminal owner clearing must preserve stable pane identity") | |
| 45 | + | // Leases retain immutable owner records even after later publication and | |
| 46 | + | // terminal clearing. | |
| 47 | + | require.Same(t, firstSession, firstLease.owner.session) | |
| 48 | + | require.Same(t, firstTab, firstLease.owner.tab) | |
| 49 | + | require.Equal(t, uint64(1), firstLease.owner.generation) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) { | |
| 53 | + | p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8}) | |
| 54 | + | sess := &session{id: "owner"} | |
| 55 | + | tb := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 56 | + | ||
| 57 | + | tb.mu.Lock() | |
| 58 | + | generation := tb.beginFloatingWarmLocked(true) | |
| 59 | + | p.mu.Lock() | |
| 60 | + | p.publishOwnerLocked(sess, tb, generation) | |
| 61 | + | lease := p.effectLeaseLocked() | |
| 62 | + | p.mu.Unlock() | |
| 63 | + | require.True(t, tb.installFloatingLocked(p, generation)) | |
| 64 | + | tb.mu.Unlock() | |
| 65 | + | require.True(t, lease.Current()) | |
| 66 | + | ||
| 67 | + | tb.mu.Lock() | |
| 68 | + | tb.floating.generation++ | |
| 69 | + | tb.mu.Unlock() | |
| 70 | + | require.False(t, lease.Current(), "a reused floating slot must reject the old pane effect lease") | |
| 71 | + | require.Equal(t, "p_float", p.stableID) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) { | |
| 75 | + | sess := &session{id: "owner"} | |
| 76 | + | tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 77 | + | second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23}) | |
| 78 | + | tb.panes[second.id] = second | |
| 79 | + | ||
| 80 | + | publishTiledPaneOwners(sess, tb) | |
| 81 | + | ||
| 82 | + | for _, p := range []*pane{tb.panes["pane-1"], second} { | |
| 83 | + | owner := p.ownerSnapshot() | |
| 84 | + | require.NotNil(t, owner) | |
| 85 | + | require.Same(t, sess, owner.session) | |
| 86 | + | require.Same(t, tb, owner.tab) | |
| 87 | + | require.Equal(t, uint64(1), owner.generation) | |
| 88 | + | require.Zero(t, owner.floatingSlotGeneration) | |
| 89 | + | } | |
| 90 | + | require.Equal(t, "p_one", tb.panes["pane-1"].stableID) | |
| 91 | + | require.Equal(t, "p_two", second.stableID) | |
| 92 | + | } | |
M internal/usecase/daemon/pane_rearrange_test.go +2 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | + | "maps" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -158,9 +159,7 @@ func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) { | |||
| 158 | 159 | h.tab.mu.Lock() | |
| 159 | 160 | h.tab.tree.Focus = "pane-3" | |
| 160 | 161 | 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) | |
| 164 | 163 | h.tab.mu.Unlock() | |
| 165 | 164 | ||
| 166 | 165 | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
A internal/usecase/daemon/pane_sync_owner.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // migratePaneSyncOwnerLocked transfers synchronized-output coordination after | |
| 4 | + | // an ownership commit. The caller must hold p.mu and pass the exact leases | |
| 5 | + | // returned by publishOwnerLocked. Holding the pane parsing fence keeps PTY | |
| 6 | + | // writes from observing a coordinator between owners. | |
| 7 | + | // | |
| 8 | + | // The returned cleanup must be finished after releasing pane and coordinator | |
| 9 | + | // locks. It stops detached timers but never joins their workers. | |
| 10 | + | func (d *Daemon) migratePaneSyncOwnerLocked(p *pane, oldOwner, newOwner paneEffectLease) syncTimerCleanup { | |
| 11 | + | var cleanup syncTimerCleanup | |
| 12 | + | if p == nil || oldOwner.pane != p || newOwner.pane != p || newOwner.owner == nil || p.owner.Load() != newOwner.owner { | |
| 13 | + | return cleanup | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | oldGeneration := p.syncGen | |
| 17 | + | if oldOwner.owner != nil && oldOwner.owner.session != nil { | |
| 18 | + | if coordinator := oldOwner.owner.session.renderCoordinator(); coordinator != nil { | |
| 19 | + | cleanup.append(coordinator.detachSyncBatchGeneration(p, oldGeneration)) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | p.syncGen = 0 | |
| 23 | + | if p.screen == nil || !p.screen.SyncUpdateActive() || newOwner.owner.session == nil || newOwner.owner.tab == nil { | |
| 24 | + | return cleanup | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | generation := newOwner.owner.session.syncGen.Add(1) | |
| 28 | + | p.syncGen = generation | |
| 29 | + | coordinator := newOwner.owner.session.renderCoordinator() | |
| 30 | + | if coordinator == nil { | |
| 31 | + | return cleanup | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | lease := newOwner | |
| 35 | + | renderable := func() bool { | |
| 36 | + | return lease.Current() && d.paneRenderable(lease.owner.session, lease.owner.tab, p) | |
| 37 | + | } | |
| 38 | + | forceEnd := func() { | |
| 39 | + | p.mu.Lock() | |
| 40 | + | if p.owner.Load() == lease.owner && p.syncGen == generation && p.screen.SyncUpdateActive() { | |
| 41 | + | p.screen.ForceSyncEnd() | |
| 42 | + | } | |
| 43 | + | p.mu.Unlock() | |
| 44 | + | } | |
| 45 | + | cleanup.append(coordinator.beginSyncBatchWithRenderability(p, generation, renderable, forceEnd)) | |
| 46 | + | return cleanup | |
| 47 | + | } | |
A internal/usecase/daemon/pane_sync_owner_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type paneSyncOwnerFixture struct { | |
| 15 | + | d *Daemon | |
| 16 | + | pane *pane | |
| 17 | + | source, destination *session | |
| 18 | + | sourceTab, targetTab *tab | |
| 19 | + | sourceCoordinator *renderCoordinator | |
| 20 | + | targetCoordinator *renderCoordinator | |
| 21 | + | sourceClock *coordinatorMockClock | |
| 22 | + | targetClock *coordinatorMockClock | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture { | |
| 26 | + | t.Helper() | |
| 27 | + | size := domain.Size{Cols: 80, Rows: 23} | |
| 28 | + | sourceTab := newTab(newQuietPTY(), size) | |
| 29 | + | targetTab := newTab(newQuietPTY(), size) | |
| 30 | + | source := &session{id: "sync-source", tabs: []*tab{sourceTab}, active: 0, client: &attachedClient{}} | |
| 31 | + | destination := &session{id: "sync-destination", tabs: []*tab{targetTab}, active: 0, client: &attachedClient{}} | |
| 32 | + | d := &Daemon{sessions: map[domain.SessionID]*session{source.id: source, destination.id: destination}} | |
| 33 | + | p := sourceTab.focusedPane() | |
| 34 | + | publishPaneOwner(p, source, sourceTab, 0) | |
| 35 | + | ||
| 36 | + | // Model the membership commit that accompanies owner publication. Keeping | |
| 37 | + | // the source membership is harmless here; callbacks must route by owner. | |
| 38 | + | targetTab.mu.Lock() | |
| 39 | + | targetTab.panes[p.id] = p | |
| 40 | + | targetTab.tree.Root = layout.NewLeaf(p.id) | |
| 41 | + | targetTab.tree.Focus = p.id | |
| 42 | + | targetTab.mu.Unlock() | |
| 43 | + | ||
| 44 | + | sourceClock := newCoordinatorMockClock(t, 8) | |
| 45 | + | targetClock := newCoordinatorMockClock(t, 8) | |
| 46 | + | sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: sourceClock.clock}) | |
| 47 | + | targetCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: targetClock.clock}) | |
| 48 | + | source.installRenderCoordinator(sourceCoordinator) | |
| 49 | + | destination.installRenderCoordinator(targetCoordinator) | |
| 50 | + | return paneSyncOwnerFixture{ | |
| 51 | + | d: d, pane: p, source: source, destination: destination, | |
| 52 | + | sourceTab: sourceTab, targetTab: targetTab, | |
| 53 | + | sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator, | |
| 54 | + | sourceClock: sourceClock, targetClock: targetClock, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (f paneSyncOwnerFixture) armSourceBatch(t *testing.T, active bool, force func()) (*coordinatorMockTimer, paneEffectLease) { | |
| 59 | + | t.Helper() | |
| 60 | + | f.pane.mu.Lock() | |
| 61 | + | if active { | |
| 62 | + | f.pane.screen.Write([]byte(renderer.SyncStartCSI)) | |
| 63 | + | } | |
| 64 | + | generation := f.source.syncGen.Add(1) | |
| 65 | + | f.pane.syncGen = generation | |
| 66 | + | lease := f.pane.effectLeaseLocked() | |
| 67 | + | f.pane.mu.Unlock() | |
| 68 | + | f.sourceCoordinator.noteSyncBeginWithRenderability(f.pane, generation, lease.Current, force) | |
| 69 | + | return awaitCoordinatorScheduledTimer(t, f.sourceClock), lease | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestMigratePaneSynchronizedOutputOwner(t *testing.T) { | |
| 73 | + | t.Run("active batch moves to destination and only destination consumes sync end", func(t *testing.T) { | |
| 74 | + | fixture := newPaneSyncOwnerFixture(t) | |
| 75 | + | forceEntered := make(chan struct{}) | |
| 76 | + | var enterOnce sync.Once | |
| 77 | + | var oldLease paneEffectLease | |
| 78 | + | oldTimer, oldLease := fixture.armSourceBatch(t, true, func() { | |
| 79 | + | enterOnce.Do(func() { close(forceEntered) }) | |
| 80 | + | fixture.pane.mu.Lock() | |
| 81 | + | if fixture.pane.owner.Load() == oldLease.owner && fixture.pane.screen.SyncUpdateActive() { | |
| 82 | + | fixture.pane.screen.ForceSyncEnd() | |
| 83 | + | } | |
| 84 | + | fixture.pane.mu.Unlock() | |
| 85 | + | }) | |
| 86 | + | fixture.sourceCoordinator.mu.Lock() | |
| 87 | + | oldWorkerDone := fixture.sourceCoordinator.syncBatches[fixture.pane].lane.token.done | |
| 88 | + | fixture.sourceCoordinator.mu.Unlock() | |
| 89 | + | ||
| 90 | + | // Select the old watchdog while the pane parsing fence is held. The | |
| 91 | + | // migration must detach it without waiting for its blocked callback. | |
| 92 | + | fixture.pane.mu.Lock() | |
| 93 | + | oldTimer.ch <- time.Time{} | |
| 94 | + | <-forceEntered | |
| 95 | + | oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0) | |
| 96 | + | cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner) | |
| 97 | + | oldTimer.mock.AssertNotCalled(t, "Stop") | |
| 98 | + | fixture.sourceCoordinator.mu.Lock() | |
| 99 | + | require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane) | |
| 100 | + | fixture.sourceCoordinator.mu.Unlock() | |
| 101 | + | fixture.targetCoordinator.mu.Lock() | |
| 102 | + | targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane] | |
| 103 | + | fixture.targetCoordinator.mu.Unlock() | |
| 104 | + | require.NotNil(t, targetBatch) | |
| 105 | + | require.Equal(t, fixture.destination.syncGen.Load(), targetBatch.generation) | |
| 106 | + | fixture.pane.mu.Unlock() | |
| 107 | + | cleanup.finish() | |
| 108 | + | oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| 109 | + | <-oldWorkerDone | |
| 110 | + | ||
| 111 | + | fixture.pane.mu.Lock() | |
| 112 | + | require.True(t, fixture.pane.screen.SyncUpdateActive(), "selected old watchdog must become a stale no-op") | |
| 113 | + | fixture.pane.mu.Unlock() | |
| 114 | + | ||
| 115 | + | fixture.d.processPanePTYData(fixture.pane, []byte(renderer.SyncEndCSI), false) | |
| 116 | + | fixture.sourceCoordinator.mu.Lock() | |
| 117 | + | require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane) | |
| 118 | + | fixture.sourceCoordinator.mu.Unlock() | |
| 119 | + | fixture.targetCoordinator.mu.Lock() | |
| 120 | + | require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane) | |
| 121 | + | fixture.targetCoordinator.mu.Unlock() | |
| 122 | + | fixture.pane.mu.Lock() | |
| 123 | + | require.False(t, fixture.pane.screen.SyncUpdateActive()) | |
| 124 | + | fixture.pane.mu.Unlock() | |
| 125 | + | }) | |
| 126 | + | ||
| 127 | + | t.Run("destination callbacks reject a newer owner generation", func(t *testing.T) { | |
| 128 | + | fixture := newPaneSyncOwnerFixture(t) | |
| 129 | + | _, _ = fixture.armSourceBatch(t, true, nil) | |
| 130 | + | fixture.pane.mu.Lock() | |
| 131 | + | oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0) | |
| 132 | + | cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner) | |
| 133 | + | fixture.pane.mu.Unlock() | |
| 134 | + | cleanup.finish() | |
| 135 | + | _ = awaitCoordinatorScheduledTimer(t, fixture.targetClock) | |
| 136 | + | ||
| 137 | + | fixture.targetCoordinator.mu.Lock() | |
| 138 | + | targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane] | |
| 139 | + | fixture.targetCoordinator.mu.Unlock() | |
| 140 | + | require.NotNil(t, targetBatch) | |
| 141 | + | require.True(t, targetBatch.renderable()) | |
| 142 | + | ||
| 143 | + | fixture.pane.mu.Lock() | |
| 144 | + | fixture.pane.publishOwnerLocked(fixture.source, fixture.sourceTab, 0) | |
| 145 | + | fixture.pane.mu.Unlock() | |
| 146 | + | require.False(t, targetBatch.renderable(), "renderability must validate the destination owner generation") | |
| 147 | + | targetBatch.force() | |
| 148 | + | fixture.pane.mu.Lock() | |
| 149 | + | require.True(t, fixture.pane.screen.SyncUpdateActive(), "force-end must validate the destination owner generation") | |
| 150 | + | fixture.pane.mu.Unlock() | |
| 151 | + | fixture.targetCoordinator.noteSyncPaneRemoved(fixture.pane) | |
| 152 | + | }) | |
| 153 | + | ||
| 154 | + | t.Run("inactive screen removes source batch without creating destination state", func(t *testing.T) { | |
| 155 | + | fixture := newPaneSyncOwnerFixture(t) | |
| 156 | + | oldTimer, _ := fixture.armSourceBatch(t, false, nil) | |
| 157 | + | ||
| 158 | + | fixture.pane.mu.Lock() | |
| 159 | + | oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0) | |
| 160 | + | cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner) | |
| 161 | + | require.Zero(t, fixture.pane.syncGen) | |
| 162 | + | fixture.pane.mu.Unlock() | |
| 163 | + | cleanup.finish() | |
| 164 | + | ||
| 165 | + | fixture.sourceCoordinator.mu.Lock() | |
| 166 | + | require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane) | |
| 167 | + | fixture.sourceCoordinator.mu.Unlock() | |
| 168 | + | fixture.targetCoordinator.mu.Lock() | |
| 169 | + | require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane) | |
| 170 | + | fixture.targetCoordinator.mu.Unlock() | |
| 171 | + | require.Zero(t, fixture.destination.syncGen.Load()) | |
| 172 | + | require.Empty(t, fixture.targetClock.timers) | |
| 173 | + | oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| 174 | + | }) | |
| 175 | + | } | |
M internal/usecase/daemon/picker.go +353 -151
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "sort" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -15,18 +16,35 @@ import ( | |||
| 15 | 16 | ||
| 16 | 17 | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 17 | 18 | ||
| 19 | + | // enterPicker preserves the existing navigation entry point. Navigation always | |
| 20 | + | // publishes its model, including an empty one; only move entry can fail for a | |
| 21 | + | // missing destination. | |
| 18 | 22 | 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) | |
| 25 | 41 | d.registerPreviewForSelection(ac) | |
| 26 | 42 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 27 | 43 | } | |
| 28 | 44 | ||
| 29 | - | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |
| 45 | + | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | |
| 46 | + | // knows nothing about picker intent; picker.New owns all destination policy. | |
| 47 | + | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) { | |
| 30 | 48 | d.mu.Lock() | |
| 31 | 49 | sessions := d.sessionsSnapshotLocked() | |
| 32 | 50 | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| @@ -45,33 +63,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 45 | 63 | ||
| 46 | 64 | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |
| 47 | 65 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |
| 48 | - | curTab := 0 | |
| 66 | + | var current picker.SourceFilter | |
| 49 | 67 | for _, s := range sessions { | |
| 50 | 68 | s.mu.Lock() | |
| 51 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 69 | + | view := picker.SessionView{ | |
| 70 | + | ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active, | |
| 71 | + | Tabs: make([]picker.TabEntry, 0, len(s.tabs)), | |
| 72 | + | } | |
| 52 | 73 | if !s.ephemeral { | |
| 53 | 74 | createdAt := s.createdAt | |
| 54 | - | view.TargetName = s.name | |
| 55 | 75 | view.ExpectedCreatedAt = &createdAt | |
| 56 | 76 | } | |
| 57 | 77 | sessionAttention := false | |
| 58 | 78 | for i, tb := range s.tabs { | |
| 59 | 79 | name := tabDisplayName(tb, i) | |
| 60 | - | view.Tabs[i] = picker.TabEntry{ | |
| 80 | + | tabID := domain.TabStableID(tb.stableID) | |
| 81 | + | view.Tabs = append(view.Tabs, picker.TabEntry{ | |
| 82 | + | TabID: tabID, | |
| 61 | 83 | Name: name, | |
| 62 | 84 | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | |
| 63 | 85 | Attention: tb.attention, | |
| 64 | - | } | |
| 86 | + | }) | |
| 65 | 87 | if tb.attention { | |
| 66 | 88 | sessionAttention = true | |
| 67 | 89 | } | |
| 90 | + | if s == cur && i == s.active { | |
| 91 | + | current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID} | |
| 92 | + | } | |
| 68 | 93 | } | |
| 69 | 94 | if sessionAttention { | |
| 70 | 95 | view.Name = attentionSuffix(view.Name) | |
| 71 | 96 | } | |
| 72 | - | if s == cur { | |
| 73 | - | curTab = s.active | |
| 74 | - | } | |
| 75 | 97 | s.mu.Unlock() | |
| 76 | 98 | views = append(views, view) | |
| 77 | 99 | } | |
| @@ -79,6 +101,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 79 | 101 | createdAt := s.createdAt | |
| 80 | 102 | views = append(views, picker.SessionView{ | |
| 81 | 103 | ID: domain.SessionID("stopped:" + s.name), | |
| 104 | + | Incarnation: s.incarnation, | |
| 82 | 105 | Name: s.name, | |
| 83 | 106 | TargetName: s.name, | |
| 84 | 107 | Tabs: []picker.TabEntry{{}}, | |
| @@ -86,7 +109,21 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 86 | 109 | ExpectedCreatedAt: &createdAt, | |
| 87 | 110 | }) | |
| 88 | 111 | } | |
| 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 | + | }) | |
| 90 | 127 | } | |
| 91 | 128 | ||
| 92 | 129 | func attentionSuffix(label string) string { | |
| @@ -106,6 +143,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 106 | 143 | active: func() bool { return rt.picker != nil }, | |
| 107 | 144 | closeLocked: func() { | |
| 108 | 145 | rt.picker = nil | |
| 146 | + | rt.pickerIntent = pickerNavigate | |
| 147 | + | rt.pickerSource = moveSourceLocator{} | |
| 109 | 148 | generation = rt.pickerPreviewGeneration | |
| 110 | 149 | }, | |
| 111 | 150 | afterClose: func() { | |
| @@ -117,7 +156,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 117 | 156 | } | |
| 118 | 157 | } | |
| 119 | 158 | ||
| 120 | - | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |
| 159 | + | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 121 | 160 | sess := ac.currentSession() | |
| 122 | 161 | if sess == nil { | |
| 123 | 162 | return | |
| @@ -142,17 +181,38 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 142 | 181 | }) | |
| 143 | 182 | var target picker.Target | |
| 144 | 183 | var ok bool | |
| 184 | + | var intent pickerIntent | |
| 185 | + | var source moveSourceLocator | |
| 145 | 186 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | |
| 146 | 187 | target, ok = rt.picker.Selected() | |
| 147 | 188 | } | |
| 189 | + | intent, source = rt.pickerIntent, rt.pickerSource | |
| 148 | 190 | rt.pickerMu.Unlock() | |
| 149 | 191 | ||
| 150 | 192 | if result.action == 'x' { | |
| 193 | + | var effect *roleEffectTicket | |
| 194 | + | if len(effects) != 0 { | |
| 195 | + | effect = effects[0] | |
| 196 | + | } | |
| 151 | 197 | if ok { | |
| 152 | - | if err := d.killPickerTarget(target); err != nil { | |
| 198 | + | var err error | |
| 199 | + | if effect != nil { | |
| 200 | + | effect.bindActionEnd(d, "picker-delete") | |
| 201 | + | err = d.killPickerTargetForRole(target, effect.roleToken()) | |
| 202 | + | } else { | |
| 203 | + | err = d.killPickerTarget(target) | |
| 204 | + | } | |
| 205 | + | if err != nil { | |
| 153 | 206 | d.reportError(sess, err) | |
| 154 | 207 | } | |
| 155 | 208 | } | |
| 209 | + | if effect != nil && effect.ended.Load() { | |
| 210 | + | fresh, admitted := ac.beginRoleEffect(effect.token) | |
| 211 | + | if !admitted { | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | defer fresh.End() | |
| 215 | + | } | |
| 156 | 216 | d.refreshPicker(ac) | |
| 157 | 217 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 158 | 218 | return | |
| @@ -160,12 +220,43 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 160 | 220 | if result.changed { | |
| 161 | 221 | d.registerPreviewForSelection(ac) | |
| 162 | 222 | } | |
| 163 | - | if result.exit { | |
| 223 | + | committing := (result.action == '\r' || result.action == '\n') && ok | |
| 224 | + | if result.exit && !committing { | |
| 164 | 225 | d.closePicker(ac) | |
| 165 | 226 | } | |
| 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 { | |
| 168 | 258 | d.reportError(sess, err) | |
| 259 | + | return | |
| 169 | 260 | } | |
| 170 | 261 | return | |
| 171 | 262 | } | |
| @@ -181,6 +272,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 181 | 272 | ac.overlays.pickerMu.Lock() | |
| 182 | 273 | var target picker.Target | |
| 183 | 274 | var ok bool | |
| 275 | + | intent := ac.overlays.pickerIntent | |
| 184 | 276 | if ac.overlays.picker != nil { | |
| 185 | 277 | target, ok = ac.overlays.picker.Selected() | |
| 186 | 278 | } | |
| @@ -194,15 +286,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 194 | 286 | if !ok { | |
| 195 | 287 | return | |
| 196 | 288 | } | |
| 197 | - | next := d.tabByTarget(target) | |
| 198 | - | targetSess := d.sessionByID(target.Session) | |
| 289 | + | targetSess, next := d.previewTarget(target, intent) | |
| 199 | 290 | if next == nil || targetSess == nil { | |
| 200 | 291 | return | |
| 201 | 292 | } | |
| 202 | 293 | ac.overlays.pickerMu.Lock() | |
| 203 | 294 | // Selection may have changed while the target was resolved. | |
| 204 | 295 | 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) | |
| 206 | 297 | if valid { | |
| 207 | 298 | ac.overlays.pickerPreview = next | |
| 208 | 299 | ac.overlays.pickerPreviewSession = targetSess | |
| @@ -314,6 +405,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 314 | 405 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 315 | 406 | ac.overlays.pickerMu.Lock() | |
| 316 | 407 | ac.overlays.picker = nil | |
| 408 | + | ac.overlays.pickerIntent = pickerNavigate | |
| 409 | + | ac.overlays.pickerSource = moveSourceLocator{} | |
| 317 | 410 | ac.overlays.pickerPending = nil | |
| 318 | 411 | ac.overlays.pickerESC.stop() | |
| 319 | 412 | generation := ac.overlays.pickerPreviewGeneration | |
| @@ -321,19 +414,15 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 321 | 414 | d.clearPreviewGeneration(ac, generation) | |
| 322 | 415 | } | |
| 323 | 416 | ||
| 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 | |
| 330 | 421 | } | |
| 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 | |
| 335 | 424 | } | |
| 336 | - | return sess.tabs[target.TabIndex] | |
| 425 | + | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | |
| 337 | 426 | } | |
| 338 | 427 | ||
| 339 | 428 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |
| @@ -347,6 +436,43 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 347 | 436 | // command switching behavior. | |
| 348 | 437 | type sessionHandoffGuard struct { | |
| 349 | 438 | expectedSource *tab | |
| 439 | + | closePicker bool | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // switchActiveTargetForRole hands a frame-bound navigation request to the | |
| 443 | + | // centralized transition. The transition releases admission at its freeze seam | |
| 444 | + | // and revalidates the exact initiating token before changing target focus or | |
| 445 | + | // attachment ownership. | |
| 446 | + | func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error { | |
| 447 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 448 | + | return nil | |
| 449 | + | } | |
| 450 | + | d.mu.Lock() | |
| 451 | + | targetSess := d.sessions[target.Session] | |
| 452 | + | d.mu.Unlock() | |
| 453 | + | if targetSess == nil || targetSess == token.sess { | |
| 454 | + | return nil | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 458 | + | source: token.sess, target: targetSess, next: token.ac, | |
| 459 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 460 | + | expectedTransport: token.transport, sourceToken: &token, action: "jump-attention", | |
| 461 | + | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | |
| 462 | + | }) | |
| 463 | + | if err != nil { | |
| 464 | + | // Losing the exact source role is a benign stale action, not a notice for | |
| 465 | + | // whichever attachment replaced the initiator. | |
| 466 | + | if !token.activeCurrent() { | |
| 467 | + | return nil | |
| 468 | + | } | |
| 469 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 470 | + | } | |
| 471 | + | d.touchMRU(targetSess) | |
| 472 | + | token.ac.recordPreviousSession(token.sess) | |
| 473 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 474 | + | d.firstPaintForTransition(transition.published) | |
| 475 | + | return nil | |
| 350 | 476 | } | |
| 351 | 477 | ||
| 352 | 478 | // switchToTarget resolves named lifecycle targets and commits their transition | |
| @@ -356,15 +482,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 356 | 482 | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{}) | |
| 357 | 483 | } | |
| 358 | 484 | ||
| 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. | |
| 361 | 499 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 500 | + | return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "") | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error { | |
| 362 | 504 | if target.Name != "" { | |
| 363 | 505 | ctx := d.serveCtx | |
| 364 | 506 | if ctx == nil { | |
| 365 | 507 | ctx = context.Background() | |
| 366 | 508 | } | |
| 367 | 509 | if err := d.waitForTargetRestore(ctx, target.Name); err != nil { | |
| 510 | + | if sourceToken != nil && !sourceToken.activeCurrent() { | |
| 511 | + | return errAttachmentTransition | |
| 512 | + | } | |
| 368 | 513 | d.invalidateRender(from, ac, true, "picker.go") | |
| 369 | 514 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 370 | 515 | } | |
| @@ -373,8 +518,7 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target | |||
| 373 | 518 | d.mu.Lock() | |
| 374 | 519 | var ( | |
| 375 | 520 | targetSess *session | |
| 376 | - | old *attachedClient | |
| 377 | - | cleanups []renderLifecycleCleanup | |
| 521 | + | transition attachmentTransitionResult | |
| 378 | 522 | switched bool | |
| 379 | 523 | cause error | |
| 380 | 524 | ) | |
| @@ -382,39 +526,56 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target | |||
| 382 | 526 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 383 | 527 | if ok { | |
| 384 | 528 | if isStopped { | |
| 385 | - | targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 529 | + | targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action) | |
| 386 | 530 | } else { | |
| 387 | 531 | // The snapshot ID may describe the stopped representation. The | |
| 388 | 532 | // locked name/lifecycle resolver chose this active incarnation. | |
| 389 | 533 | resolvedTarget := target | |
| 390 | 534 | resolvedTarget.Session = active.id | |
| 391 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard) | |
| 535 | + | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action) | |
| 392 | 536 | } | |
| 393 | 537 | } | |
| 394 | 538 | } else if active := d.sessions[target.Session]; active != nil { | |
| 395 | - | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard) | |
| 539 | + | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action) | |
| 396 | 540 | } | |
| 397 | 541 | d.mu.Unlock() | |
| 398 | 542 | ||
| 399 | 543 | if !switched { | |
| 544 | + | if sourceToken != nil && !sourceToken.activeCurrent() { | |
| 545 | + | return errAttachmentTransition | |
| 546 | + | } | |
| 400 | 547 | if guard.expectedSource != nil { | |
| 401 | 548 | return errNoNeighbor | |
| 402 | 549 | } | |
| 403 | 550 | d.invalidateRender(from, ac, true, "picker.go") | |
| 404 | 551 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | |
| 405 | 552 | } | |
| 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 | + | } | |
| 412 | 563 | } | |
| 564 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 413 | 565 | 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 | + | } | |
| 416 | 577 | } else { | |
| 417 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 578 | + | d.firstPaintForTransition(transition.published) | |
| 418 | 579 | } | |
| 419 | 580 | return nil | |
| 420 | 581 | } | |
| @@ -439,121 +600,154 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||
| 439 | 600 | return nil, stopped, true, true | |
| 440 | 601 | } | |
| 441 | 602 | ||
| 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) { | |
| 449 | 606 | if d.sessions[target.Session] != targetSess { | |
| 450 | - | return nil, nil, nil, false | |
| 607 | + | return nil, attachmentTransitionResult{}, false | |
| 451 | 608 | } | |
| 452 | 609 | 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 | |
| 457 | 618 | } | |
| 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 | |
| 460 | 628 | } | |
| 461 | 629 | ||
| 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 | |
| 473 | 634 | } | |
| 474 | - | var guardedSource *tab | |
| 475 | 635 | if guard.expectedSource != nil { | |
| 476 | 636 | 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 | |
| 480 | 639 | } | |
| 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 | |
| 488 | 646 | } | |
| 489 | 647 | } | |
| 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() | |
| 498 | 649 | ||
| 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 | + | } | |
| 514 | 676 | d.touchMRU(targetSess) | |
| 515 | 677 | ac.recordPreviousSession(from) | |
| 516 | - | return targetSess, old, cleanups, true | |
| 678 | + | return targetSess, transition, true | |
| 517 | 679 | } | |
| 518 | 680 | ||
| 519 | 681 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 520 | 682 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 521 | 683 | // and stopped record untouched. | |
| 522 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { | |
| 684 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) { | |
| 523 | 685 | if stopped.record.Name != "" && stopped.state == ports.SessionBroken { | |
| 524 | - | return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 686 | + | return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | if sourceToken != nil { | |
| 690 | + | var targetSess *session | |
| 691 | + | d.mu.Unlock() | |
| 692 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 693 | + | source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 694 | + | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 695 | + | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 696 | + | createTargetLocked: func() (*session, error) { | |
| 697 | + | current, ok := d.stopped[target.Name] | |
| 698 | + | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) { | |
| 699 | + | return nil, errAttachmentTransition | |
| 700 | + | } | |
| 701 | + | from.mu.Lock() | |
| 702 | + | cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env) | |
| 703 | + | from.mu.Unlock() | |
| 704 | + | created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, current.tabNames) | |
| 705 | + | targetSess = created | |
| 706 | + | return created, createErr | |
| 707 | + | }, | |
| 708 | + | }) | |
| 709 | + | d.mu.Lock() | |
| 710 | + | if err != nil { | |
| 711 | + | if targetSess != nil { | |
| 712 | + | d.mu.Unlock() | |
| 713 | + | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | |
| 714 | + | d.mu.Lock() | |
| 715 | + | } | |
| 716 | + | return targetSess, attachmentTransitionResult{}, false, err | |
| 717 | + | } | |
| 718 | + | d.touchMRU(targetSess) | |
| 719 | + | ac.recordPreviousSession(from) | |
| 720 | + | return targetSess, transition, true, nil | |
| 525 | 721 | } | |
| 526 | 722 | ||
| 527 | - | // The caller already holds d.mu. Keep handoff ownership atomic with global | |
| 528 | - | // routing so it cannot select an attachment midway between sessions. | |
| 529 | - | d.notices.routingMu.Lock() | |
| 530 | - | defer d.notices.routingMu.Unlock() | |
| 531 | 723 | from.mu.Lock() | |
| 532 | 724 | if from.client != ac { | |
| 533 | 725 | from.mu.Unlock() | |
| 534 | - | return nil, nil, false, nil | |
| 726 | + | return nil, attachmentTransitionResult{}, false, nil | |
| 535 | 727 | } | |
| 536 | 728 | term := from.terminal | |
| 537 | 729 | env := copyEnvironment(from.env) | |
| 730 | + | from.mu.Unlock() | |
| 538 | 731 | cwd := d.dirOrHome(stopped.cwd) | |
| 539 | 732 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | |
| 540 | 733 | if err != nil { | |
| 541 | - | from.mu.Unlock() | |
| 542 | 734 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 543 | - | return nil, nil, false, err | |
| 735 | + | return nil, attachmentTransitionResult{}, false, err | |
| 544 | 736 | } | |
| 545 | - | from.client = nil | |
| 546 | - | ac.setSession(nil) | |
| 547 | - | from.mu.Unlock() | |
| 548 | 737 | ||
| 549 | - | cleanups := d.handoffCoordinator(from, targetSess, nil, ac) | |
| 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 | + | } | |
| 554 | 748 | d.touchMRU(targetSess) | |
| 555 | 749 | ac.recordPreviousSession(from) | |
| 556 | - | return targetSess, cleanups, true, nil | |
| 750 | + | return targetSess, transition, true, nil | |
| 557 | 751 | } | |
| 558 | 752 | ||
| 559 | 753 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| @@ -564,13 +758,13 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) | |||
| 564 | 758 | // targets must use switchToTarget so resolution and commit share d.mu. | |
| 565 | 759 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 566 | 760 | d.mu.Lock() | |
| 567 | - | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}) | |
| 761 | + | _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "") | |
| 568 | 762 | d.mu.Unlock() | |
| 569 | 763 | if !switched { | |
| 570 | 764 | return nil | |
| 571 | 765 | } | |
| 572 | - | finishRenderLifecycleCleanups(cleanups) | |
| 573 | - | return old | |
| 766 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 767 | + | return transition.displaced.ac | |
| 574 | 768 | } | |
| 575 | 769 | ||
| 576 | 770 | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |
| @@ -579,23 +773,45 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 579 | 773 | d.mu.Lock() | |
| 580 | 774 | stopped, ok := d.stopped[target.Name] | |
| 581 | 775 | var ( | |
| 582 | - | targetSess *session | |
| 583 | - | cleanups []renderLifecycleCleanup | |
| 776 | + | transition attachmentTransitionResult | |
| 584 | 777 | switched bool | |
| 585 | 778 | ) | |
| 586 | 779 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 587 | - | targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 780 | + | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | |
| 588 | 781 | } | |
| 589 | 782 | d.mu.Unlock() | |
| 590 | 783 | if !switched { | |
| 591 | 784 | d.invalidateRender(from, ac, true, "picker.go") | |
| 592 | 785 | return false | |
| 593 | 786 | } | |
| 594 | - | finishRenderLifecycleCleanups(cleanups) | |
| 595 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 787 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 788 | + | d.firstPaintForTransition(transition.published) | |
| 596 | 789 | return true | |
| 597 | 790 | } | |
| 598 | 791 | ||
| 792 | + | func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error { | |
| 793 | + | if token.ac == nil { | |
| 794 | + | return d.killPickerTarget(target) | |
| 795 | + | } | |
| 796 | + | if target.Stopped { | |
| 797 | + | token.effect.bindActionEnd(d, "picker-delete") | |
| 798 | + | token.effect.End() | |
| 799 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 800 | + | defer frozen.unfreeze() | |
| 801 | + | if !d.sourceRoleTokenCurrentFrozen(token) { | |
| 802 | + | return nil | |
| 803 | + | } | |
| 804 | + | return d.killPickerTarget(target) | |
| 805 | + | } | |
| 806 | + | d.mu.Lock() | |
| 807 | + | targetSess := d.sessions[target.Session] | |
| 808 | + | d.mu.Unlock() | |
| 809 | + | if targetSess == nil { | |
| 810 | + | return nil | |
| 811 | + | } | |
| 812 | + | return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | |
| 813 | + | } | |
| 814 | + | ||
| 599 | 815 | func (d *Daemon) killPickerTarget(target picker.Target) error { | |
| 600 | 816 | if target.Stopped { | |
| 601 | 817 | if err := d.retryStoppedPurge(target.Name); err != nil { | |
| @@ -613,20 +829,6 @@ func (d *Daemon) killPickerTarget(target picker.Target) error { | |||
| 613 | 829 | return nil | |
| 614 | 830 | } | |
| 615 | 831 | ||
| 616 | - | func (d *Daemon) refreshPicker(ac *attachedClient) { | |
| 617 | - | sess := ac.currentSession() | |
| 618 | - | if sess == nil { | |
| 619 | - | return | |
| 620 | - | } | |
| 621 | - | views, curTab := d.pickerViews(sess) | |
| 622 | - | ac.overlays.pickerMu.Lock() | |
| 623 | - | if ac.overlays.picker != nil { | |
| 624 | - | ac.overlays.picker = picker.New(views, sess.id, curTab) | |
| 625 | - | } | |
| 626 | - | ac.overlays.pickerMu.Unlock() | |
| 627 | - | d.registerPreviewForSelection(ac) | |
| 628 | - | } | |
| 629 | - | ||
| 630 | 832 | func snapshotPickerPreview(tb *tab) picker.Preview { | |
| 631 | 833 | if tb == nil { | |
| 632 | 834 | return picker.Preview{} | |
M internal/usecase/daemon/picker_test.go +455 -24
| @@ -23,6 +23,11 @@ import ( | |||
| 23 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | + | var ( | |
| 27 | + | _ domain.TabStableID = moveSourceLocator{}.TabID | |
| 28 | + | _ domain.PaneStableID = moveSourceLocator{}.PaneID | |
| 29 | + | ) | |
| 30 | + | ||
| 26 | 31 | // --- test doubles ----------------------------------------------------------- | |
| 27 | 32 | ||
| 28 | 33 | type refusingSnapshotDeleteRepository struct { | |
| @@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 74 | 79 | d.sessions[current.id] = current | |
| 75 | 80 | d.sessions[ringing.id] = ringing | |
| 76 | 81 | ||
| 77 | - | views, curTab := d.pickerViews(current) | |
| 82 | + | views, currentSelection := d.pickerViews(current) | |
| 78 | 83 | ||
| 79 | - | require.Equal(t, 0, curTab) | |
| 84 | + | require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection) | |
| 80 | 85 | require.Len(t, views, 2) | |
| 81 | 86 | require.Equal(t, "alpha", views[0].Name) | |
| 82 | 87 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | |
| 83 | 88 | require.Equal(t, "beta ", views[1].Name) | |
| 84 | 89 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | |
| 85 | 90 | ||
| 86 | - | model := picker.New(views, current.id, 0) | |
| 91 | + | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}}) | |
| 87 | 92 | model.Down() | |
| 88 | 93 | model.Down() | |
| 89 | 94 | target, ok := model.Selected() | |
| @@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 130 | 135 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |
| 131 | 136 | } | |
| 132 | 137 | ||
| 138 | + | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |
| 139 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 140 | + | source := &session{ | |
| 141 | + | id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0, | |
| 142 | + | tabs: []*tab{ | |
| 143 | + | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | |
| 144 | + | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | |
| 145 | + | }, | |
| 146 | + | } | |
| 147 | + | destination := &session{ | |
| 148 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 149 | + | tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}, | |
| 150 | + | } | |
| 151 | + | d.sessions[source.id] = source | |
| 152 | + | d.sessions[destination.id] = destination | |
| 153 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | |
| 154 | + | sourceLocator := moveSourceLocator{ | |
| 155 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 156 | + | TabID: "source-tab", | |
| 157 | + | PaneID: "source-pane", | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | views, current := d.pickerViews(source) | |
| 161 | + | require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles") | |
| 162 | + | require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current) | |
| 163 | + | var sourceView picker.SessionView | |
| 164 | + | for _, view := range views { | |
| 165 | + | if view.ID == source.id { | |
| 166 | + | sourceView = view | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs, | |
| 170 | + | "daemon capture does not remove the move source") | |
| 171 | + | ||
| 172 | + | paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{ | |
| 173 | + | Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab", | |
| 174 | + | }) | |
| 175 | + | paneTarget, ok := paneModel.Selected() | |
| 176 | + | require.True(t, ok) | |
| 177 | + | require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID) | |
| 178 | + | ||
| 179 | + | tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{}) | |
| 180 | + | tabTarget, ok := tabModel.Selected() | |
| 181 | + | require.True(t, ok) | |
| 182 | + | require.Equal(t, destination.id, tabTarget.Session) | |
| 183 | + | require.Equal(t, destination.incarnation, tabTarget.Incarnation) | |
| 184 | + | require.Equal(t, destination.name, tabTarget.Name) | |
| 185 | + | require.Equal(t, -1, tabTarget.TabIndex) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) { | |
| 189 | + | d, sess, ac, _, releases := newManualTabSession(t, 1) | |
| 190 | + | defer releases[0]() | |
| 191 | + | sess.mu.Lock() | |
| 192 | + | sess.incarnation = domain.IncarnationID{1} | |
| 193 | + | tb := sess.tabs[0] | |
| 194 | + | sess.mu.Unlock() | |
| 195 | + | tb.mu.Lock() | |
| 196 | + | tabID := tb.stableID | |
| 197 | + | paneID := tb.focusedPane().stableID | |
| 198 | + | tb.mu.Unlock() | |
| 199 | + | source := moveSourceLocator{ | |
| 200 | + | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |
| 201 | + | TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID), | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} { | |
| 205 | + | err := d.enterPickerForIntent(sess, ac, intent, source) | |
| 206 | + | ||
| 207 | + | require.ErrorIs(t, err, errNoMoveDestination) | |
| 208 | + | require.False(t, ac.overlays.pickerActive()) | |
| 209 | + | ac.overlays.pickerMu.Lock() | |
| 210 | + | require.Nil(t, ac.overlays.pickerPreview) | |
| 211 | + | require.Nil(t, ac.overlays.pickerPreviewSession) | |
| 212 | + | require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation") | |
| 213 | + | ac.overlays.pickerMu.Unlock() | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) { | |
| 218 | + | p1, release1 := newBlockingPTY(t) | |
| 219 | + | p2, release2 := newBlockingPTY(t) | |
| 220 | + | defer release1() | |
| 221 | + | defer release2() | |
| 222 | + | d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 223 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1} | |
| 224 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 225 | + | d.sessions[sourceSession.id] = sourceSession | |
| 226 | + | targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel) | |
| 227 | + | targetTab.stableID = "destination-tab" | |
| 228 | + | targetSession := &session{ | |
| 229 | + | id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 230 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}, | |
| 231 | + | } | |
| 232 | + | d.sessions[targetSession.id] = targetSession | |
| 233 | + | sourceTab := sourceSession.tabs[0] | |
| 234 | + | sourceTab.mu.Lock() | |
| 235 | + | sourceTab.stableID = "source-tab" | |
| 236 | + | sourcePaneID := sourceTab.focusedPane().stableID | |
| 237 | + | sourceTab.mu.Unlock() | |
| 238 | + | source := moveSourceLocator{ | |
| 239 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 240 | + | TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID), | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source)) | |
| 244 | + | ac.overlays.pickerMu.Lock() | |
| 245 | + | storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 246 | + | selected, ok := ac.overlays.picker.Selected() | |
| 247 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 248 | + | require.Same(t, targetTab, ac.overlays.pickerPreview) | |
| 249 | + | require.Same(t, targetSession, ac.overlays.pickerPreviewSession) | |
| 250 | + | ac.overlays.pickerMu.Unlock() | |
| 251 | + | require.Equal(t, pickerMovePane, storedIntent) | |
| 252 | + | require.Equal(t, source, storedSource) | |
| 253 | + | require.True(t, ok) | |
| 254 | + | require.Equal(t, targetSession.id, selected.Session) | |
| 255 | + | require.Equal(t, targetSession.incarnation, selected.Incarnation) | |
| 256 | + | require.Equal(t, targetSession.name, selected.Name) | |
| 257 | + | require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID) | |
| 258 | + | require.Equal(t, 0, selected.TabIndex) | |
| 259 | + | require.Nil(t, selected.ExpectedCreatedAt) | |
| 260 | + | rc := targetSession.renderCoordinator() | |
| 261 | + | require.NotNil(t, rc) | |
| 262 | + | rc.mu.Lock() | |
| 263 | + | subscription, subscribed := rc.previewWakes[ac] | |
| 264 | + | rc.mu.Unlock() | |
| 265 | + | require.True(t, subscribed) | |
| 266 | + | require.Equal(t, generation, subscription.generation) | |
| 267 | + | ||
| 268 | + | d.enterPicker(sourceSession, ac) | |
| 269 | + | ||
| 270 | + | rc.mu.Lock() | |
| 271 | + | _, subscribed = rc.previewWakes[ac] | |
| 272 | + | rc.mu.Unlock() | |
| 273 | + | require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription") | |
| 274 | + | ac.overlays.pickerMu.Lock() | |
| 275 | + | require.NotNil(t, ac.overlays.picker) | |
| 276 | + | require.Equal(t, pickerNavigate, ac.overlays.pickerIntent) | |
| 277 | + | require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource) | |
| 278 | + | ac.overlays.pickerMu.Unlock() | |
| 279 | + | ||
| 280 | + | d.closePicker(ac) | |
| 281 | + | ac.overlays.pickerMu.Lock() | |
| 282 | + | require.Nil(t, ac.overlays.picker) | |
| 283 | + | require.Nil(t, ac.overlays.pickerPreview) | |
| 284 | + | ac.overlays.pickerMu.Unlock() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) { | |
| 288 | + | p1, release1 := newBlockingPTY(t) | |
| 289 | + | p2, release2 := newBlockingPTY(t) | |
| 290 | + | p3, release3 := newBlockingPTY(t) | |
| 291 | + | defer release1() | |
| 292 | + | defer release2() | |
| 293 | + | defer release3() | |
| 294 | + | d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 295 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 296 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 297 | + | d.sessions[sourceSession.id] = sourceSession | |
| 298 | + | target := &session{ | |
| 299 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1, | |
| 300 | + | ctx: sourceSession.ctx, cancel: func() {}, | |
| 301 | + | tabs: []*tab{ | |
| 302 | + | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | |
| 303 | + | newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel), | |
| 304 | + | }, | |
| 305 | + | } | |
| 306 | + | target.tabs[0].stableID, target.tabs[1].stableID = "first", "active" | |
| 307 | + | d.sessions[target.id] = target | |
| 308 | + | source := moveSourceLocator{ | |
| 309 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 310 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source)) | |
| 314 | + | ||
| 315 | + | ac.overlays.pickerMu.Lock() | |
| 316 | + | selected, ok := ac.overlays.picker.Selected() | |
| 317 | + | require.Same(t, target.tabs[1], ac.overlays.pickerPreview) | |
| 318 | + | ac.overlays.pickerMu.Unlock() | |
| 319 | + | require.True(t, ok) | |
| 320 | + | require.Equal(t, target.id, selected.Session) | |
| 321 | + | require.Equal(t, target.incarnation, selected.Incarnation) | |
| 322 | + | require.Equal(t, target.name, selected.Name) | |
| 323 | + | require.Equal(t, -1, selected.TabIndex) | |
| 324 | + | require.Nil(t, selected.ExpectedCreatedAt) | |
| 325 | + | require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus") | |
| 326 | + | d.closePicker(ac) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |
| 330 | + | t.Run("pane destination stable tab", func(t *testing.T) { | |
| 331 | + | d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1) | |
| 332 | + | defer releaseAll(sourceReleases) | |
| 333 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 334 | + | sourceSession.tabs[0].stableID = "source-tab" | |
| 335 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 336 | + | d.sessions[sourceSession.id] = sourceSession | |
| 337 | + | ||
| 338 | + | firstPTY, releaseFirst := newBlockingPTY(t) | |
| 339 | + | defer releaseFirst() | |
| 340 | + | secondPTY, releaseSecond := newBlockingPTY(t) | |
| 341 | + | defer releaseSecond() | |
| 342 | + | firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel) | |
| 343 | + | secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel) | |
| 344 | + | firstTab.stableID, secondTab.stableID = "first", "selected" | |
| 345 | + | destination := &session{ | |
| 346 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 347 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}, | |
| 348 | + | } | |
| 349 | + | d.sessions[destination.id] = destination | |
| 350 | + | source := moveSourceLocator{ | |
| 351 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 352 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source)) | |
| 356 | + | ac.overlays.pickerMu.Lock() | |
| 357 | + | ac.overlays.picker.Down() | |
| 358 | + | before, ok := ac.overlays.picker.Selected() | |
| 359 | + | ac.overlays.pickerMu.Unlock() | |
| 360 | + | require.True(t, ok) | |
| 361 | + | require.Equal(t, domain.TabStableID("selected"), before.TabID) | |
| 362 | + | ||
| 363 | + | destination.mu.Lock() | |
| 364 | + | destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0] | |
| 365 | + | destination.mu.Unlock() | |
| 366 | + | d.refreshPicker(ac) | |
| 367 | + | ||
| 368 | + | ac.overlays.pickerMu.Lock() | |
| 369 | + | after, ok := ac.overlays.picker.Selected() | |
| 370 | + | ac.overlays.pickerMu.Unlock() | |
| 371 | + | require.True(t, ok) | |
| 372 | + | require.Equal(t, before.Session, after.Session) | |
| 373 | + | require.Equal(t, before.Incarnation, after.Incarnation) | |
| 374 | + | require.Equal(t, before.TabID, after.TabID) | |
| 375 | + | require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab") | |
| 376 | + | d.closePicker(ac) | |
| 377 | + | }) | |
| 378 | + | ||
| 379 | + | t.Run("tab destination session", func(t *testing.T) { | |
| 380 | + | d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1) | |
| 381 | + | defer releaseAll(sourceReleases) | |
| 382 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 383 | + | sourceSession.tabs[0].stableID = "source-tab" | |
| 384 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 385 | + | d.sessions[sourceSession.id] = sourceSession | |
| 386 | + | ||
| 387 | + | releases := make([]func(), 0, 2) | |
| 388 | + | for i, destination := range []struct { | |
| 389 | + | id domain.SessionID | |
| 390 | + | name string | |
| 391 | + | }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} { | |
| 392 | + | pty, release := newBlockingPTY(t) | |
| 393 | + | releases = append(releases, release) | |
| 394 | + | tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel) | |
| 395 | + | tb.stableID = string(destination.id) + "-tab" | |
| 396 | + | d.sessions[destination.id] = &session{ | |
| 397 | + | id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true, | |
| 398 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}, | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | defer releaseAll(releases) | |
| 402 | + | source := moveSourceLocator{ | |
| 403 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 404 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source)) | |
| 408 | + | ac.overlays.pickerMu.Lock() | |
| 409 | + | ac.overlays.picker.Down() | |
| 410 | + | before, ok := ac.overlays.picker.Selected() | |
| 411 | + | ac.overlays.pickerMu.Unlock() | |
| 412 | + | require.True(t, ok) | |
| 413 | + | require.Equal(t, domain.SessionID("selected"), before.Session) | |
| 414 | + | ||
| 415 | + | d.refreshPicker(ac) | |
| 416 | + | ||
| 417 | + | ac.overlays.pickerMu.Lock() | |
| 418 | + | after, ok := ac.overlays.picker.Selected() | |
| 419 | + | ac.overlays.pickerMu.Unlock() | |
| 420 | + | require.True(t, ok) | |
| 421 | + | require.Equal(t, before, after) | |
| 422 | + | d.closePicker(ac) | |
| 423 | + | }) | |
| 424 | + | } | |
| 425 | + | ||
| 133 | 426 | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |
| 134 | 427 | p, release := newBlockingPTY(t) | |
| 135 | 428 | defer release() | |
| @@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 147 | 440 | break | |
| 148 | 441 | } | |
| 149 | 442 | } | |
| 150 | - | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | |
| 443 | + | model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}}) | |
| 151 | 444 | target, ok := model.Selected() | |
| 152 | 445 | require.True(t, ok) | |
| 153 | 446 | ||
| @@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||
| 193 | 486 | views, _ := d.pickerViews(sess) | |
| 194 | 487 | ||
| 195 | 488 | require.Len(t, views, 1) | |
| 196 | - | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs) | |
| 489 | + | require.Equal(t, []picker.TabEntry{ | |
| 490 | + | {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"}, | |
| 491 | + | {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true}, | |
| 492 | + | }, views[0].Tabs) | |
| 197 | 493 | } | |
| 198 | 494 | ||
| 199 | 495 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |
| @@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |||
| 217 | 513 | views, _ := d.pickerViews(sess) | |
| 218 | 514 | ||
| 219 | 515 | require.Len(t, views, 1) | |
| 220 | - | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs) | |
| 516 | + | require.Equal(t, []picker.TabEntry{ | |
| 517 | + | {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"}, | |
| 518 | + | {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"}, | |
| 519 | + | }, views[0].Tabs) | |
| 221 | 520 | } | |
| 222 | 521 | ||
| 223 | 522 | func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |
| @@ -505,7 +804,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 505 | 804 | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |
| 506 | 805 | } | |
| 507 | 806 | ||
| 508 | - | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| 807 | + | func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |
| 509 | 808 | p1, releasePTY1 := newBlockingPTY(t) | |
| 510 | 809 | p2, releasePTY2 := newBlockingPTY(t) | |
| 511 | 810 | defer releasePTY1() | |
| @@ -540,11 +839,24 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 540 | 839 | require.Same(t, ac1, sess2.client) | |
| 541 | 840 | require.Nil(t, sess1.client) | |
| 542 | 841 | 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) | |
| 545 | 851 | 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") | |
| 548 | 860 | } | |
| 549 | 861 | ||
| 550 | 862 | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |
| @@ -623,7 +935,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 623 | 935 | require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage") | |
| 624 | 936 | } | |
| 625 | 937 | ||
| 626 | - | func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |
| 938 | + | func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) { | |
| 627 | 939 | p1, releasePTY1 := newBlockingPTY(t) | |
| 628 | 940 | p2, releasePTY2 := newBlockingPTY(t) | |
| 629 | 941 | defer releasePTY1() | |
| @@ -662,25 +974,41 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 662 | 974 | }() | |
| 663 | 975 | <-enteredSend | |
| 664 | 976 | ||
| 665 | - | switchDone := make(chan struct{}) | |
| 977 | + | switchDone := make(chan attachmentTransitionResult, 1) | |
| 978 | + | switchErr := make(chan error, 1) | |
| 666 | 979 | 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 | |
| 670 | 991 | }() | |
| 671 | - | awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary") | |
| 672 | 992 | ||
| 673 | - | // The handoff is immediately about to acquire sendMu, which paint still 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()) | |
| 675 | 1001 | 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") | |
| 678 | 1004 | default: | |
| 679 | 1005 | } | |
| 680 | 1006 | ||
| 681 | 1007 | close(releaseSend) | |
| 682 | 1008 | awaitTestCompletion(t, paintDone, "paint did not finish after Send was released") | |
| 683 | - | awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished") | |
| 1009 | + | for _, cleanup := range result.cleanups { | |
| 1010 | + | cleanup.finish() | |
| 1011 | + | } | |
| 684 | 1012 | } | |
| 685 | 1013 | ||
| 686 | 1014 | func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |
| @@ -850,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||
| 850 | 1178 | pickerModel: picker.New([]picker.SessionView{ | |
| 851 | 1179 | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | |
| 852 | 1180 | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 853 | - | }, "selected", 0), | |
| 1181 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}), | |
| 854 | 1182 | } | |
| 855 | 1183 | ||
| 856 | 1184 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| @@ -879,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||
| 879 | 1207 | } | |
| 880 | 1208 | ||
| 881 | 1209 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |
| 882 | - | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 1210 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 883 | 1211 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | |
| 884 | 1212 | cases := []struct { | |
| 885 | 1213 | name string | |
| @@ -1131,6 +1459,92 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||
| 1131 | 1459 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | |
| 1132 | 1460 | } | |
| 1133 | 1461 | ||
| 1462 | + | func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) { | |
| 1463 | + | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |
| 1464 | + | defer releaseAll(currentReleases) | |
| 1465 | + | current.id, current.name, current.active = "current", "z-current", 1 | |
| 1466 | + | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |
| 1467 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 1468 | + | d.sessions[current.id] = current | |
| 1469 | + | ||
| 1470 | + | otherPTY, releaseOther := newBlockingPTY(t) | |
| 1471 | + | defer releaseOther() | |
| 1472 | + | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |
| 1473 | + | otherTab.stableID = "other-tab" | |
| 1474 | + | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1475 | + | d.sessions[other.id] = other | |
| 1476 | + | ||
| 1477 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1478 | + | defer releaseTarget() | |
| 1479 | + | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1480 | + | targetTab.stableID = "target-tab" | |
| 1481 | + | target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1482 | + | d.sessions[target.id] = target | |
| 1483 | + | ||
| 1484 | + | d.enterPicker(current, ac) | |
| 1485 | + | d.handlePickerInput(ac, []byte("k")) | |
| 1486 | + | d.handlePickerInput(ac, []byte("k")) | |
| 1487 | + | ac.overlays.pickerMu.Lock() | |
| 1488 | + | selected, ok := ac.overlays.picker.Selected() | |
| 1489 | + | ac.overlays.pickerMu.Unlock() | |
| 1490 | + | require.True(t, ok) | |
| 1491 | + | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | |
| 1492 | + | ||
| 1493 | + | d.handlePickerInput(ac, []byte("x")) | |
| 1494 | + | ||
| 1495 | + | d.mu.Lock() | |
| 1496 | + | _, targetStillLive := d.sessions[target.id] | |
| 1497 | + | d.mu.Unlock() | |
| 1498 | + | require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session") | |
| 1499 | + | ||
| 1500 | + | ac.overlays.pickerMu.Lock() | |
| 1501 | + | selected, ok = ac.overlays.picker.Selected() | |
| 1502 | + | ac.overlays.pickerMu.Unlock() | |
| 1503 | + | require.True(t, ok) | |
| 1504 | + | require.Equal(t, current.id, selected.Session) | |
| 1505 | + | require.Equal(t, domain.TabStableID("current-active"), selected.TabID) | |
| 1506 | + | require.Equal(t, 1, selected.TabIndex) | |
| 1507 | + | } | |
| 1508 | + | ||
| 1509 | + | func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) { | |
| 1510 | + | d, current, ac, _, currentReleases := newManualTabSession(t, 1) | |
| 1511 | + | defer releaseAll(currentReleases) | |
| 1512 | + | current.id, current.name = "current", "z-current" | |
| 1513 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 1514 | + | d.sessions[current.id] = current | |
| 1515 | + | ||
| 1516 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1517 | + | defer releaseTarget() | |
| 1518 | + | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1519 | + | target := &session{id: "target", name: "a-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1520 | + | d.sessions[target.id] = target | |
| 1521 | + | current.mu.Lock() | |
| 1522 | + | current.client = ac | |
| 1523 | + | current.mu.Unlock() | |
| 1524 | + | require.NotNil(t, d.attachCoordinator(current, nil, ac, true)) | |
| 1525 | + | ||
| 1526 | + | d.enterPicker(current, ac) | |
| 1527 | + | d.handlePickerInput(ac, []byte("k")) | |
| 1528 | + | ac.overlays.pickerMu.Lock() | |
| 1529 | + | selected, ok := ac.overlays.picker.Selected() | |
| 1530 | + | ac.overlays.pickerMu.Unlock() | |
| 1531 | + | require.True(t, ok) | |
| 1532 | + | require.Equal(t, target.id, selected.Session) | |
| 1533 | + | ||
| 1534 | + | token := current.attachmentToken(ac, ac.transport()) | |
| 1535 | + | token.lease = current.renderCoordinator().attachmentLease(ac) | |
| 1536 | + | ac.publishRoleCapability(token) | |
| 1537 | + | effect, admitted := ac.beginRoleEffect(token) | |
| 1538 | + | require.True(t, admitted) | |
| 1539 | + | d.handlePickerInput(ac, []byte("x"), effect) | |
| 1540 | + | ||
| 1541 | + | d.mu.Lock() | |
| 1542 | + | _, targetStillLive := d.sessions[target.id] | |
| 1543 | + | d.mu.Unlock() | |
| 1544 | + | require.False(t, targetStillLive, "effect-backed picker deletion must remove the selected session") | |
| 1545 | + | require.True(t, ac.overlays.pickerActive(), "successful deletion refreshes rather than closes the picker") | |
| 1546 | + | } | |
| 1547 | + | ||
| 1134 | 1548 | func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) { | |
| 1135 | 1549 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 1136 | 1550 | defer releaseAll(releases) | |
| @@ -1177,6 +1591,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1177 | 1591 | p, release := newBlockingPTY(t) | |
| 1178 | 1592 | defer release() | |
| 1179 | 1593 | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1594 | + | otherPTY, releaseOther := newBlockingPTY(t) | |
| 1595 | + | defer releaseOther() | |
| 1596 | + | otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel) | |
| 1597 | + | otherTab.stableID = "other-tab" | |
| 1598 | + | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1599 | + | d.sessions[other.id] = other | |
| 1180 | 1600 | cause := errors.New("delete failed") | |
| 1181 | 1601 | store, state := newMockStore(t) | |
| 1182 | 1602 | WithStore(t, store)(d) | |
| @@ -1207,4 +1627,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1207 | 1627 | d.mu.Unlock() | |
| 1208 | 1628 | require.True(t, retained, "failed deletion must retain the reserved name") | |
| 1209 | 1629 | require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore") | |
| 1630 | + | ||
| 1631 | + | from.mu.Lock() | |
| 1632 | + | activeTab := from.tabs[from.active] | |
| 1633 | + | activeTabID := activeTab.stableID | |
| 1634 | + | from.mu.Unlock() | |
| 1635 | + | ac.overlays.pickerMu.Lock() | |
| 1636 | + | selected, selectedOK := ac.overlays.picker.Selected() | |
| 1637 | + | ac.overlays.pickerMu.Unlock() | |
| 1638 | + | require.True(t, selectedOK) | |
| 1639 | + | require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session") | |
| 1640 | + | require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab") | |
| 1210 | 1641 | } | |
M internal/usecase/daemon/prompt.go +29 -3
| @@ -23,10 +23,21 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s | |||
| 23 | 23 | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) { | |
| 27 | + | d.closePrompt(ac) | |
| 28 | + | ac.overlays.promptMu.Lock() | |
| 29 | + | ac.overlays.prompt = promptui.New(title, initial) | |
| 30 | + | ac.overlays.promptTransitionSubmit = submit | |
| 31 | + | ac.overlays.promptPending = nil | |
| 32 | + | ac.overlays.promptMu.Unlock() | |
| 33 | + | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 34 | + | } | |
| 35 | + | ||
| 26 | 36 | func (d *Daemon) closePrompt(ac *attachedClient) { | |
| 27 | 37 | ac.overlays.promptMu.Lock() | |
| 28 | 38 | ac.overlays.prompt = nil | |
| 29 | 39 | ac.overlays.promptSubmit = nil | |
| 40 | + | ac.overlays.promptTransitionSubmit = nil | |
| 30 | 41 | ac.overlays.promptPending = nil | |
| 31 | 42 | ac.overlays.promptMu.Unlock() | |
| 32 | 43 | } | |
| @@ -39,7 +50,7 @@ func promptValidationError(err error) bool { | |||
| 39 | 50 | errors.Is(err, errSessionNameInUse) | |
| 40 | 51 | } | |
| 41 | 52 | ||
| 42 | - | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |
| 53 | + | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 43 | 54 | sess := ac.currentSession() | |
| 44 | 55 | if sess == nil { | |
| 45 | 56 | return | |
| @@ -55,6 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 55 | 66 | changed := false | |
| 56 | 67 | exit := false | |
| 57 | 68 | var submit func(string) error | |
| 69 | + | var transitionSubmit func(string, attachmentRoleToken) error | |
| 58 | 70 | var submittedPrompt *promptui.Model | |
| 59 | 71 | var value string | |
| 60 | 72 | ||
| @@ -69,6 +81,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 69 | 81 | }, | |
| 70 | 82 | enter: func() { | |
| 71 | 83 | submit = ac.overlays.promptSubmit | |
| 84 | + | transitionSubmit = ac.overlays.promptTransitionSubmit | |
| 72 | 85 | submittedPrompt = ac.overlays.prompt | |
| 73 | 86 | value = strings.TrimSpace(ac.overlays.prompt.Value()) | |
| 74 | 87 | }, | |
| @@ -78,8 +91,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 78 | 91 | }) | |
| 79 | 92 | ac.overlays.promptMu.Unlock() | |
| 80 | 93 | ||
| 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 { | |
| 83 | 109 | ac.overlays.promptMu.Lock() | |
| 84 | 110 | if ac.overlays.prompt == submittedPrompt { | |
| 85 | 111 | ac.overlays.prompt.SetError(err.Error()) | |
M internal/usecase/daemon/render.go +123 -60
| @@ -82,7 +82,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||
| 82 | 82 | return false | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | // ptyReader is retained as a compatibility entry point for focused tests. The | |
| 86 | + | // production goroutine calls readPanePTY, whose closure owns only the pane. | |
| 85 | 87 | func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |
| 88 | + | if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil { | |
| 89 | + | publishPaneOwner(p, sess, tb, 0) | |
| 90 | + | } | |
| 91 | + | d.readPanePTY(p) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (d *Daemon) readPanePTY(p *pane) { | |
| 86 | 95 | defer d.sessWg.Done() | |
| 87 | 96 | if p == nil { | |
| 88 | 97 | return | |
| @@ -98,103 +107,146 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 98 | 107 | for { | |
| 99 | 108 | n, err := p.pty.Read(buf) | |
| 100 | 109 | if n > 0 { | |
| 101 | - | d.processPTYData(sess, tb, p, buf[:n], true) | |
| 110 | + | d.processPanePTYData(p, buf[:n], true) | |
| 102 | 111 | } | |
| 103 | 112 | 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 | + | } | |
| 108 | 119 | if p.onExit != nil { | |
| 109 | 120 | p.onExit() | |
| 110 | 121 | } else { | |
| 111 | - | d.reapPane(sess, tb, p) | |
| 122 | + | d.reapPaneOwner(p) | |
| 112 | 123 | } | |
| 113 | 124 | return | |
| 114 | 125 | } | |
| 115 | 126 | } | |
| 116 | 127 | } | |
| 117 | 128 | ||
| 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 | |
| 119 | 149 | // pane, reader calls append exact bytes and returns immediately; it never waits | |
| 120 | 150 | // 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 | + | } | |
| 123 | 155 | p.mu.Lock() | |
| 124 | 156 | if bufferDuringApply && p.resizeApplying { | |
| 125 | 157 | p.resizePending = append(p.resizePending, data...) | |
| 126 | 158 | p.mu.Unlock() | |
| 127 | 159 | return | |
| 128 | 160 | } | |
| 129 | - | wasSyncing := p.screen.SyncUpdateActive() | |
| 161 | + | effects := panePTYEffects{lease: p.effectLeaseLocked()} | |
| 162 | + | owner := effects.lease.owner | |
| 163 | + | if owner != nil && owner.session != nil { | |
| 164 | + | effects.renderCoordinator = owner.session.renderCoordinator() | |
| 165 | + | } | |
| 166 | + | effects.wasSyncing = p.screen.SyncUpdateActive() | |
| 130 | 167 | p.screen.Write(data) | |
| 131 | 168 | 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) | |
| 139 | 174 | 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() { | |
| 142 | 180 | p.mu.Lock() | |
| 143 | - | if p.syncGen == syncGen && p.screen.SyncUpdateActive() { | |
| 181 | + | if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() { | |
| 144 | 182 | p.screen.ForceSyncEnd() | |
| 145 | 183 | } | |
| 146 | 184 | p.mu.Unlock() | |
| 147 | 185 | }) | |
| 148 | 186 | } | |
| 149 | 187 | } 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) | |
| 172 | 189 | } | |
| 173 | 190 | } | |
| 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 | |
| 181 | 192 | p.ptyAttention = false | |
| 182 | - | responses := append([]byte(nil), p.ptyResponses...) | |
| 193 | + | effects.responses = append([]byte(nil), p.ptyResponses...) | |
| 183 | 194 | p.ptyResponses = p.ptyResponses[:0] | |
| 184 | - | clipboards := append([]string(nil), p.ptyClipboards...) | |
| 195 | + | effects.clipboards = append([]string(nil), p.ptyClipboards...) | |
| 185 | 196 | p.ptyClipboards = p.ptyClipboards[:0] | |
| 186 | 197 | 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 | + | } | |
| 189 | 228 | } | |
| 190 | - | for _, b64 := range clipboards { | |
| 191 | - | d.forwardClipboardAsync(sess, b64) | |
| 229 | + | if effects.renderCoordinator == nil || !effects.lease.Current() { | |
| 230 | + | return | |
| 192 | 231 | } | |
| 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"}) | |
| 196 | 243 | } | |
| 197 | 244 | } | |
| 245 | + | if effects.completeSyncRead && renderable { | |
| 246 | + | effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) | |
| 247 | + | } else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing { | |
| 248 | + | effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 249 | + | } | |
| 198 | 250 | } | |
| 199 | 251 | ||
| 200 | 252 | // paint draws the composed client frame (active tab plus status bar) and | |
| @@ -210,8 +262,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid | |||
| 210 | 262 | // paint owns attachment sendMu while it captures, composes, and emits; JSONL | |
| 211 | 263 | // observer I/O must therefore happen only after that ownership is released. | |
| 212 | 264 | type runtimeMarkBatch struct { | |
| 213 | - | observer ports.RuntimeObserver | |
| 214 | - | marks []ports.RuntimeMark | |
| 265 | + | observer ports.RuntimeObserver | |
| 266 | + | marks []ports.RuntimeMark | |
| 267 | + | roleEffect *roleEffectTicket | |
| 215 | 268 | } | |
| 216 | 269 | ||
| 217 | 270 | func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch { | |
| @@ -243,6 +296,16 @@ func (b *runtimeMarkBatch) flush() { | |||
| 243 | 296 | ||
| 244 | 297 | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) { | |
| 245 | 298 | marks := d.newRuntimeMarkBatch() | |
| 299 | + | if lease != nil { | |
| 300 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 301 | + | token.lease = lease | |
| 302 | + | ticket, admitted := ac.beginRoleEffect(token) | |
| 303 | + | if !admitted { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | marks.roleEffect = ticket | |
| 307 | + | defer ticket.End() | |
| 308 | + | } | |
| 246 | 309 | tb := sess.activeTab() | |
| 247 | 310 | if tb == nil { | |
| 248 | 311 | return | |
M internal/usecase/daemon/render_coordinator.go +180 -112
| @@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache | |||
| 132 | 132 | return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 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) { | |
| 139 | 156 | 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() | |
| 144 | 158 | detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | |
| 145 | 159 | if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) || | |
| 146 | 160 | (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) || | |
| 147 | 161 | (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 | |
| 150 | 163 | } | |
| 151 | - | onInvalidate := c.opts.onInvalidate | |
| 164 | + | reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate} | |
| 152 | 165 | c.metrics.invalidations.Add(1) | |
| 153 | 166 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | |
| 154 | - | queueStart := false | |
| 155 | - | var queueCorrelation ports.RuntimeCorrelation | |
| 156 | 167 | if !wasPending { | |
| 157 | 168 | c.ackDeferred = false | |
| 158 | 169 | c.deadlineDue = false | |
| 159 | 170 | if c.opts.observer != nil { | |
| 160 | 171 | c.queueMarked = true | |
| 161 | 172 | c.queueCorrelation = ports.NewRuntimeCorrelation() | |
| 162 | - | queueCorrelation = c.queueCorrelation | |
| 163 | - | queueStart = true | |
| 173 | + | reservation.queueCorrelation = c.queueCorrelation | |
| 174 | + | reservation.queueStart = true | |
| 164 | 175 | } | |
| 165 | 176 | } | |
| 166 | 177 | c.pending = true | |
| @@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 168 | 179 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | |
| 169 | 180 | c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0 | |
| 170 | 181 | 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() | |
| 178 | 185 | } | |
| 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 | |
| 181 | 188 | if c.pendingUrgent { | |
| 182 | - | delay = urgentRenderDeadline | |
| 189 | + | reservation.delay = urgentRenderDeadline | |
| 183 | 190 | } | |
| 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)) | |
| 190 | 204 | } | |
| 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) | |
| 195 | 209 | } | |
| 196 | - | return true | |
| 210 | + | return | |
| 197 | 211 | } | |
| 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) | |
| 202 | 213 | timerC := timer.C() | |
| 203 | 214 | c.mu.Lock() | |
| 204 | - | valid := !c.torndown && c.pending && c.normalLane.generation == gen | |
| 215 | + | valid := !c.torndown && c.pending && c.normalLane.generation == r.generation | |
| 205 | 216 | var token *timerToken | |
| 206 | 217 | if valid && timerC != nil { | |
| 207 | - | token = c.normalLane.publishLocked(gen, timer) | |
| 218 | + | token = c.normalLane.publishLocked(r.generation, timer) | |
| 208 | 219 | valid = token != nil | |
| 209 | 220 | if valid { | |
| 210 | 221 | c.supervisor.startLocked(token, timerC, func() { | |
| @@ -216,34 +227,44 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 216 | 227 | c.mu.Unlock() | |
| 217 | 228 | if !valid { | |
| 218 | 229 | timer.Stop() | |
| 219 | - | if onInvalidate != nil { | |
| 220 | - | onInvalidate(inv) | |
| 221 | - | } | |
| 222 | - | return true | |
| 223 | - | } | |
| 224 | - | if timerC == nil { | |
| 230 | + | } else if timerC == nil { | |
| 225 | 231 | 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) | |
| 231 | 233 | } | |
| 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) | |
| 237 | 236 | } | |
| 238 | - | runtime.Gosched() | |
| 237 | + | if valid && timerC != nil { | |
| 238 | + | runtime.Gosched() | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it | |
| 243 | + | // rejects a same-object replacement whose source pointer still matches but | |
| 244 | + | // whose lifecycle lease has been revoked. | |
| 245 | + | func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |
| 246 | + | reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv) | |
| 247 | + | if !ok { | |
| 248 | + | return false | |
| 249 | + | } | |
| 250 | + | reservation.finish() | |
| 239 | 251 | return true | |
| 240 | 252 | } | |
| 241 | 253 | ||
| 242 | 254 | // notifyAck reports that the client acknowledged an output state, releasing | |
| 243 | 255 | // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired | |
| 244 | 256 | // normal or urgent deadline. | |
| 245 | - | func (c *renderCoordinator) notifyAck() { | |
| 257 | + | func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) } | |
| 258 | + | ||
| 259 | + | // notifyAckForLease prevents an acknowledgement captured by an obsolete active | |
| 260 | + | // frame from flushing the replacement attachment's render queue. A nil lease is | |
| 261 | + | // reserved for coordinator-owned/direct test callers. | |
| 262 | + | func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) { | |
| 246 | 263 | c.mu.Lock() | |
| 264 | + | if lease != nil && !c.leaseCurrentLocked(lease, true) { | |
| 265 | + | c.mu.Unlock() | |
| 266 | + | return | |
| 267 | + | } | |
| 247 | 268 | if (!c.ackDeferred && !c.deadlineDue) || !c.pending { | |
| 248 | 269 | c.mu.Unlock() | |
| 249 | 270 | return | |
| @@ -278,20 +299,47 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) | |||
| 278 | 299 | c.noteSyncBeginWithRenderability(p, gen, renderable, force...) | |
| 279 | 300 | } | |
| 280 | 301 | ||
| 302 | + | // syncTimerCleanup carries detached timer work across an enclosing pane or | |
| 303 | + | // coordinator critical section. finish never waits for a worker. | |
| 304 | + | type syncTimerCleanup struct{ tokens []*timerToken } | |
| 305 | + | ||
| 306 | + | func (cleanup *syncTimerCleanup) add(token *timerToken) { | |
| 307 | + | if token != nil { | |
| 308 | + | cleanup.tokens = append(cleanup.tokens, token) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (cleanup *syncTimerCleanup) append(other syncTimerCleanup) { | |
| 313 | + | cleanup.tokens = append(cleanup.tokens, other.tokens...) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func (cleanup syncTimerCleanup) finish() { | |
| 317 | + | for _, token := range cleanup.tokens { | |
| 318 | + | stopDetachedTimer(token) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 281 | 322 | // noteSyncBeginWithRenderability records lifecycle unconditionally while the | |
| 282 | 323 | // supplied predicate decides dynamically whether this batch gates composition. | |
| 283 | 324 | func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) { | |
| 325 | + | c.beginSyncBatchWithRenderability(p, gen, renderable, force...).finish() | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // beginSyncBatchWithRenderability publishes a batch and returns every timer | |
| 329 | + | // detached while doing so. Callers that already own pane.mu defer finish until | |
| 330 | + | // after releasing the parsing fence. | |
| 331 | + | func (c *renderCoordinator) beginSyncBatchWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) syncTimerCleanup { | |
| 332 | + | var cleanup syncTimerCleanup | |
| 284 | 333 | c.mu.Lock() | |
| 285 | 334 | if c.torndown { | |
| 286 | 335 | c.mu.Unlock() | |
| 287 | - | return | |
| 336 | + | return cleanup | |
| 288 | 337 | } | |
| 289 | 338 | if c.syncBatches == nil { | |
| 290 | 339 | c.syncBatches = make(map[*pane]*syncBatch) | |
| 291 | 340 | } | |
| 292 | - | var old *timerToken | |
| 293 | 341 | if previous := c.syncBatches[p]; previous != nil { | |
| 294 | - | old = detachSyncBatchLocked(previous) | |
| 342 | + | cleanup.add(detachSyncBatchLocked(previous)) | |
| 295 | 343 | } | |
| 296 | 344 | batch := &syncBatch{generation: gen, renderable: renderable} | |
| 297 | 345 | if len(force) != 0 { | |
| @@ -301,9 +349,8 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, | |||
| 301 | 349 | c.syncRegistryVersion++ | |
| 302 | 350 | clock := c.opts.clock | |
| 303 | 351 | c.mu.Unlock() | |
| 304 | - | stopDetachedTimer(old) | |
| 305 | 352 | if clock == nil { | |
| 306 | - | return | |
| 353 | + | return cleanup | |
| 307 | 354 | } | |
| 308 | 355 | ||
| 309 | 356 | // Both timer operations are external. A reentrant callback may replace or | |
| @@ -323,9 +370,25 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, | |||
| 323 | 370 | } | |
| 324 | 371 | c.mu.Unlock() | |
| 325 | 372 | if !valid || timerC == nil { | |
| 326 | - | stopDetachedTimer(&timerToken{timer: timer}) | |
| 327 | - | return | |
| 373 | + | cleanup.add(&timerToken{timer: timer}) | |
| 328 | 374 | } | |
| 375 | + | return cleanup | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // detachSyncBatchGeneration removes only the exact pane generation. It returns | |
| 379 | + | // timer cleanup to the caller so no Timer method runs under an enclosing pane | |
| 380 | + | // or coordinator lock. | |
| 381 | + | func (c *renderCoordinator) detachSyncBatchGeneration(p *pane, gen uint64) syncTimerCleanup { | |
| 382 | + | var cleanup syncTimerCleanup | |
| 383 | + | c.mu.Lock() | |
| 384 | + | batch := c.syncBatches[p] | |
| 385 | + | if batch != nil && batch.generation == gen { | |
| 386 | + | delete(c.syncBatches, p) | |
| 387 | + | c.syncRegistryVersion++ | |
| 388 | + | cleanup.add(detachSyncBatchLocked(batch)) | |
| 389 | + | } | |
| 390 | + | c.mu.Unlock() | |
| 391 | + | return cleanup | |
| 329 | 392 | } | |
| 330 | 393 | ||
| 331 | 394 | func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) { | |
| @@ -558,29 +621,41 @@ func (cleanup renderLifecycleCleanup) finish() { | |||
| 558 | 621 | // beginDetach invalidates pending work. It never waits for detached workers. | |
| 559 | 622 | func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup { | |
| 560 | 623 | c.mu.Lock() | |
| 624 | + | cleanup := c.beginDetachLocked(ac) | |
| 625 | + | c.mu.Unlock() | |
| 626 | + | return cleanup | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | // beginDetachLocked is the prelocked lifecycle seam used when registry, | |
| 630 | + | // current-session, and lease publication must share one commit boundary. | |
| 631 | + | func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup { | |
| 561 | 632 | var cleanup renderLifecycleCleanup | |
| 562 | 633 | if c.lease != nil && c.lease.active && c.lease.attachment == ac { | |
| 563 | 634 | 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() | |
| 582 | 636 | } | |
| 583 | - | c.mu.Unlock() | |
| 637 | + | return cleanup | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup { | |
| 641 | + | c.pending = false | |
| 642 | + | c.pendingReset = false | |
| 643 | + | c.pendingUrgent = false | |
| 644 | + | c.ackDeferred = false | |
| 645 | + | c.pendingPreview = false | |
| 646 | + | c.coalesced = 0 | |
| 647 | + | _, timer := c.normalLane.replaceLocked() | |
| 648 | + | c.armed = false | |
| 649 | + | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 650 | + | _, retryTimer := c.retryLane.replaceLocked() | |
| 651 | + | cleanup := renderLifecycleCleanup{ | |
| 652 | + | tokens: []*timerToken{timer, resizeTimer, retryTimer}, | |
| 653 | + | observer: c.opts.observer, | |
| 654 | + | queueCorrelation: c.queueCorrelation, | |
| 655 | + | queueMarked: c.queueMarked, | |
| 656 | + | ackBlocked: c.ackBlocked, | |
| 657 | + | } | |
| 658 | + | c.queueMarked, c.ackBlocked = false, nil | |
| 584 | 659 | return cleanup | |
| 585 | 660 | } | |
| 586 | 661 | ||
| @@ -593,36 +668,29 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 593 | 668 | // for detached workers. | |
| 594 | 669 | func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup { | |
| 595 | 670 | 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) | |
| 622 | 672 | c.mu.Unlock() | |
| 623 | 673 | return cleanup | |
| 624 | 674 | } | |
| 625 | 675 | ||
| 676 | + | // beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before | |
| 677 | + | // their ownership commit, making lease installation non-failing afterward. | |
| 678 | + | func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) { | |
| 679 | + | if !c.canReplaceLocked(old, replacement) { | |
| 680 | + | return renderLifecycleCleanup{}, nil | |
| 681 | + | } | |
| 682 | + | lease := c.installLeaseLocked(replacement, ready) | |
| 683 | + | return c.resetAttachmentLifecycleLocked(), lease | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool { | |
| 687 | + | // A coordinator may be installed while replacing a legacy attachment which | |
| 688 | + | // predated coordinator ownership. Any inactive lease is detached historical | |
| 689 | + | // state; a resume may also replace the same client object because registry and | |
| 690 | + | // transport validation proved a new role incarnation. | |
| 691 | + | return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement) | |
| 692 | + | } | |
| 693 | + | ||
| 626 | 694 | // noteReplace is for callers that hold no outer lifecycle locks. | |
| 627 | 695 | func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) { | |
| 628 | 696 | ready := true | |
M internal/usecase/daemon/render_coordinator_test.go +2 -2
| @@ -1334,7 +1334,7 @@ func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) { | |||
| 1334 | 1334 | rc.mu.Unlock() | |
| 1335 | 1335 | } | |
| 1336 | 1336 | ||
| 1337 | - | func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) { | |
| 1337 | + | func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) { | |
| 1338 | 1338 | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 1339 | 1339 | owner := &attachedClient{} | |
| 1340 | 1340 | rc.attach(owner) | |
| @@ -1345,7 +1345,7 @@ func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing | |||
| 1345 | 1345 | ||
| 1346 | 1346 | called := false | |
| 1347 | 1347 | rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true }) | |
| 1348 | - | require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry") | |
| 1348 | + | require.True(t, called, "a nil clock must run the retry without a timer") | |
| 1349 | 1349 | rc.mu.Lock() | |
| 1350 | 1350 | retryToken := rc.retryLane.token | |
| 1351 | 1351 | rc.mu.Unlock() | |
M internal/usecase/daemon/render_coordinator_types.go +7 -3
| @@ -80,9 +80,10 @@ type resizeRequestMetadata struct { | |||
| 80 | 80 | source *attachedClient | |
| 81 | 81 | // lease binds every delayed resize/retry callback to the exact attachment | |
| 82 | 82 | // 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 | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | // renderCoordinatorOptions wires one coordinator instance. | |
| @@ -105,6 +106,9 @@ type renderCoordinatorOptions struct { | |||
| 105 | 106 | // afterSyncGateEvaluated is a package-private deterministic test seam. It | |
| 106 | 107 | // runs unlocked after visibility predicates and before registry validation. | |
| 107 | 108 | afterSyncGateEvaluated func() | |
| 109 | + | // onActivateTabAfterResize observes whether activation used a lease-backed | |
| 110 | + | // path; it is nil in production. | |
| 111 | + | onActivateTabAfterResize func(bool) | |
| 108 | 112 | } | |
| 109 | 113 | ||
| 110 | 114 | // renderCoordinator fans in producer invalidations for one attached session. | |
M internal/usecase/daemon/render_pipeline.go +26 -12
| @@ -437,17 +437,10 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 437 | 437 | return false | |
| 438 | 438 | } | |
| 439 | 439 | } | |
| 440 | - | ac.sess.mu.Lock() | |
| 441 | - | if ac.sess.v != sess { | |
| 442 | - | ac.sess.mu.Unlock() | |
| 443 | - | ac.sendMu.Unlock() | |
| 444 | - | return false | |
| 445 | - | } | |
| 446 | 440 | endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) | |
| 447 | 441 | prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset) | |
| 448 | 442 | endDiff(0, err == nil) | |
| 449 | 443 | if err != nil { | |
| 450 | - | ac.sess.mu.Unlock() | |
| 451 | 444 | ac.sendMu.Unlock() | |
| 452 | 445 | d.log.Error("render draw failed", "err", err, "session", sess.name) | |
| 453 | 446 | // Without a coordinator reportError repaints synchronously. Suppress only | |
| @@ -470,7 +463,8 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 470 | 463 | var sendTr ports.Transport | |
| 471 | 464 | var sendErr error | |
| 472 | 465 | if len(data) > 0 { | |
| 473 | - | sendTr = ac.transport() | |
| 466 | + | sendTransport := ac.transportSnapshot() | |
| 467 | + | sendTr = sendTransport.transport | |
| 474 | 468 | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | |
| 475 | 469 | if sendTr == nil { | |
| 476 | 470 | sendErr = errors.New("client transport is nil") | |
| @@ -479,18 +473,25 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 479 | 473 | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 480 | 474 | send = async.SendAsync | |
| 481 | 475 | } | |
| 476 | + | interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport) | |
| 482 | 477 | sendErr = prepared.send(data, ac.echoAck.Load(), send) | |
| 478 | + | if interruptible { | |
| 479 | + | if sendErr != nil { | |
| 480 | + | marks.roleEffect.reportTransportFailure(sendTransport) | |
| 481 | + | } | |
| 482 | + | marks.roleEffect.endTransportSend() | |
| 483 | + | } | |
| 483 | 484 | } | |
| 484 | 485 | endEmit(uint64(len(data)), sendErr == nil) | |
| 485 | 486 | } | |
| 486 | 487 | if sendErr == nil { | |
| 487 | 488 | // 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. | |
| 490 | 492 | ac.pipelineScratch = ac.pipelineCache | |
| 491 | 493 | ac.pipelineCache = composed.cache | |
| 492 | 494 | } | |
| 493 | - | ac.sess.mu.Unlock() | |
| 494 | 495 | if sendErr == nil { | |
| 495 | 496 | // A successful no-byte emission also commits: its renderer shadow still | |
| 496 | 497 | // represents the captured frame. Lock panes only under sendMu and with no | |
| @@ -502,7 +503,20 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 502 | 503 | } | |
| 503 | 504 | ac.sendMu.Unlock() | |
| 504 | 505 | if sendErr != nil { | |
| 505 | - | d.detachOnSendError(sess, ac, sendTr) | |
| 506 | + | // A transport failure may invalidate the role gate. Release this render's | |
| 507 | + | // admission first. Detachment freezes the gate and therefore cannot mutate | |
| 508 | + | // ownership until any enclosing admitted operation has also ended. | |
| 509 | + | if marks.roleEffect == nil { | |
| 510 | + | d.detachOnSendError(sess, ac, sendTr) | |
| 511 | + | } else { | |
| 512 | + | // Capture the exact admitted capability, including its coordinator | |
| 513 | + | // lease, before End permits a replacement publication. Reserve cleanup | |
| 514 | + | // accounting before End so terminal Wait cannot race a later Add. | |
| 515 | + | token := marks.roleEffect.roleToken() | |
| 516 | + | launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr) | |
| 517 | + | marks.roleEffect.End() | |
| 518 | + | launchCleanup() | |
| 519 | + | } | |
| 506 | 520 | } | |
| 507 | 521 | return true | |
| 508 | 522 | } | |
M internal/usecase/daemon/render_pipeline_test.go +1 -1
| @@ -511,7 +511,7 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) { | |||
| 511 | 511 | snap := &overlayRenderSnapshot{ | |
| 512 | 512 | copySearchModel: visualsearch.New(snapshot), | |
| 513 | 513 | pickerActive: true, | |
| 514 | - | pickerModel: picker.New(nil, "", 0), | |
| 514 | + | pickerModel: picker.New(nil, picker.SelectionConfig{}), | |
| 515 | 515 | noticesOverlayActive: true, | |
| 516 | 516 | noticesOverlayModel: notices.New(nil, time.Time{}), | |
| 517 | 517 | paletteActive: true, | |
M internal/usecase/daemon/render_resize_deadline.go +55 -14
| @@ -1,6 +1,11 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/domain" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const maxResizeRetryAttempts = 3 | |
| 4 | 9 | ||
| 5 | 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | |
| 6 | 11 | // token validation at every callback effect boundary. | |
| @@ -17,6 +22,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source | |||
| 17 | 22 | _, retry := c.retryLane.replaceLocked() | |
| 18 | 23 | c.resize.epoch++ | |
| 19 | 24 | c.resize.size, c.resize.source, c.resize.lease = size, source, lease | |
| 25 | + | c.resize.retryAttempts = 0 | |
| 20 | 26 | epoch := c.resize.epoch | |
| 21 | 27 | c.mu.Unlock() | |
| 22 | 28 | stopDetachedTimer(retry) | |
| @@ -90,40 +96,61 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached | |||
| 90 | 96 | return true | |
| 91 | 97 | } | |
| 92 | 98 | ||
| 93 | - | func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) { | |
| 99 | + | type resizeRetryReservation struct { | |
| 100 | + | coordinator *renderCoordinator | |
| 101 | + | epoch uint64 | |
| 102 | + | source *attachedClient | |
| 103 | + | lease *attachmentLease | |
| 104 | + | run func() | |
| 105 | + | generation uint64 | |
| 106 | + | old *timerToken | |
| 107 | + | clock ports.Clock | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // reserveResizeRetryForLease publishes the retry-lane generation only. Timer | |
| 111 | + | // calls are deferred to finish so owner fences protect no external operation. | |
| 112 | + | func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) { | |
| 94 | 113 | c.mu.Lock() | |
| 95 | - | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch { | |
| 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 | |
| 98 | 117 | } | |
| 118 | + | c.resize.retryAttempts++ | |
| 99 | 119 | 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 { | |
| 104 | 125 | return | |
| 105 | 126 | } | |
| 106 | - | timer := clock.NewTimer(minOutputRenderDeadline) | |
| 127 | + | c := r.coordinator | |
| 128 | + | stopDetachedTimer(r.old) | |
| 129 | + | if r.clock == nil { | |
| 130 | + | r.run() | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | timer := r.clock.NewTimer(minOutputRenderDeadline) | |
| 107 | 134 | timerC := timer.C() | |
| 108 | 135 | if timerC == nil { | |
| 109 | 136 | timer.Stop() | |
| 110 | 137 | return | |
| 111 | 138 | } | |
| 112 | 139 | c.mu.Lock() | |
| 113 | - | valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == 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 | |
| 114 | 141 | if valid { | |
| 115 | - | token := c.retryLane.publishLocked(gen, timer) | |
| 142 | + | token := c.retryLane.publishLocked(r.generation, timer) | |
| 116 | 143 | valid = token != nil | |
| 117 | 144 | if valid { | |
| 118 | 145 | c.supervisor.startLocked(token, timerC, func() { | |
| 119 | 146 | c.mu.Lock() | |
| 120 | - | valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == 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 | |
| 121 | 148 | if valid { | |
| 122 | 149 | c.retryLane.clearLocked(token) | |
| 123 | 150 | } | |
| 124 | 151 | c.mu.Unlock() | |
| 125 | 152 | if valid { | |
| 126 | - | run() | |
| 153 | + | r.run() | |
| 127 | 154 | } | |
| 128 | 155 | }) | |
| 129 | 156 | } | |
| @@ -134,6 +161,20 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at | |||
| 134 | 161 | } | |
| 135 | 162 | } | |
| 136 | 163 | ||
| 164 | + | func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) { | |
| 165 | + | reservation, ok := c.reserveResizeRetryForLease(epoch, source, lease, run) | |
| 166 | + | if !ok { | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | reservation.finish() | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (c *renderCoordinator) resizeRetryAvailableForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool { | |
| 173 | + | c.mu.Lock() | |
| 174 | + | defer c.mu.Unlock() | |
| 175 | + | return !c.torndown && c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.retryAttempts < maxResizeRetryAttempts | |
| 176 | + | } | |
| 177 | + | ||
| 137 | 178 | func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool { | |
| 138 | 179 | c.mu.Lock() | |
| 139 | 180 | defer c.mu.Unlock() | |
A internal/usecase/daemon/resize_publication.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type resizeCommitPublication struct { | |
| 9 | + | members []resizeMember | |
| 10 | + | session *session | |
| 11 | + | attachment *attachedClient | |
| 12 | + | lease *attachmentLease | |
| 13 | + | epoch uint64 | |
| 14 | + | role attachmentRoleToken | |
| 15 | + | coordinator *renderCoordinator | |
| 16 | + | observer ports.RuntimeObserver | |
| 17 | + | mark ports.RuntimeMark | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (p resizeCommitPublication) current() bool { | |
| 21 | + | if !resizeMembersOwnerCurrent(p.members) { | |
| 22 | + | return false | |
| 23 | + | } | |
| 24 | + | if p.attachment == nil { | |
| 25 | + | return true | |
| 26 | + | } | |
| 27 | + | // Attached resize publication is admitted only through a live role ticket. | |
| 28 | + | if p.role.effect == nil { | |
| 29 | + | return false | |
| 30 | + | } | |
| 31 | + | if p.role.effect.ended.Load() || p.role.sess != p.session || | |
| 32 | + | p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease || | |
| 33 | + | p.role.generation != p.attachment.roleGeneration.Load() || | |
| 34 | + | !p.attachment.transportSnapshotCurrent(p.role.transport) || | |
| 35 | + | p.attachment.currentSession() != p.session || p.coordinator == nil { | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | p.session.mu.Lock() | |
| 39 | + | active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive | |
| 40 | + | p.session.mu.Unlock() | |
| 41 | + | return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // emit performs observer work only after every resize-owner fence and | |
| 45 | + | // attachment sendMu are released. The generation-bound publication is checked | |
| 46 | + | // once more so a move that wins after geometry publication but before observer | |
| 47 | + | // admission suppresses stale source telemetry. | |
| 48 | + | func (p resizeCommitPublication) emit() bool { | |
| 49 | + | if !p.current() { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | if p.observer != nil { | |
| 53 | + | p.observer.ObserveRuntime(p.mark) | |
| 54 | + | } | |
| 55 | + | return true | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // publishResizeCommit linearizes attachment geometry and an immutable telemetry | |
| 59 | + | // reservation with pane ownership. sendMu is acquired before resize fences, so | |
| 60 | + | // the canonical rule never acquires an attachment lock while a pane fence is | |
| 61 | + | // held. Observer callbacks run only after both lock families are released. | |
| 62 | + | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool { | |
| 63 | + | if ac != nil && ticket == nil { | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |
| 67 | + | if ac != nil { | |
| 68 | + | ac.sendMu.Lock() | |
| 69 | + | if d.afterResizeCommitSendLocked != nil { | |
| 70 | + | d.afterResizeCommitSendLocked() | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | fences := acquireResizeOwnerPostEffectFences(members) | |
| 74 | + | if fences == nil { | |
| 75 | + | if ac != nil { | |
| 76 | + | ac.sendMu.Unlock() | |
| 77 | + | } | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | publication := resizeCommitPublication{ | |
| 81 | + | members: append([]resizeMember(nil), members...), | |
| 82 | + | session: sess, | |
| 83 | + | attachment: ac, | |
| 84 | + | lease: lease, | |
| 85 | + | epoch: epoch, | |
| 86 | + | observer: d.runtimeObserver, | |
| 87 | + | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |
| 88 | + | } | |
| 89 | + | if ticket != nil { | |
| 90 | + | publication.role = ticket.roleToken() | |
| 91 | + | publication.coordinator = sess.renderCoordinator() | |
| 92 | + | } | |
| 93 | + | if !publication.current() { | |
| 94 | + | fences.Release() | |
| 95 | + | if ac != nil { | |
| 96 | + | ac.sendMu.Unlock() | |
| 97 | + | } | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | if ac != nil { | |
| 101 | + | ac.size = size | |
| 102 | + | } | |
| 103 | + | fences.Release() | |
| 104 | + | if ac != nil { | |
| 105 | + | ac.sendMu.Unlock() | |
| 106 | + | } | |
| 107 | + | return publication.emit() | |
| 108 | + | } | |
A internal/usecase/daemon/resize_retry_token.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane | |
| 4 | + | // owner generations which accepted the failure. A moved pane cannot lend its | |
| 5 | + | // old source worker authority over either the PTY or source publications. | |
| 6 | + | type acceptedTabLayoutRetryToken struct { | |
| 7 | + | session *session | |
| 8 | + | tab *tab | |
| 9 | + | members []resizeMember | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken { | |
| 13 | + | return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)} | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (t acceptedTabLayoutRetryToken) current() bool { | |
| 17 | + | if t.session == nil || t.tab == nil || len(t.members) == 0 { | |
| 18 | + | return false | |
| 19 | + | } | |
| 20 | + | for i := range t.members { | |
| 21 | + | member := &t.members[i] | |
| 22 | + | if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) { | |
| 23 | + | return false | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | return true | |
| 27 | + | } | |
M internal/usecase/daemon/resume.go +121 -17
| @@ -3,11 +3,14 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "crypto/rand" | |
| 5 | 5 | "encoding/binary" | |
| 6 | + | "errors" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race") | |
| 13 | + | ||
| 11 | 14 | func newResumeToken() uint64 { | |
| 12 | 15 | var b [8]byte | |
| 13 | 16 | if _, err := rand.Read(b[:]); err != nil { | |
| @@ -45,6 +48,10 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool { | |||
| 45 | 48 | } | |
| 46 | 49 | ||
| 47 | 50 | func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |
| 51 | + | return d.parkAttachmentAs(sess, ac, attachmentActive) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attachmentRole) bool { | |
| 48 | 55 | if !d.prepareParkAttachment(sess, ac) { | |
| 49 | 56 | return false | |
| 50 | 57 | } | |
| @@ -54,21 +61,45 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 54 | 61 | // remain outside d.mu: sendMu is ordered before the daemon lock. | |
| 55 | 62 | ac.clearCaptureFrames() | |
| 56 | 63 | d.mu.Lock() | |
| 57 | - | if d.closing { | |
| 64 | + | if d.closing || d.sessions[sess.id] != sess { | |
| 58 | 65 | d.mu.Unlock() | |
| 59 | 66 | return false | |
| 60 | 67 | } | |
| 68 | + | if role == attachmentActive { | |
| 69 | + | sess.mu.Lock() | |
| 70 | + | if sess.client != nil { | |
| 71 | + | role = attachmentSnatched | |
| 72 | + | } | |
| 73 | + | sess.mu.Unlock() | |
| 74 | + | } | |
| 61 | 75 | token := ac.resumeToken | |
| 76 | + | var ( | |
| 77 | + | oldRetirement *parkedAttachmentRetirement | |
| 78 | + | oldSame *parkedAttachment | |
| 79 | + | ) | |
| 62 | 80 | 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 | + | } | |
| 65 | 87 | } | |
| 66 | 88 | grace := d.resumeParkGrace | |
| 67 | 89 | timer := d.clock.NewTimer(grace) | |
| 68 | - | parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})} | |
| 90 | + | parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})} | |
| 69 | 91 | ac.parked = true | |
| 70 | 92 | d.parked[token] = parked | |
| 71 | 93 | d.mu.Unlock() | |
| 94 | + | if oldRetirement != nil { | |
| 95 | + | finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement}) | |
| 96 | + | } | |
| 97 | + | if oldSame != nil { | |
| 98 | + | if oldSame.timer != nil { | |
| 99 | + | oldSame.timer.Stop() | |
| 100 | + | } | |
| 101 | + | oldSame.closeDone() | |
| 102 | + | } | |
| 72 | 103 | d.log.Info("client parked for resume", "session", sess.name, "grace", grace) | |
| 73 | 104 | ||
| 74 | 105 | go func(token uint64, parked *parkedAttachment) { | |
| @@ -112,15 +143,66 @@ func (p *parkedAttachment) closeDone() { | |||
| 112 | 143 | p.doneOnce.Do(func() { close(p.done) }) | |
| 113 | 144 | } | |
| 114 | 145 | ||
| 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 | |
| 119 | 179 | for token, parked := range d.parked { | |
| 120 | 180 | if parked.sess == sess { | |
| 121 | - | d.removeParkedLocked(token, parked) | |
| 181 | + | retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) | |
| 122 | 182 | } | |
| 123 | 183 | } | |
| 184 | + | return retirements | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement { | |
| 188 | + | retirements := make([]parkedAttachmentRetirement, 0, len(d.parked)) | |
| 189 | + | for token, parked := range d.parked { | |
| 190 | + | retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) | |
| 191 | + | } | |
| 192 | + | return retirements | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // finishParkedAttachmentRetirements stops timers and closes transports without | |
| 196 | + | // holding daemon, session, coordinator, or pane locks. | |
| 197 | + | func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) { | |
| 198 | + | for _, retirement := range retirements { | |
| 199 | + | if retirement.parked.timer != nil { | |
| 200 | + | retirement.parked.timer.Stop() | |
| 201 | + | } | |
| 202 | + | retirement.parked.closeDone() | |
| 203 | + | ac := retirement.parked.ac | |
| 204 | + | _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport)) | |
| 205 | + | } | |
| 124 | 206 | } | |
| 125 | 207 | ||
| 126 | 208 | // resumeParked acquires the parked client's output lock before the daemon | |
| @@ -144,7 +226,10 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||
| 144 | 226 | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 145 | 227 | } | |
| 146 | 228 | 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"} | |
| 148 | 233 | } | |
| 149 | 234 | return d.resumeParkedLocked(h, tr, sz) | |
| 150 | 235 | } | |
| @@ -158,13 +243,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 158 | 243 | } | |
| 159 | 244 | sess := parked.sess | |
| 160 | 245 | registered := d.sessions[sess.id] == sess | |
| 246 | + | if !registered { | |
| 247 | + | d.removeParkedLocked(h.ResumeToken, parked) | |
| 248 | + | d.log.Warn("resume rejected", "session", sess.name, "registered", false) | |
| 249 | + | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 250 | + | } | |
| 161 | 251 | sess.mu.Lock() | |
| 162 | 252 | active := sess.client != nil | |
| 163 | 253 | 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 | |
| 168 | 256 | } | |
| 169 | 257 | ac := parked.ac | |
| 170 | 258 | if ac.clientID != h.ClientID { | |
| @@ -183,15 +271,31 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 183 | 271 | ac.size = sz | |
| 184 | 272 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 185 | 273 | ac.parked = false | |
| 186 | - | ac.setSession(sess) | |
| 187 | 274 | // The resumed session's snapshot is the sole source for future PTY children. | |
| 188 | 275 | // Existing PTYs retain the environment they were started with. | |
| 189 | 276 | sess.mu.Lock() | |
| 190 | 277 | sess.env = copyEnvironment(h.Env) | |
| 191 | 278 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 192 | - | sess.client = ac | |
| 193 | 279 | sess.mu.Unlock() | |
| 194 | - | d.attachCoordinator(sess, nil, ac, false) | |
| 280 | + | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | |
| 281 | + | // neither held. Reacquire them before returning to preserve this helper's | |
| 282 | + | // locked-caller contract. | |
| 283 | + | d.mu.Unlock() | |
| 284 | + | ac.sendMu.Unlock() | |
| 285 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 286 | + | target: sess, | |
| 287 | + | next: ac, | |
| 288 | + | expectedRole: attachmentDetached, | |
| 289 | + | targetRole: parked.role, | |
| 290 | + | expectedTransport: ac.transportSnapshot(), | |
| 291 | + | ready: false, | |
| 292 | + | }) | |
| 293 | + | ac.sendMu.Lock() | |
| 294 | + | d.mu.Lock() | |
| 295 | + | if err != nil { | |
| 296 | + | return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"} | |
| 297 | + | } | |
| 298 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 195 | 299 | d.touchMRU(sess) | |
| 196 | 300 | d.log.Info("client resumed", "session", sess.name) | |
| 197 | 301 | return sess, ac, true, nil | |
M internal/usecase/daemon/resume_test.go +68 -10
| @@ -51,6 +51,40 @@ func (t *closeTrackingTransport) Sends() []ports.Frame { | |||
| 51 | 51 | return append([]ports.Frame(nil), t.sends...) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | type closeCountingBlockedTransport struct { | |
| 55 | + | closeOnce sync.Once | |
| 56 | + | closed chan struct{} | |
| 57 | + | count int | |
| 58 | + | mu sync.Mutex | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func newCloseCountingBlockedTransport() *closeCountingBlockedTransport { | |
| 62 | + | return &closeCountingBlockedTransport{closed: make(chan struct{})} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (t *closeCountingBlockedTransport) Send(ports.Frame) error { | |
| 66 | + | <-t.closed | |
| 67 | + | return errors.New("closed") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (t *closeCountingBlockedTransport) Recv() (ports.Frame, error) { | |
| 71 | + | return ports.Frame{}, errors.New("closed") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (t *closeCountingBlockedTransport) Close() error { | |
| 75 | + | t.mu.Lock() | |
| 76 | + | t.count++ | |
| 77 | + | t.mu.Unlock() | |
| 78 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (t *closeCountingBlockedTransport) CloseCount() int { | |
| 83 | + | t.mu.Lock() | |
| 84 | + | defer t.mu.Unlock() | |
| 85 | + | return t.count | |
| 86 | + | } | |
| 87 | + | ||
| 54 | 88 | func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello { | |
| 55 | 89 | return ports.Hello{ | |
| 56 | 90 | Version: ports.ProtocolVersion, | |
| @@ -139,6 +173,24 @@ func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) { | |||
| 139 | 173 | require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport") | |
| 140 | 174 | } | |
| 141 | 175 | ||
| 176 | + | func TestDetachedNotificationTimeoutClosesCapturedTransportOnce(t *testing.T) { | |
| 177 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 178 | + | d := newTestDaemon(t, nil, clock) | |
| 179 | + | tr := newCloseCountingBlockedTransport() | |
| 180 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 181 | + | ||
| 182 | + | d.notifyDetachedSnapshotAsync(detachedAttachmentSnapshot{ | |
| 183 | + | ac: ac, | |
| 184 | + | transport: ac.transportSnapshot(), | |
| 185 | + | }, ports.ReasonDetach) | |
| 186 | + | timer := <-clock.timers | |
| 187 | + | timer.ch <- time.Time{} | |
| 188 | + | d.waitNotifies() | |
| 189 | + | ||
| 190 | + | require.Equal(t, 1, tr.CloseCount(), "timeout revocation must close the captured transport at most once") | |
| 191 | + | require.Nil(t, ac.transport(), "timeout cleanup must revoke the captured transport") | |
| 192 | + | } | |
| 193 | + | ||
| 142 | 194 | func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |
| 143 | 195 | pty, releasePTY := newBlockingPTY(t) | |
| 144 | 196 | defer releasePTY() | |
| @@ -308,6 +360,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 308 | 360 | token := ac.resumeToken | |
| 309 | 361 | require.True(t, sess.detachIfCurrent(ac)) | |
| 310 | 362 | require.True(t, d.parkAttachment(sess, ac)) | |
| 363 | + | generation := ac.roleGeneration.Load() | |
| 311 | 364 | ||
| 312 | 365 | newTr := &closeTrackingTransport{} | |
| 313 | 366 | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |
| @@ -315,6 +368,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 315 | 368 | require.True(t, ok) | |
| 316 | 369 | require.Same(t, sess, resumedSess) | |
| 317 | 370 | require.Same(t, ac, resumedAC) | |
| 371 | + | require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition") | |
| 318 | 372 | ||
| 319 | 373 | _ = ac.closeCapturedTransport(oldTr) | |
| 320 | 374 | require.True(t, oldTr.Closed(), "old transport is closed") | |
| @@ -518,18 +572,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||
| 518 | 572 | func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |
| 519 | 573 | for _, tc := range []struct { | |
| 520 | 574 | name string | |
| 521 | - | discard func(*Daemon, uint64, *parkedAttachment) | |
| 575 | + | discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement | |
| 522 | 576 | }{ | |
| 523 | 577 | { | |
| 524 | 578 | name: "expiry", | |
| 525 | - | discard: func(d *Daemon, token uint64, parked *parkedAttachment) { | |
| 579 | + | discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement { | |
| 526 | 580 | d.removeParkedLocked(token, parked) | |
| 581 | + | return nil | |
| 527 | 582 | }, | |
| 528 | 583 | }, | |
| 529 | 584 | { | |
| 530 | 585 | 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) | |
| 533 | 588 | }, | |
| 534 | 589 | }, | |
| 535 | 590 | } { | |
| @@ -547,8 +602,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||
| 547 | 602 | d.mu.Lock() | |
| 548 | 603 | parked := d.parked[ac.resumeToken] | |
| 549 | 604 | require.NotNil(t, parked) | |
| 550 | - | tc.discard(d, ac.resumeToken, parked) | |
| 605 | + | retirements := tc.discard(d, ac.resumeToken, parked) | |
| 551 | 606 | d.mu.Unlock() | |
| 607 | + | finishParkedAttachmentRetirements(retirements) | |
| 552 | 608 | ||
| 553 | 609 | require.Nil(t, ac.previousSession.Get()) | |
| 554 | 610 | }) | |
| @@ -604,7 +660,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |||
| 604 | 660 | require.False(t, ok, "killed session cannot be resumed") | |
| 605 | 661 | } | |
| 606 | 662 | ||
| 607 | - | func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |
| 663 | + | func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) { | |
| 608 | 664 | pty, release := newBlockingPTY(t) | |
| 609 | 665 | defer release() | |
| 610 | 666 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| @@ -620,13 +676,15 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |||
| 620 | 676 | require.NotSame(t, oldAC, activeAC) | |
| 621 | 677 | ||
| 622 | 678 | d.mu.Lock() | |
| 623 | - | _, parked := d.parked[token] | |
| 679 | + | parked := d.parked[token] | |
| 624 | 680 | d.mu.Unlock() | |
| 681 | + | require.NotNil(t, parked, "normal attach preserves the parked predecessor") | |
| 682 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 625 | 683 | _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 626 | - | require.False(t, parked, "normal attach invalidates stale parked token") | |
| 627 | 684 | 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)) | |
| 630 | 688 | require.Same(t, activeAC, sess.client) | |
| 631 | 689 | } | |
| 632 | 690 | ||
A internal/usecase/daemon/role_aware_parking_test.go +482 -0
| @@ -0,0 +1,482 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestActiveLinkLossParksActiveAndDetachesCoordinator(t *testing.T) { | |
| 17 | + | pty, release := newBlockingPTY(t) | |
| 18 | + | defer release() | |
| 19 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 20 | + | ||
| 21 | + | transport := &closeTrackingTransport{} | |
| 22 | + | sess, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | token := active.resumeToken | |
| 25 | + | rc := sess.renderCoordinator() | |
| 26 | + | require.NotNil(t, rc.attachmentLease(active)) | |
| 27 | + | ||
| 28 | + | d.clientGone(sess, active, transport, false) | |
| 29 | + | ||
| 30 | + | d.mu.Lock() | |
| 31 | + | parked := d.parked[token] | |
| 32 | + | d.mu.Unlock() | |
| 33 | + | require.NotNil(t, parked) | |
| 34 | + | require.Equal(t, attachmentActive, parked.role) | |
| 35 | + | require.Nil(t, sess.client) | |
| 36 | + | require.Nil(t, rc.attachmentLease(active), "active loss should detach coordinator ownership") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestSnatchedLinkLossParksWithoutDisturbingActiveOwner(t *testing.T) { | |
| 40 | + | pty, release := newBlockingPTY(t) | |
| 41 | + | defer release() | |
| 42 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 43 | + | ||
| 44 | + | oldTransport := &closeTrackingTransport{} | |
| 45 | + | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | oldToken := old.resumeToken | |
| 48 | + | ||
| 49 | + | activeTransport := &closeTrackingTransport{} | |
| 50 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | d.attachmentCleanupWg.Wait() | |
| 53 | + | lease := sess.renderCoordinator().attachmentLease(active) | |
| 54 | + | require.NotNil(t, lease) | |
| 55 | + | ||
| 56 | + | d.runConnLoop(old) | |
| 57 | + | ||
| 58 | + | d.mu.Lock() | |
| 59 | + | parked := d.parked[oldToken] | |
| 60 | + | d.mu.Unlock() | |
| 61 | + | require.NotNil(t, parked, "resumable snatched attachment should survive link loss") | |
| 62 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 63 | + | require.Same(t, active, sess.client) | |
| 64 | + | require.Same(t, lease, sess.renderCoordinator().attachmentLease(active)) | |
| 65 | + | require.False(t, activeTransport.Closed()) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | type sendErrorTransport struct { | |
| 69 | + | closeTrackingTransport | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (*sendErrorTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 73 | + | ||
| 74 | + | func TestSnatchedPanelSendFailureParksResumableAttachment(t *testing.T) { | |
| 75 | + | pty, release := newBlockingPTY(t) | |
| 76 | + | defer release() | |
| 77 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 78 | + | ||
| 79 | + | failed := &sendErrorTransport{} | |
| 80 | + | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), failed) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | token := waiting.resumeToken | |
| 83 | + | ||
| 84 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | d.attachmentCleanupWg.Wait() | |
| 87 | + | ||
| 88 | + | d.mu.Lock() | |
| 89 | + | parked := d.parked[token] | |
| 90 | + | d.mu.Unlock() | |
| 91 | + | require.NotNil(t, parked, "failed snatched panel should park a resumable attachment") | |
| 92 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 93 | + | require.Nil(t, waiting.transport(), "parking should revoke the exact failed transport") | |
| 94 | + | require.True(t, failed.Closed()) | |
| 95 | + | require.Same(t, active, sess.client) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestStaleSnatchedTransportCannotParkFreshIncarnation(t *testing.T) { | |
| 99 | + | pty, release := newBlockingPTY(t) | |
| 100 | + | defer release() | |
| 101 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 102 | + | ||
| 103 | + | oldTransport := &closeTrackingTransport{} | |
| 104 | + | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | d.attachmentCleanupWg.Wait() | |
| 109 | + | stale := sess.attachmentToken(waiting, oldTransport) | |
| 110 | + | fresh := &closeTrackingTransport{} | |
| 111 | + | waiting.replaceTransport(fresh) | |
| 112 | + | ||
| 113 | + | require.False(t, d.parkOrDropSnatchedAttachment(stale)) | |
| 114 | + | d.mu.Lock() | |
| 115 | + | _, parked := d.parked[waiting.resumeToken] | |
| 116 | + | d.mu.Unlock() | |
| 117 | + | require.False(t, parked) | |
| 118 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 119 | + | require.Same(t, fresh, waiting.transport()) | |
| 120 | + | require.False(t, fresh.Closed()) | |
| 121 | + | require.Same(t, active, sess.client) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestSnatchedParkExpiryDoesNotAffectActiveOwner(t *testing.T) { | |
| 125 | + | pty, release := newBlockingPTY(t) | |
| 126 | + | defer release() | |
| 127 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 128 | + | ||
| 129 | + | waitingTransport := &closeTrackingTransport{} | |
| 130 | + | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), waitingTransport) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | token := waiting.resumeToken | |
| 133 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | d.attachmentCleanupWg.Wait() | |
| 136 | + | lease := sess.renderCoordinator().attachmentLease(active) | |
| 137 | + | ||
| 138 | + | d.runConnLoop(waiting) | |
| 139 | + | d.mu.Lock() | |
| 140 | + | parked := d.parked[token] | |
| 141 | + | d.mu.Unlock() | |
| 142 | + | require.NotNil(t, parked) | |
| 143 | + | ||
| 144 | + | d.expireParked(token, parked) | |
| 145 | + | ||
| 146 | + | d.mu.Lock() | |
| 147 | + | _, retained := d.parked[token] | |
| 148 | + | d.mu.Unlock() | |
| 149 | + | require.False(t, retained) | |
| 150 | + | require.Same(t, active, sess.client) | |
| 151 | + | require.Same(t, lease, sess.renderCoordinator().attachmentLease(active)) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestNonzeroResumeTokenOverridesOrdinaryRoutingFallbacks(t *testing.T) { | |
| 155 | + | pty, release := newBlockingPTY(t) | |
| 156 | + | defer release() | |
| 157 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 158 | + | ||
| 159 | + | work, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), &closeTrackingTransport{}) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | const unknownToken = uint64(0xdecafbad) | |
| 162 | + | for _, tc := range []struct { | |
| 163 | + | name string | |
| 164 | + | intent uint8 | |
| 165 | + | target string | |
| 166 | + | }{ | |
| 167 | + | {name: "attach existing", intent: ports.IntentAttach, target: "work"}, | |
| 168 | + | {name: "create unique", intent: ports.IntentNew, target: "fallback"}, | |
| 169 | + | {name: "create ephemeral", intent: ports.IntentEphemeral}, | |
| 170 | + | } { | |
| 171 | + | t.Run(tc.name, func(t *testing.T) { | |
| 172 | + | h := helloResumeCapable(tc.intent, tc.target, unknownToken) | |
| 173 | + | routedSession, routedClient, routeErr := d.route(h, &closeTrackingTransport{}) | |
| 174 | + | require.Error(t, routeErr) | |
| 175 | + | require.Nil(t, routedSession) | |
| 176 | + | require.Nil(t, routedClient) | |
| 177 | + | var protocolErr *protoErr | |
| 178 | + | require.ErrorAs(t, routeErr, &protocolErr) | |
| 179 | + | require.Equal(t, ports.ErrNoSuchSession, protocolErr.code) | |
| 180 | + | require.Same(t, active, work.client) | |
| 181 | + | d.mu.Lock() | |
| 182 | + | require.Len(t, d.sessions, 1) | |
| 183 | + | require.Nil(t, d.findByNameLocked("fallback")) | |
| 184 | + | d.mu.Unlock() | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | _, replacement, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 189 | + | require.NoError(t, err, "a zero token keeps ordinary attach semantics") | |
| 190 | + | require.NotSame(t, active, replacement) | |
| 191 | + | require.Same(t, replacement, work.client) | |
| 192 | + | require.Equal(t, attachmentSnatched, work.attachmentRole(active)) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestHandleHelloExpiredSnatchedResumeFailsClosedAfterSessionEnds(t *testing.T) { | |
| 196 | + | workPTY, releaseWork := newBlockingPTY(t) | |
| 197 | + | keeperPTY, releaseKeeper := newBlockingPTY(t) | |
| 198 | + | fallbackPTY, releaseFallback := newBlockingPTY(t) | |
| 199 | + | defer releaseWork() | |
| 200 | + | defer releaseKeeper() | |
| 201 | + | defer releaseFallback() | |
| 202 | + | d := newTestDaemon(t, newFactorySeq(t, workPTY, keeperPTY, fallbackPTY), stubClock{}) | |
| 203 | + | ||
| 204 | + | oldTransport := &closeTrackingTransport{} | |
| 205 | + | work, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | oldToken := old.resumeToken | |
| 208 | + | ||
| 209 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | d.attachmentCleanupWg.Wait() | |
| 212 | + | require.Equal(t, attachmentSnatched, work.attachmentRole(old)) | |
| 213 | + | require.Same(t, active, work.client) | |
| 214 | + | ||
| 215 | + | d.runConnLoop(old) | |
| 216 | + | d.mu.Lock() | |
| 217 | + | parked := d.parked[oldToken] | |
| 218 | + | d.mu.Unlock() | |
| 219 | + | require.NotNil(t, parked) | |
| 220 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 221 | + | ||
| 222 | + | keeper, _, err := d.route(helloResumeCapable(ports.IntentNew, "keeper", 0), &closeTrackingTransport{}) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | require.NoError(t, d.killSession(work, ports.ReasonSessionKilled, false)) | |
| 225 | + | ||
| 226 | + | d.mu.Lock() | |
| 227 | + | _, tokenRetained := d.parked[oldToken] | |
| 228 | + | stoppedWork, lifecycleRetained := d.stopped["work"] | |
| 229 | + | d.mu.Unlock() | |
| 230 | + | require.False(t, tokenRetained, "terminal lifecycle teardown must purge the parked token") | |
| 231 | + | require.True(t, lifecycleRetained, "the stopped lifecycle makes attach fallback observable") | |
| 232 | + | ||
| 233 | + | reconnect := &closeTrackingTransport{} | |
| 234 | + | d.handleHello(reconnect, ports.Frame{ | |
| 235 | + | Type: ports.MsgHello, | |
| 236 | + | Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken)), | |
| 237 | + | }) | |
| 238 | + | ||
| 239 | + | frames := reconnect.Sends() | |
| 240 | + | require.Len(t, frames, 1) | |
| 241 | + | require.Equal(t, ports.MsgError, frames[0].Type) | |
| 242 | + | wireErr, err := ports.UnmarshalErrorMsg(frames[0].Payload) | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | require.Equal(t, ports.ErrNoSuchSession, wireErr.Code) | |
| 245 | + | require.True(t, reconnect.Closed()) | |
| 246 | + | d.mu.Lock() | |
| 247 | + | require.Same(t, keeper, d.findByNameLocked("keeper")) | |
| 248 | + | require.Nil(t, d.findByNameLocked("work"), "expired resume must not recreate the stopped session") | |
| 249 | + | require.Equal(t, stoppedWork, d.stopped["work"]) | |
| 250 | + | require.Len(t, d.sessions, 1) | |
| 251 | + | d.mu.Unlock() | |
| 252 | + | require.Nil(t, work.client, "expired resume must not reclaim the ended lifecycle") | |
| 253 | + | require.Nil(t, active.currentSession()) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestSnatchedResumeHandshakeSendsWelcomeAndPanelWithoutReplacingOwner(t *testing.T) { | |
| 257 | + | pty, release := newBlockingPTY(t) | |
| 258 | + | defer release() | |
| 259 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 260 | + | ||
| 261 | + | oldTransport := &closeTrackingTransport{} | |
| 262 | + | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | token := old.resumeToken | |
| 265 | + | d.clientGone(sess, old, oldTransport, false) | |
| 266 | + | ||
| 267 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | ||
| 270 | + | resumedTransport := &closeTrackingTransport{} | |
| 271 | + | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 272 | + | d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 273 | + | ||
| 274 | + | frames := resumedTransport.Sends() | |
| 275 | + | require.GreaterOrEqual(t, len(frames), 2) | |
| 276 | + | require.Equal(t, ports.MsgWelcome, frames[0].Type) | |
| 277 | + | welcome, err := ports.UnmarshalWelcome(frames[0].Payload) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | newToken := welcome.ResumeToken | |
| 280 | + | require.NotZero(t, newToken) | |
| 281 | + | require.NotEqual(t, token, newToken, "successful snatched resume rotates the credential") | |
| 282 | + | require.Equal(t, newToken, old.resumeToken, "the Welcome publishes the one rotated credential") | |
| 283 | + | require.Equal(t, ports.MsgOutput, frames[1].Type) | |
| 284 | + | output, err := ports.UnmarshalOutput(frames[1].Payload) | |
| 285 | + | require.NoError(t, err) | |
| 286 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 287 | + | d.mu.Lock() | |
| 288 | + | _, oldTokenParked := d.parked[token] | |
| 289 | + | parked := d.parked[newToken] | |
| 290 | + | d.mu.Unlock() | |
| 291 | + | require.False(t, oldTokenParked, "the consumed credential cannot be reused") | |
| 292 | + | require.NotNil(t, parked) | |
| 293 | + | require.Equal(t, attachmentSnatched, parked.role, "the rotated credential remains bound to the snatched role") | |
| 294 | + | require.Same(t, active, sess.client) | |
| 295 | + | ||
| 296 | + | replay := &closeTrackingTransport{} | |
| 297 | + | d.handleHello(replay, ports.Frame{ | |
| 298 | + | Type: ports.MsgHello, | |
| 299 | + | Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token)), | |
| 300 | + | }) | |
| 301 | + | replayFrames := replay.Sends() | |
| 302 | + | require.Len(t, replayFrames, 1) | |
| 303 | + | require.Equal(t, ports.MsgError, replayFrames[0].Type) | |
| 304 | + | replayErr, err := ports.UnmarshalErrorMsg(replayFrames[0].Payload) | |
| 305 | + | require.NoError(t, err) | |
| 306 | + | require.Equal(t, ports.ErrNoSuchSession, replayErr.Code) | |
| 307 | + | require.True(t, replay.Closed()) | |
| 308 | + | require.Same(t, active, sess.client, "replaying the old credential must not replace active ownership") | |
| 309 | + | d.mu.Lock() | |
| 310 | + | require.Same(t, parked, d.parked[newToken]) | |
| 311 | + | d.mu.Unlock() | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestResumeWelcomeRechecksRoleAfterReplacement(t *testing.T) { | |
| 315 | + | pty, release := newBlockingPTY(t) | |
| 316 | + | defer release() | |
| 317 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 318 | + | ||
| 319 | + | oldTransport := &closeTrackingTransport{} | |
| 320 | + | sess, resumed, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | token := resumed.resumeToken | |
| 323 | + | d.clientGone(sess, resumed, oldTransport, false) | |
| 324 | + | ||
| 325 | + | blocked := newWelcomeBlockingTransport(t) | |
| 326 | + | handshakeDone := make(chan struct{}) | |
| 327 | + | go func() { | |
| 328 | + | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 329 | + | d.handleHello(blocked.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 330 | + | close(handshakeDone) | |
| 331 | + | }() | |
| 332 | + | awaitTestCompletion(t, blocked.welcomeEntered, "resume did not start Welcome") | |
| 333 | + | welcome := awaitFrame(t, blocked.sends, ports.MsgWelcome) | |
| 334 | + | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 335 | + | requireNoCoordinatorOutputFrame(t, blocked.sends) | |
| 336 | + | resumedLease := sess.renderCoordinator().attachmentLease(resumed) | |
| 337 | + | require.NotNil(t, resumedLease) | |
| 338 | + | require.False(t, resumedLease.ready, "blocked Welcome must keep the resumed lease unready") | |
| 339 | + | ||
| 340 | + | resumeGateFrozen := make(chan struct{}) | |
| 341 | + | var frozenOnce sync.Once | |
| 342 | + | d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { | |
| 343 | + | if ac == resumed { | |
| 344 | + | frozenOnce.Do(func() { close(resumeGateFrozen) }) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | type attachResult struct { | |
| 348 | + | ac *attachedClient | |
| 349 | + | err error | |
| 350 | + | } | |
| 351 | + | replacementResult := make(chan attachResult, 1) | |
| 352 | + | go func() { | |
| 353 | + | _, replacement, routeErr := d.route( | |
| 354 | + | helloResumeCapable(ports.IntentAttach, "work", 0), | |
| 355 | + | &closeTrackingTransport{}, | |
| 356 | + | ) | |
| 357 | + | replacementResult <- attachResult{ac: replacement, err: routeErr} | |
| 358 | + | }() | |
| 359 | + | awaitTestCompletion(t, resumeGateFrozen, "replacement did not freeze the resumed Welcome role") | |
| 360 | + | ||
| 361 | + | blocked.release() | |
| 362 | + | replacement := awaitTestValue(t, replacementResult, "replacement did not finish after Welcome") | |
| 363 | + | require.NoError(t, replacement.err) | |
| 364 | + | d.attachmentCleanupWg.Wait() | |
| 365 | + | panelFrame := awaitFrame(t, blocked.sends, ports.MsgOutput) | |
| 366 | + | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 367 | + | require.NoError(t, err) | |
| 368 | + | require.Zero(t, panel.BaseStateNum, "post-Welcome snatched panel must reset output state") | |
| 369 | + | require.Contains(t, string(panel.Data), "Session snatched") | |
| 370 | + | requireNoCoordinatorOutputFrame(t, blocked.sends) | |
| 371 | + | ||
| 372 | + | require.Same(t, replacement.ac, sess.client) | |
| 373 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(resumed)) | |
| 374 | + | rc := sess.renderCoordinator() | |
| 375 | + | rc.mu.Lock() | |
| 376 | + | require.False(t, resumedLease.ready, "replacement during Welcome must not ready the stale active lease") | |
| 377 | + | rc.mu.Unlock() | |
| 378 | + | require.Nil(t, rc.attachmentLease(resumed)) | |
| 379 | + | require.NotNil(t, rc.attachmentLease(replacement.ac)) | |
| 380 | + | ||
| 381 | + | blocked.finish() | |
| 382 | + | awaitTestCompletion(t, handshakeDone, "resumed snatched connection did not continue after replacement") | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func TestSnatchedResumePreservesOwnerCapabilityAndCanReclaim(t *testing.T) { | |
| 386 | + | pty, release := newBlockingPTY(t) | |
| 387 | + | defer release() | |
| 388 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 389 | + | ||
| 390 | + | oldTransport := &closeTrackingTransport{} | |
| 391 | + | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 392 | + | require.NoError(t, err) | |
| 393 | + | token := waiting.resumeToken | |
| 394 | + | waiting.setClientTheme(themeui.Theme{ | |
| 395 | + | Foreground: renderer.RGB{R: 90, G: 10, B: 10}, | |
| 396 | + | Background: renderer.RGB{R: 20, G: 1, B: 1}, | |
| 397 | + | HasFG: true, HasBG: true, Known: true, | |
| 398 | + | }) | |
| 399 | + | d.clientGone(sess, waiting, oldTransport, false) | |
| 400 | + | ||
| 401 | + | activeTransport := &closeTrackingTransport{} | |
| 402 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 403 | + | require.NoError(t, err) | |
| 404 | + | rc := sess.renderCoordinator() | |
| 405 | + | activeGeneration := active.roleGeneration.Load() | |
| 406 | + | activeLease := rc.attachmentLease(active) | |
| 407 | + | require.NotNil(t, activeLease) | |
| 408 | + | activeTheme := themeui.Theme{ | |
| 409 | + | Foreground: renderer.RGB{R: 10, G: 80, B: 20}, | |
| 410 | + | Background: renderer.RGB{R: 1, G: 15, B: 3}, | |
| 411 | + | HasFG: true, HasBG: true, Known: true, | |
| 412 | + | } | |
| 413 | + | active.setClientTheme(activeTheme) | |
| 414 | + | require.True(t, d.applyHostTheme(sess, active, activeTheme, false)) | |
| 415 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 416 | + | ||
| 417 | + | resumedTransport := &closeTrackingTransport{} | |
| 418 | + | resumedSess, resumed, ok, err := d.resumeParked( | |
| 419 | + | helloResumeCapable(ports.IntentResume, "work", token), | |
| 420 | + | resumedTransport, | |
| 421 | + | domain.Size{Cols: 80, Rows: 24}, | |
| 422 | + | ) | |
| 423 | + | require.NoError(t, err) | |
| 424 | + | require.True(t, ok) | |
| 425 | + | require.Same(t, sess, resumedSess) | |
| 426 | + | require.Same(t, waiting, resumed) | |
| 427 | + | require.Same(t, active, sess.client) | |
| 428 | + | require.Equal(t, activeGeneration, active.roleGeneration.Load()) | |
| 429 | + | require.Same(t, activeLease, rc.attachmentLease(active)) | |
| 430 | + | require.Nil(t, rc.attachmentLease(waiting), "snatched resume must not acquire a render lease") | |
| 431 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 432 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 433 | + | ||
| 434 | + | resumedGeneration := waiting.roleGeneration.Load() | |
| 435 | + | require.False(t, d.handleSnatchedClientFrame(sess.attachmentToken(waiting, resumedTransport), ports.Frame{ | |
| 436 | + | Type: ports.MsgInput, | |
| 437 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 438 | + | })) | |
| 439 | + | d.attachmentCleanupWg.Wait() | |
| 440 | + | ||
| 441 | + | require.Same(t, waiting, sess.client) | |
| 442 | + | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 443 | + | require.Greater(t, waiting.roleGeneration.Load(), resumedGeneration) | |
| 444 | + | require.NotNil(t, rc.attachmentLease(waiting)) | |
| 445 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func TestOrdinaryAttachDemotesParkedActiveAndAllowsSnatchedResume(t *testing.T) { | |
| 449 | + | pty, release := newBlockingPTY(t) | |
| 450 | + | defer release() | |
| 451 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 452 | + | ||
| 453 | + | oldTransport := &closeTrackingTransport{} | |
| 454 | + | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 455 | + | require.NoError(t, err) | |
| 456 | + | token := old.resumeToken | |
| 457 | + | d.clientGone(sess, old, oldTransport, false) | |
| 458 | + | ||
| 459 | + | activeTransport := &closeTrackingTransport{} | |
| 460 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 461 | + | require.NoError(t, err) | |
| 462 | + | ||
| 463 | + | d.mu.Lock() | |
| 464 | + | parked := d.parked[token] | |
| 465 | + | d.mu.Unlock() | |
| 466 | + | require.NotNil(t, parked, "ordinary attach must preserve the predecessor's resume token") | |
| 467 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 468 | + | require.Equal(t, token, old.resumeToken) | |
| 469 | + | ||
| 470 | + | resumedTransport := &closeTrackingTransport{} | |
| 471 | + | resumedSess, resumed, ok, err := d.resumeParked( | |
| 472 | + | helloResumeCapable(ports.IntentResume, "work", token), | |
| 473 | + | resumedTransport, | |
| 474 | + | domain.Size{Cols: 80, Rows: 24}, | |
| 475 | + | ) | |
| 476 | + | require.NoError(t, err) | |
| 477 | + | require.True(t, ok) | |
| 478 | + | require.Same(t, sess, resumedSess) | |
| 479 | + | require.Same(t, old, resumed) | |
| 480 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(old)) | |
| 481 | + | require.Same(t, active, sess.client) | |
| 482 | + | } | |
A internal/usecase/daemon/role_effect_freeze.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type roleEffectDrainDeadline struct { | |
| 12 | + | clock ports.Clock | |
| 13 | + | done chan struct{} | |
| 14 | + | stopCh chan struct{} | |
| 15 | + | finished chan struct{} | |
| 16 | + | startOnce sync.Once | |
| 17 | + | stopOnce sync.Once | |
| 18 | + | started atomic.Bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |
| 22 | + | return &roleEffectDrainDeadline{ | |
| 23 | + | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (d *roleEffectDrainDeadline) stop() { | |
| 28 | + | if d == nil { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | d.stopOnce.Do(func() { | |
| 32 | + | // Claim an unstarted deadline without allocating a timer. Done is never | |
| 33 | + | // called after the owning freeze/drain operation returns. | |
| 34 | + | d.startOnce.Do(func() {}) | |
| 35 | + | if !d.started.Load() { | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | close(d.stopCh) | |
| 39 | + | <-d.finished | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |
| 44 | + | if d == nil { | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | d.startOnce.Do(func() { | |
| 48 | + | d.started.Store(true) | |
| 49 | + | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 50 | + | go func() { | |
| 51 | + | defer close(d.finished) | |
| 52 | + | select { | |
| 53 | + | case <-timer.C(): | |
| 54 | + | close(d.done) | |
| 55 | + | case <-d.stopCh: | |
| 56 | + | timer.Stop() | |
| 57 | + | } | |
| 58 | + | }() | |
| 59 | + | }) | |
| 60 | + | return d.done | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type frozenRoleEffectGates struct { | |
| 64 | + | clients []*attachedClient | |
| 65 | + | interruptions map[*attachedClient]transportSnapshot | |
| 66 | + | acquired bool | |
| 67 | + | drained bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |
| 71 | + | interrupted, ok := f.interruptions[ac] | |
| 72 | + | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (f frozenRoleEffectGates) contains(target *attachedClient) bool { | |
| 76 | + | for _, ac := range f.clients { | |
| 77 | + | if ac == target { | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type roleTransportInterrupt struct { | |
| 85 | + | ac *attachedClient | |
| 86 | + | transport transportSnapshot | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // roleEffectFreezeOptions controls one role-gate freeze operation. A freeze | |
| 90 | + | // must be called without daemon, routing, session, coordinator, send, overlay, | |
| 91 | + | // router, or pane locks held. | |
| 92 | + | type roleEffectFreezeOptions struct { | |
| 93 | + | interrupts []roleTransportInterrupt | |
| 94 | + | done func() <-chan struct{} | |
| 95 | + | afterFrozen func(*attachedClient) | |
| 96 | + | nonblocking bool | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // freezeRoleEffectGates establishes transition priority in immutable attachment | |
| 100 | + | // order, then drains earlier admissions. | |
| 101 | + | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |
| 102 | + | return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // freezeRoleEffectGatesWith uses done as one overall bound for ordered | |
| 106 | + | // acquisition and drain. Acquisition is all-or-nothing: if the bound expires | |
| 107 | + | // behind another owner, only gates acquired by this call are rolled back, in | |
| 108 | + | // reverse order. nonblocking is used by move reservations to avoid inverting | |
| 109 | + | // the move/teardown protocols. | |
| 110 | + | func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates { | |
| 111 | + | ordered := orderedRoleEffectClients(clients) | |
| 112 | + | frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking) | |
| 113 | + | if !acquired { | |
| 114 | + | return frozenRoleEffectGates{} | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | interruptByClient := roleTransportInterruptsByClient(options.interrupts) | |
| 118 | + | frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |
| 119 | + | interruptFrozenRoleTransports(ordered, interruptByClient) | |
| 120 | + | frozen.acquired = true | |
| 121 | + | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done) | |
| 122 | + | return frozen | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // orderedRoleEffectClients snapshots unique participants in immutable lock | |
| 126 | + | // order. Assigning a previously unused order does not freeze or wait on a gate. | |
| 127 | + | func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient { | |
| 128 | + | unique := make(map[*attachedClient]struct{}, len(clients)) | |
| 129 | + | ordered := make([]*attachedClient, 0, len(clients)) | |
| 130 | + | for _, ac := range clients { | |
| 131 | + | if ac == nil { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | if _, exists := unique[ac]; exists { | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | unique[ac] = struct{}{} | |
| 138 | + | ac.roleEffects.immutableOrder() | |
| 139 | + | ordered = append(ordered, ac) | |
| 140 | + | } | |
| 141 | + | sort.Slice(ordered, func(i, j int) bool { | |
| 142 | + | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |
| 143 | + | }) | |
| 144 | + | return ordered | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A | |
| 148 | + | // true result means the overall deadline won and the caller must stop waiting. | |
| 149 | + | func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool { | |
| 150 | + | if done == nil { | |
| 151 | + | g.cond.Wait() | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | changed := g.changed | |
| 155 | + | doneCh := done() | |
| 156 | + | g.mu.Unlock() | |
| 157 | + | select { | |
| 158 | + | case <-changed: | |
| 159 | + | case <-doneCh: | |
| 160 | + | } | |
| 161 | + | g.mu.Lock() | |
| 162 | + | select { | |
| 163 | + | case <-doneCh: | |
| 164 | + | return true | |
| 165 | + | default: | |
| 166 | + | return false | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // acquireRoleEffectGates establishes exclusive transition ownership in order. | |
| 171 | + | // It must run without architecture locks. Deadline rollback releases only this | |
| 172 | + | // call's partial acquisition and does so in reverse order. | |
| 173 | + | func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) { | |
| 174 | + | frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |
| 175 | + | for _, ac := range ordered { | |
| 176 | + | g := &ac.roleEffects | |
| 177 | + | g.mu.Lock() | |
| 178 | + | g.initLocked() | |
| 179 | + | for g.phase == roleEffectsFrozen { | |
| 180 | + | if nonblocking { | |
| 181 | + | g.mu.Unlock() | |
| 182 | + | frozen.unfreeze() | |
| 183 | + | return frozenRoleEffectGates{}, false | |
| 184 | + | } | |
| 185 | + | if waitForRoleEffectChangeLocked(g, done) { | |
| 186 | + | g.mu.Unlock() | |
| 187 | + | frozen.unfreeze() | |
| 188 | + | return frozenRoleEffectGates{}, false | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | g.phase = roleEffectsFrozen | |
| 192 | + | g.mu.Unlock() | |
| 193 | + | frozen.clients = append(frozen.clients, ac) | |
| 194 | + | if afterFrozen != nil { | |
| 195 | + | afterFrozen(ac) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | return frozen, true | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot { | |
| 202 | + | byClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |
| 203 | + | for _, interrupt := range interrupts { | |
| 204 | + | if interrupt.ac != nil && interrupt.transport.transport != nil { | |
| 205 | + | byClient[interrupt.ac] = interrupt.transport | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | return byClient | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // interruptFrozenRoleTransports snapshots every exact in-flight link while all | |
| 212 | + | // gates are frozen, then closes the complete set before any drain wait. One | |
| 213 | + | // uncooperative Send cannot prevent another participant from being interrupted. | |
| 214 | + | func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) { | |
| 215 | + | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |
| 216 | + | for _, ac := range ordered { | |
| 217 | + | expected, mayInterrupt := interruptByClient[ac] | |
| 218 | + | if !mayInterrupt { | |
| 219 | + | continue | |
| 220 | + | } | |
| 221 | + | g := &ac.roleEffects | |
| 222 | + | g.mu.Lock() | |
| 223 | + | if gateHasTransportEffectLocked(g, expected) { | |
| 224 | + | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |
| 225 | + | } | |
| 226 | + | g.mu.Unlock() | |
| 227 | + | } | |
| 228 | + | for _, interrupt := range toClose { | |
| 229 | + | _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // drainFrozenRoleEffects waits for admissions made before the freeze. A closed | |
| 234 | + | // deadline permits terminal teardown to continue with the capability frozen; | |
| 235 | + | // late tickets can then retire without pinning the owner indefinitely. | |
| 236 | + | func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool { | |
| 237 | + | for _, ac := range ordered { | |
| 238 | + | g := &ac.roleEffects | |
| 239 | + | g.mu.Lock() | |
| 240 | + | for g.inFlight != 0 { | |
| 241 | + | if waitForRoleEffectChangeLocked(g, done) { | |
| 242 | + | g.mu.Unlock() | |
| 243 | + | return false | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt && | |
| 247 | + | g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation { | |
| 248 | + | interruptions[ac] = expected | |
| 249 | + | } | |
| 250 | + | g.mu.Unlock() | |
| 251 | + | } | |
| 252 | + | return true | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |
| 256 | + | for _, active := range g.transportEffects { | |
| 257 | + | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |
| 258 | + | return true | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | return false | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (f frozenRoleEffectGates) unfreeze() { | |
| 265 | + | for i := len(f.clients) - 1; i >= 0; i-- { | |
| 266 | + | g := &f.clients[i].roleEffects | |
| 267 | + | g.mu.Lock() | |
| 268 | + | g.initLocked() | |
| 269 | + | g.phase = roleEffectsStable | |
| 270 | + | g.signalLocked() | |
| 271 | + | g.mu.Unlock() | |
| 272 | + | } | |
| 273 | + | } | |
A internal/usecase/daemon/role_effect_gate.go +307 -0
| @@ -0,0 +1,307 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "sync/atomic" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type roleEffectPhase uint8 | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | roleEffectsStable roleEffectPhase = iota | |
| 14 | + | roleEffectsFrozen | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // roleCapability is the single attachment-local publication consumed by role | |
| 18 | + | // effect admission. It deliberately contains no independently mutable state: | |
| 19 | + | // a ticket is admitted only when every field matches the frame's token. | |
| 20 | + | type roleCapability struct { | |
| 21 | + | sess *session | |
| 22 | + | role attachmentRole | |
| 23 | + | generation uint64 | |
| 24 | + | transport transportSnapshot | |
| 25 | + | lease *attachmentLease | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func capabilityFromToken(token attachmentRoleToken) roleCapability { | |
| 29 | + | return roleCapability{ | |
| 30 | + | sess: token.sess, | |
| 31 | + | role: token.role, | |
| 32 | + | generation: token.generation, | |
| 33 | + | transport: token.transport, | |
| 34 | + | lease: token.lease, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (c roleCapability) matches(token attachmentRoleToken) bool { | |
| 39 | + | return token.ac != nil && c.sess == token.sess && c.role == token.role && | |
| 40 | + | c.generation == token.generation && c.transport.transport == token.transport.transport && | |
| 41 | + | c.transport.incarnation == token.transport.incarnation && c.lease == token.lease | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type roleEffectGate struct { | |
| 45 | + | mu sync.Mutex | |
| 46 | + | cond *sync.Cond | |
| 47 | + | changed chan struct{} | |
| 48 | + | phase roleEffectPhase | |
| 49 | + | capability roleCapability | |
| 50 | + | inFlight uint64 | |
| 51 | + | transportEffects map[*roleEffectTicket]transportSnapshot | |
| 52 | + | failedTransport transportSnapshot | |
| 53 | + | order atomic.Uint64 | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var nextRoleEffectGateOrder atomic.Uint64 | |
| 57 | + | ||
| 58 | + | func (g *roleEffectGate) initLocked() { | |
| 59 | + | if g.cond == nil { | |
| 60 | + | g.cond = sync.NewCond(&g.mu) | |
| 61 | + | } | |
| 62 | + | if g.changed == nil { | |
| 63 | + | g.changed = make(chan struct{}) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (g *roleEffectGate) signalLocked() { | |
| 68 | + | g.initLocked() | |
| 69 | + | close(g.changed) | |
| 70 | + | g.changed = make(chan struct{}) | |
| 71 | + | g.cond.Broadcast() | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (g *roleEffectGate) immutableOrder() uint64 { | |
| 75 | + | if order := g.order.Load(); order != 0 { | |
| 76 | + | return order | |
| 77 | + | } | |
| 78 | + | order := nextRoleEffectGateOrder.Add(1) | |
| 79 | + | if g.order.CompareAndSwap(0, order) { | |
| 80 | + | return order | |
| 81 | + | } | |
| 82 | + | return g.order.Load() | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (g *roleEffectGate) inFlightCount() uint64 { | |
| 86 | + | if g == nil { | |
| 87 | + | return 0 | |
| 88 | + | } | |
| 89 | + | g.mu.Lock() | |
| 90 | + | defer g.mu.Unlock() | |
| 91 | + | return g.inFlight | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // roleEffectTicket reserves the exact published capability until End. End is | |
| 95 | + | // idempotent so error paths can safely defer it before doing any observable I/O. | |
| 96 | + | type roleEffectTicket struct { | |
| 97 | + | gate *roleEffectGate | |
| 98 | + | token attachmentRoleToken | |
| 99 | + | actionDaemon *Daemon | |
| 100 | + | action string | |
| 101 | + | ended atomic.Bool | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) { | |
| 105 | + | if t == nil || t.ended.Load() { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | t.actionDaemon = d | |
| 109 | + | t.action = action | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (t *roleEffectTicket) roleToken() attachmentRoleToken { | |
| 113 | + | if t == nil { | |
| 114 | + | return attachmentRoleToken{} | |
| 115 | + | } | |
| 116 | + | token := t.token | |
| 117 | + | token.effect = t | |
| 118 | + | return token | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (t *roleEffectTicket) End() { | |
| 122 | + | if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | g := t.gate | |
| 126 | + | g.mu.Lock() | |
| 127 | + | g.initLocked() | |
| 128 | + | if g.inFlight == 0 { | |
| 129 | + | g.mu.Unlock() | |
| 130 | + | panic("daemon: role effect ticket ended without admission") | |
| 131 | + | } | |
| 132 | + | delete(g.transportEffects, t) | |
| 133 | + | g.inFlight-- | |
| 134 | + | g.signalLocked() | |
| 135 | + | g.mu.Unlock() | |
| 136 | + | if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil { | |
| 137 | + | t.actionDaemon.afterActionRoleEffectEnded(t.action) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) { | |
| 142 | + | if effect != nil { | |
| 143 | + | effect.End() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // beginTransportSend marks only the interval spent in an external transport | |
| 148 | + | // send as interruptible. A replacement may close this exact published link to | |
| 149 | + | // release the send, but cannot retire an idle healthy snatched connection. | |
| 150 | + | func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool { | |
| 151 | + | if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil { | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | g := t.gate | |
| 155 | + | g.mu.Lock() | |
| 156 | + | g.initLocked() | |
| 157 | + | if t.ended.Load() || g.capability.transport.transport != expected.transport || | |
| 158 | + | g.capability.transport.incarnation != expected.incarnation { | |
| 159 | + | g.mu.Unlock() | |
| 160 | + | return false | |
| 161 | + | } | |
| 162 | + | if g.transportEffects == nil { | |
| 163 | + | g.transportEffects = make(map[*roleEffectTicket]transportSnapshot) | |
| 164 | + | } | |
| 165 | + | g.transportEffects[t] = expected | |
| 166 | + | g.signalLocked() | |
| 167 | + | g.mu.Unlock() | |
| 168 | + | return true | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) { | |
| 172 | + | if t == nil || t.gate == nil || expected.transport == nil { | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | g := t.gate | |
| 176 | + | g.mu.Lock() | |
| 177 | + | if !t.ended.Load() && g.capability.transport.transport == expected.transport && | |
| 178 | + | g.capability.transport.incarnation == expected.incarnation { | |
| 179 | + | g.failedTransport = expected | |
| 180 | + | g.signalLocked() | |
| 181 | + | } | |
| 182 | + | g.mu.Unlock() | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (t *roleEffectTicket) endTransportSend() { | |
| 186 | + | if t == nil || t.gate == nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | g := t.gate | |
| 190 | + | g.mu.Lock() | |
| 191 | + | if _, exists := g.transportEffects[t]; exists { | |
| 192 | + | delete(g.transportEffects, t) | |
| 193 | + | g.signalLocked() | |
| 194 | + | } | |
| 195 | + | g.mu.Unlock() | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // beginRoleEffect is the sole role-bound effect admission point. The gate is | |
| 199 | + | // held only long enough to reserve the capability; the effect itself must run | |
| 200 | + | // without the gate mutex. | |
| 201 | + | func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) { | |
| 202 | + | if ac == nil || token.ac != ac { | |
| 203 | + | return nil, false | |
| 204 | + | } | |
| 205 | + | g := &ac.roleEffects | |
| 206 | + | g.mu.Lock() | |
| 207 | + | g.initLocked() | |
| 208 | + | if g.phase != roleEffectsStable || !g.capability.matches(token) { | |
| 209 | + | g.mu.Unlock() | |
| 210 | + | return nil, false | |
| 211 | + | } | |
| 212 | + | g.inFlight++ | |
| 213 | + | g.mu.Unlock() | |
| 214 | + | captured := token | |
| 215 | + | captured.effect = nil | |
| 216 | + | return &roleEffectTicket{gate: g, token: captured}, true | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // beginCurrentRoleEffect waits out a transition that already froze ac, then | |
| 220 | + | // admits the role capability derived from the session registries. Handshakes | |
| 221 | + | // use it after Welcome has completed so a replacement blocked behind that send | |
| 222 | + | // can publish before readiness, first paint, or the snatched reset panel. | |
| 223 | + | func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transport) (attachmentRoleToken, *roleEffectTicket, bool) { | |
| 224 | + | if ac == nil || sess == nil || tr == nil { | |
| 225 | + | return attachmentRoleToken{}, nil, false | |
| 226 | + | } | |
| 227 | + | for { | |
| 228 | + | token := sess.attachmentToken(ac, tr) | |
| 229 | + | if token.role == attachmentDetached { | |
| 230 | + | return token, nil, false | |
| 231 | + | } | |
| 232 | + | if ticket, admitted := ac.beginRoleEffect(token); admitted { | |
| 233 | + | return token, ticket, true | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | g := &ac.roleEffects | |
| 237 | + | g.mu.Lock() | |
| 238 | + | g.initLocked() | |
| 239 | + | if g.phase == roleEffectsFrozen { | |
| 240 | + | changed := g.changed | |
| 241 | + | g.mu.Unlock() | |
| 242 | + | <-changed | |
| 243 | + | continue | |
| 244 | + | } | |
| 245 | + | g.mu.Unlock() | |
| 246 | + | // Publication may have completed between token capture and admission. | |
| 247 | + | // Re-read the registry and exact generation instead of using stale role | |
| 248 | + | // or lease authority. | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // publishRoleCapability is used by direct/headless setup and by transition | |
| 253 | + | // publication while the gate is frozen. Production role changes publish only | |
| 254 | + | // through publishFrozenRoleCapability. | |
| 255 | + | func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) { | |
| 256 | + | if ac == nil { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | g := &ac.roleEffects | |
| 260 | + | g.mu.Lock() | |
| 261 | + | g.initLocked() | |
| 262 | + | g.capability = capabilityFromToken(token) | |
| 263 | + | g.failedTransport = transportSnapshot{} | |
| 264 | + | g.mu.Unlock() | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // bootstrapRoleCapability supports direct/headless session construction. It | |
| 268 | + | // never changes an existing or frozen production publication. | |
| 269 | + | func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) { | |
| 270 | + | if ac == nil || token.sess == nil || token.role == attachmentDetached { | |
| 271 | + | return | |
| 272 | + | } | |
| 273 | + | g := &ac.roleEffects | |
| 274 | + | g.mu.Lock() | |
| 275 | + | g.initLocked() | |
| 276 | + | if g.phase == roleEffectsStable && g.capability.sess == nil { | |
| 277 | + | g.capability = capabilityFromToken(token) | |
| 278 | + | g.failedTransport = transportSnapshot{} | |
| 279 | + | } | |
| 280 | + | g.mu.Unlock() | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) { | |
| 284 | + | g := &ac.roleEffects | |
| 285 | + | g.mu.Lock() | |
| 286 | + | g.initLocked() | |
| 287 | + | if g.phase != roleEffectsFrozen { | |
| 288 | + | g.mu.Unlock() | |
| 289 | + | panic("daemon: publishing role capability without transition freeze") | |
| 290 | + | } | |
| 291 | + | g.capability = capabilityFromToken(token) | |
| 292 | + | g.failedTransport = transportSnapshot{} | |
| 293 | + | g.mu.Unlock() | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func (ac *attachedClient) invalidateFrozenRoleCapability() { | |
| 297 | + | g := &ac.roleEffects | |
| 298 | + | g.mu.Lock() | |
| 299 | + | g.initLocked() | |
| 300 | + | if g.phase != roleEffectsFrozen { | |
| 301 | + | g.mu.Unlock() | |
| 302 | + | panic("daemon: invalidating role capability without transition freeze") | |
| 303 | + | } | |
| 304 | + | g.capability = roleCapability{} | |
| 305 | + | g.failedTransport = transportSnapshot{} | |
| 306 | + | g.mu.Unlock() | |
| 307 | + | } | |
A internal/usecase/daemon/role_effect_gate_test.go +1475 -0
| @@ -0,0 +1,1475 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | + | "github.com/bnema/vev/pkg/renderer" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | |
| 20 | + | t.Helper() | |
| 21 | + | var once sync.Once | |
| 22 | + | release := func() { once.Do(func() { close(ch) }) } | |
| 23 | + | t.Cleanup(release) | |
| 24 | + | return release | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type blockedRenderReplacementTransport struct { | |
| 28 | + | sendEntered chan struct{} | |
| 29 | + | closed chan struct{} | |
| 30 | + | sendOnce sync.Once | |
| 31 | + | closeOnce sync.Once | |
| 32 | + | locksFree chan bool | |
| 33 | + | d *Daemon | |
| 34 | + | sess *session | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport { | |
| 38 | + | return &blockedRenderReplacementTransport{ | |
| 39 | + | sendEntered: make(chan struct{}), | |
| 40 | + | closed: make(chan struct{}), | |
| 41 | + | locksFree: make(chan bool, 1), | |
| 42 | + | d: d, | |
| 43 | + | sess: sess, | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (t *blockedRenderReplacementTransport) Send(ports.Frame) error { | |
| 48 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 49 | + | <-t.closed | |
| 50 | + | return errors.New("transport closed") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) { | |
| 54 | + | return ports.Frame{}, io.EOF | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (t *blockedRenderReplacementTransport) Close() error { | |
| 58 | + | locksFree := t.d.mu.TryLock() | |
| 59 | + | if locksFree { | |
| 60 | + | t.d.mu.Unlock() | |
| 61 | + | } | |
| 62 | + | if locksFree { | |
| 63 | + | locksFree = t.d.notices.routingMu.TryLock() | |
| 64 | + | if locksFree { | |
| 65 | + | t.d.notices.routingMu.Unlock() | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | if locksFree { | |
| 69 | + | locksFree = t.sess.mu.TryLock() | |
| 70 | + | if locksFree { | |
| 71 | + | t.sess.mu.Unlock() | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | if locksFree { | |
| 75 | + | if rc := t.sess.renderCoordinator(); rc != nil { | |
| 76 | + | locksFree = rc.mu.TryLock() | |
| 77 | + | if locksFree { | |
| 78 | + | rc.mu.Unlock() | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | t.closeOnce.Do(func() { | |
| 83 | + | t.locksFree <- locksFree | |
| 84 | + | close(t.closed) | |
| 85 | + | }) | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | type teardownBlockingTransport struct { | |
| 90 | + | sendEntered chan struct{} | |
| 91 | + | closed chan struct{} | |
| 92 | + | sendOnce sync.Once | |
| 93 | + | closeOnce sync.Once | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func newTeardownBlockingTransport() *teardownBlockingTransport { | |
| 97 | + | return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})} | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (t *teardownBlockingTransport) Send(ports.Frame) error { | |
| 101 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 102 | + | <-t.closed | |
| 103 | + | return errors.New("transport closed") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 107 | + | ||
| 108 | + | func (t *teardownBlockingTransport) Close() error { | |
| 109 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (t *teardownBlockingTransport) Closed() bool { | |
| 114 | + | select { | |
| 115 | + | case <-t.closed: | |
| 116 | + | return true | |
| 117 | + | default: | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} { | |
| 123 | + | t.Helper() | |
| 124 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 125 | + | require.True(t, admitted) | |
| 126 | + | done := make(chan struct{}) | |
| 127 | + | go func() { | |
| 128 | + | defer close(done) | |
| 129 | + | defer ticket.End() | |
| 130 | + | _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket) | |
| 131 | + | }() | |
| 132 | + | return done | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func requireRoleGateRetired(t *testing.T, ac *attachedClient) { | |
| 136 | + | t.Helper() | |
| 137 | + | ac.roleEffects.mu.Lock() | |
| 138 | + | defer ac.roleEffects.mu.Unlock() | |
| 139 | + | require.Equal(t, roleEffectsStable, ac.roleEffects.phase) | |
| 140 | + | require.Zero(t, ac.roleEffects.inFlight) | |
| 141 | + | require.Empty(t, ac.roleEffects.transportEffects) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) { | |
| 145 | + | ac.roleEffects.mu.Lock() | |
| 146 | + | defer ac.roleEffects.mu.Unlock() | |
| 147 | + | return ac.roleEffects.phase, ac.roleEffects.capability | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) { | |
| 151 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 152 | + | firstTransport := &closeTrackingTransport{} | |
| 153 | + | first.replaceTransport(firstTransport) | |
| 154 | + | ||
| 155 | + | blockedTransport := &closeTrackingTransport{} | |
| 156 | + | blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size} | |
| 157 | + | blocked.initOverlays() | |
| 158 | + | blocked.setSession(sess) | |
| 159 | + | laterTransport := &closeTrackingTransport{} | |
| 160 | + | later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size} | |
| 161 | + | later.initOverlays() | |
| 162 | + | later.setSession(sess) | |
| 163 | + | sess.mu.Lock() | |
| 164 | + | sess.addSnatchedLocked(blocked) | |
| 165 | + | sess.addSnatchedLocked(later) | |
| 166 | + | sess.mu.Unlock() | |
| 167 | + | ||
| 168 | + | rc := d.attachCoordinator(sess, nil, first, true) | |
| 169 | + | firstToken := sess.attachmentToken(first, firstTransport) | |
| 170 | + | firstToken.lease = rc.attachmentLease(first) | |
| 171 | + | first.publishRoleCapability(firstToken) | |
| 172 | + | blockedToken := sess.attachmentToken(blocked, blockedTransport) | |
| 173 | + | blocked.publishRoleCapability(blockedToken) | |
| 174 | + | laterToken := sess.attachmentToken(later, laterTransport) | |
| 175 | + | later.publishRoleCapability(laterToken) | |
| 176 | + | ||
| 177 | + | // Fix the canonical order so teardown acquires first, waits behind the gate | |
| 178 | + | // owned by this test, and never reaches the still-stable later participant. | |
| 179 | + | first.roleEffects.immutableOrder() | |
| 180 | + | blocked.roleEffects.immutableOrder() | |
| 181 | + | later.roleEffects.immutableOrder() | |
| 182 | + | require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load()) | |
| 183 | + | require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load()) | |
| 184 | + | ||
| 185 | + | blockedOwner := freezeRoleEffectGates(blocked) | |
| 186 | + | blockedOwnerHeld := true | |
| 187 | + | t.Cleanup(func() { | |
| 188 | + | if blockedOwnerHeld { | |
| 189 | + | blockedOwner.unfreeze() | |
| 190 | + | } | |
| 191 | + | }) | |
| 192 | + | ||
| 193 | + | firstPhase, firstCapability := roleGatePublicationSnapshot(first) | |
| 194 | + | blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked) | |
| 195 | + | laterPhase, laterCapability := roleGatePublicationSnapshot(later) | |
| 196 | + | require.Equal(t, roleEffectsStable, firstPhase) | |
| 197 | + | require.Equal(t, roleEffectsFrozen, blockedPhase) | |
| 198 | + | require.Equal(t, roleEffectsStable, laterPhase) | |
| 199 | + | firstGeneration := first.roleGeneration.Load() | |
| 200 | + | blockedGeneration := blocked.roleGeneration.Load() | |
| 201 | + | laterGeneration := later.roleGeneration.Load() | |
| 202 | + | firstSnapshot := first.transportSnapshot() | |
| 203 | + | blockedSnapshot := blocked.transportSnapshot() | |
| 204 | + | laterSnapshot := later.transportSnapshot() | |
| 205 | + | ||
| 206 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 207 | + | d.clock = clock | |
| 208 | + | frozenByKill := make(chan *attachedClient, 3) | |
| 209 | + | d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac } | |
| 210 | + | killDone := make(chan error, 1) | |
| 211 | + | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 212 | + | ||
| 213 | + | require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate")) | |
| 214 | + | deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline") | |
| 215 | + | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 216 | + | deadline.ch <- time.Now() | |
| 217 | + | select { | |
| 218 | + | case err := <-killDone: | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | case <-time.After(time.Second): | |
| 221 | + | t.Fatal("teardown did not abort after partial gate acquisition timed out") | |
| 222 | + | } | |
| 223 | + | require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired") | |
| 224 | + | ||
| 225 | + | d.mu.Lock() | |
| 226 | + | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | |
| 227 | + | d.mu.Unlock() | |
| 228 | + | sess.mu.Lock() | |
| 229 | + | require.Same(t, first, sess.client) | |
| 230 | + | require.Contains(t, sess.snatched, blocked) | |
| 231 | + | require.Contains(t, sess.snatched, later) | |
| 232 | + | sess.mu.Unlock() | |
| 233 | + | for _, ac := range []*attachedClient{first, blocked, later} { | |
| 234 | + | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | |
| 235 | + | } | |
| 236 | + | require.Equal(t, firstGeneration, first.roleGeneration.Load()) | |
| 237 | + | require.Equal(t, blockedGeneration, blocked.roleGeneration.Load()) | |
| 238 | + | require.Equal(t, laterGeneration, later.roleGeneration.Load()) | |
| 239 | + | require.True(t, first.transportSnapshotCurrent(firstSnapshot)) | |
| 240 | + | require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot)) | |
| 241 | + | require.True(t, later.transportSnapshotCurrent(laterSnapshot)) | |
| 242 | + | require.False(t, firstTransport.Closed()) | |
| 243 | + | require.False(t, blockedTransport.Closed()) | |
| 244 | + | require.False(t, laterTransport.Closed()) | |
| 245 | + | ||
| 246 | + | firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first) | |
| 247 | + | blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked) | |
| 248 | + | laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later) | |
| 249 | + | require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back") | |
| 250 | + | require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition") | |
| 251 | + | require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated") | |
| 252 | + | require.Equal(t, firstCapability, gotFirstCapability) | |
| 253 | + | require.Equal(t, blockedCapability, gotBlockedCapability) | |
| 254 | + | require.Equal(t, laterCapability, gotLaterCapability) | |
| 255 | + | ||
| 256 | + | blockedOwner.unfreeze() | |
| 257 | + | blockedOwnerHeld = false | |
| 258 | + | complete := freezeRoleEffectGates(first, blocked, later) | |
| 259 | + | require.True(t, complete.acquired, "rolled-back gate set could not be acquired again") | |
| 260 | + | require.True(t, complete.drained) | |
| 261 | + | complete.unfreeze() | |
| 262 | + | for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} { | |
| 263 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 264 | + | require.True(t, admitted, "pre-publication abort changed a role capability") | |
| 265 | + | ticket.End() | |
| 266 | + | requireRoleGateRetired(t, token.ac) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |
| 271 | + | d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 272 | + | snatchedTransport := newTeardownBlockingTransport() | |
| 273 | + | snatched.replaceTransport(snatchedTransport) | |
| 274 | + | d.attachCoordinator(sess, nil, snatched, true) | |
| 275 | + | snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 276 | + | ||
| 277 | + | activeTransport := newTeardownBlockingTransport() | |
| 278 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size} | |
| 279 | + | active.initOverlays() | |
| 280 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 281 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 282 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 283 | + | }) | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | defer d.deferAttachmentTransitionCleanups(transition) | |
| 286 | + | ||
| 287 | + | unrelatedTransport := &closeTrackingTransport{} | |
| 288 | + | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | |
| 289 | + | unrelatedClient.initOverlays() | |
| 290 | + | unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient} | |
| 291 | + | unrelatedClient.setSession(unrelated) | |
| 292 | + | d.mu.Lock() | |
| 293 | + | d.sessions[unrelated.id] = unrelated | |
| 294 | + | d.mu.Unlock() | |
| 295 | + | d.attachCoordinator(unrelated, nil, unrelatedClient, true) | |
| 296 | + | ||
| 297 | + | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 298 | + | activeToken := sess.attachmentToken(active, activeTransport) | |
| 299 | + | snatchedDone := startBlockedRoleSend(t, snatchedToken) | |
| 300 | + | activeDone := startBlockedRoleSend(t, activeToken) | |
| 301 | + | awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block") | |
| 302 | + | awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block") | |
| 303 | + | ||
| 304 | + | killDone := make(chan error, 1) | |
| 305 | + | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 306 | + | select { | |
| 307 | + | case err := <-killDone: | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | case <-time.After(500 * time.Millisecond): | |
| 310 | + | _ = snatchedTransport.Close() | |
| 311 | + | _ = activeTransport.Close() | |
| 312 | + | t.Fatal("session kill remained pinned behind admitted participant sends") | |
| 313 | + | } | |
| 314 | + | awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire") | |
| 315 | + | awaitTestCompletion(t, activeDone, "active role ticket did not retire") | |
| 316 | + | d.waitNotifies() | |
| 317 | + | ||
| 318 | + | require.True(t, snatchedTransport.Closed()) | |
| 319 | + | require.True(t, activeTransport.Closed()) | |
| 320 | + | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | |
| 321 | + | require.Same(t, unrelatedClient, unrelated.client) | |
| 322 | + | require.Same(t, unrelated, unrelatedClient.currentSession()) | |
| 323 | + | requireRoleGateRetired(t, snatched) | |
| 324 | + | requireRoleGateRetired(t, active) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) { | |
| 328 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 329 | + | stale := newTeardownBlockingTransport() | |
| 330 | + | ac.replaceTransport(stale) | |
| 331 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 332 | + | ac.publishRoleCapability(sess.attachmentToken(ac, stale)) | |
| 333 | + | ||
| 334 | + | token := sess.attachmentToken(ac, stale) | |
| 335 | + | sendDone := startBlockedRoleSend(t, token) | |
| 336 | + | awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block") | |
| 337 | + | fresh := &closeTrackingTransport{} | |
| 338 | + | d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| 339 | + | ac.replaceTransport(fresh) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 343 | + | awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire") | |
| 344 | + | require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted") | |
| 345 | + | require.False(t, fresh.Closed(), "newer transport incarnation was interrupted") | |
| 346 | + | require.Same(t, fresh, ac.transport()) | |
| 347 | + | d.mu.Lock() | |
| 348 | + | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | |
| 349 | + | d.mu.Unlock() | |
| 350 | + | sess.mu.Lock() | |
| 351 | + | require.Same(t, ac, sess.client) | |
| 352 | + | sess.mu.Unlock() | |
| 353 | + | requireRoleGateRetired(t, ac) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |
| 357 | + | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 358 | + | blocked := newTeardownBlockingTransport() | |
| 359 | + | active.replaceTransport(blocked) | |
| 360 | + | d.attachCoordinator(sess, nil, active, true) | |
| 361 | + | active.publishRoleCapability(sess.attachmentToken(active, blocked)) | |
| 362 | + | ||
| 363 | + | token := sess.attachmentToken(active, blocked) | |
| 364 | + | sendDone := startBlockedRoleSend(t, token) | |
| 365 | + | awaitTestCompletion(t, blocked.sendEntered, "active send did not block") | |
| 366 | + | ||
| 367 | + | shutdownDone := make(chan bool, 1) | |
| 368 | + | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 369 | + | select { | |
| 370 | + | case incomplete := <-shutdownDone: | |
| 371 | + | require.False(t, incomplete) | |
| 372 | + | case <-time.After(500 * time.Millisecond): | |
| 373 | + | _ = blocked.Close() | |
| 374 | + | t.Fatal("shutdown remained pinned behind admitted participant send") | |
| 375 | + | } | |
| 376 | + | awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire") | |
| 377 | + | d.waitNotifies() | |
| 378 | + | require.True(t, blocked.Closed()) | |
| 379 | + | requireRoleGateRetired(t, active) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | type renderFailureTransport struct { | |
| 383 | + | mu sync.Mutex | |
| 384 | + | closed bool | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") } | |
| 388 | + | func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 389 | + | func (t *renderFailureTransport) Close() error { | |
| 390 | + | t.mu.Lock() | |
| 391 | + | t.closed = true | |
| 392 | + | t.mu.Unlock() | |
| 393 | + | return nil | |
| 394 | + | } | |
| 395 | + | func (t *renderFailureTransport) Closed() bool { | |
| 396 | + | t.mu.Lock() | |
| 397 | + | defer t.mu.Unlock() | |
| 398 | + | return t.closed | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing.T) { | |
| 402 | + | d, sess, original, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 403 | + | failedTransport := &renderFailureTransport{} | |
| 404 | + | original.replaceTransport(failedTransport) | |
| 405 | + | rc := d.attachCoordinator(sess, nil, original, true) | |
| 406 | + | originalToken := sess.attachmentToken(original, failedTransport) | |
| 407 | + | originalToken.lease = rc.attachmentLease(original) | |
| 408 | + | original.publishRoleCapability(originalToken) | |
| 409 | + | ||
| 410 | + | cleanupStarted := make(chan struct{}) | |
| 411 | + | releaseCleanup := make(chan struct{}) | |
| 412 | + | cleanupDone := make(chan struct{}) | |
| 413 | + | d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) { | |
| 414 | + | require.Equal(t, originalToken.generation, token.generation) | |
| 415 | + | require.Equal(t, originalToken.transport, token.transport) | |
| 416 | + | require.Same(t, originalToken.lease, token.lease) | |
| 417 | + | close(cleanupStarted) | |
| 418 | + | <-releaseCleanup | |
| 419 | + | } | |
| 420 | + | d.afterRoleSendErrorCleanup = func() { close(cleanupDone) } | |
| 421 | + | ||
| 422 | + | d.paint(sess, original, true, originalToken.lease) | |
| 423 | + | awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token") | |
| 424 | + | cleanupTracked := waitGroupDone(&d.attachmentCleanupWg) | |
| 425 | + | select { | |
| 426 | + | case <-cleanupTracked: | |
| 427 | + | t.Fatal("paused render failure cleanup was not tracked by attachmentCleanupWg") | |
| 428 | + | default: | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | replacementTransport := &closeTrackingTransport{} | |
| 432 | + | replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size} | |
| 433 | + | replacement.initOverlays() | |
| 434 | + | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 435 | + | target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 436 | + | expectedTransport: replacement.transportSnapshot(), ready: true, | |
| 437 | + | }) | |
| 438 | + | require.NoError(t, err) | |
| 439 | + | ||
| 440 | + | resumed, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 441 | + | target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 442 | + | expectedTransport: original.transportSnapshot(), ready: true, | |
| 443 | + | }) | |
| 444 | + | require.NoError(t, err) | |
| 445 | + | resumedGeneration := original.roleGeneration.Load() | |
| 446 | + | resumedLease := resumed.published.lease | |
| 447 | + | require.NotSame(t, originalToken.lease, resumedLease) | |
| 448 | + | ||
| 449 | + | close(releaseCleanup) | |
| 450 | + | awaitTestCompletion(t, cleanupDone, "render failure cleanup did not finish") | |
| 451 | + | awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire") | |
| 452 | + | ||
| 453 | + | sess.mu.Lock() | |
| 454 | + | require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation") | |
| 455 | + | sess.mu.Unlock() | |
| 456 | + | require.Equal(t, resumedGeneration, original.roleGeneration.Load()) | |
| 457 | + | require.True(t, resumed.published.activeCurrent()) | |
| 458 | + | require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport") | |
| 459 | + | ||
| 460 | + | d.deferAttachmentTransitionCleanups(displaced) | |
| 461 | + | d.deferAttachmentTransitionCleanups(resumed) | |
| 462 | + | d.attachmentCleanupWg.Wait() | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) { | |
| 466 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 467 | + | transport := &closeTrackingTransport{} | |
| 468 | + | ac.replaceTransport(transport) | |
| 469 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 470 | + | token := sess.attachmentToken(ac, transport) | |
| 471 | + | token.lease = rc.attachmentLease(ac) | |
| 472 | + | ac.publishRoleCapability(token) | |
| 473 | + | ||
| 474 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 475 | + | d.clock = clock | |
| 476 | + | frozen := freezeRoleEffectGates(ac) | |
| 477 | + | defer frozen.unfreeze() | |
| 478 | + | cleanupDone := make(chan struct{}) | |
| 479 | + | d.afterRoleSendErrorCleanup = func() { close(cleanupDone) } | |
| 480 | + | ||
| 481 | + | launchCleanup := d.reserveRoleSendErrorCleanup(token, transport) | |
| 482 | + | launchCleanup() | |
| 483 | + | deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline") | |
| 484 | + | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 485 | + | deadline.ch <- time.Now() | |
| 486 | + | awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup") | |
| 487 | + | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | |
| 488 | + | ||
| 489 | + | sess.mu.Lock() | |
| 490 | + | require.Same(t, ac, sess.client) | |
| 491 | + | sess.mu.Unlock() | |
| 492 | + | require.False(t, transport.Closed()) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) { | |
| 496 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 497 | + | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 498 | + | old.replaceTransport(oldTransport) | |
| 499 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 500 | + | lease := rc.attachmentLease(old) | |
| 501 | + | token := sess.attachmentToken(old, oldTransport) | |
| 502 | + | token.lease = lease | |
| 503 | + | old.publishRoleCapability(token) | |
| 504 | + | ||
| 505 | + | paintDone := make(chan struct{}) | |
| 506 | + | go func() { | |
| 507 | + | d.paint(sess, old, true, lease) | |
| 508 | + | close(paintDone) | |
| 509 | + | }() | |
| 510 | + | <-oldTransport.sendEntered | |
| 511 | + | ||
| 512 | + | newTransport := &closeTrackingTransport{} | |
| 513 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 514 | + | next.initOverlays() | |
| 515 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 516 | + | transitionErr := make(chan error, 1) | |
| 517 | + | go func() { | |
| 518 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 519 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 520 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 521 | + | }) | |
| 522 | + | transitionDone <- result | |
| 523 | + | transitionErr <- err | |
| 524 | + | }() | |
| 525 | + | ||
| 526 | + | var result attachmentTransitionResult | |
| 527 | + | select { | |
| 528 | + | case result = <-transitionDone: | |
| 529 | + | case <-time.After(time.Second): | |
| 530 | + | t.Fatal("replacement publication remained blocked behind the old render send") | |
| 531 | + | } | |
| 532 | + | require.NoError(t, <-transitionErr) | |
| 533 | + | require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held") | |
| 534 | + | select { | |
| 535 | + | case <-paintDone: | |
| 536 | + | case <-time.After(time.Second): | |
| 537 | + | t.Fatal("closing the exact old transport did not unblock its render") | |
| 538 | + | } | |
| 539 | + | require.Same(t, next, sess.client) | |
| 540 | + | require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption") | |
| 541 | + | ||
| 542 | + | d.deferAttachmentTransitionCleanups(result) | |
| 543 | + | d.attachmentCleanupWg.Wait() | |
| 544 | + | require.Nil(t, old.transport(), "the unhealthy old link was not retired") | |
| 545 | + | require.Same(t, next, sess.client) | |
| 546 | + | require.False(t, newTransport.Closed()) | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) { | |
| 550 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 551 | + | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 552 | + | old.replaceTransport(oldTransport) | |
| 553 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 554 | + | lease := rc.attachmentLease(old) | |
| 555 | + | token := sess.attachmentToken(old, oldTransport) | |
| 556 | + | token.lease = lease | |
| 557 | + | old.publishRoleCapability(token) | |
| 558 | + | old.sendMu.Lock() | |
| 559 | + | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 560 | + | old.sendMu.Unlock() | |
| 561 | + | ||
| 562 | + | staleDetachFrozen := make(chan struct{}) | |
| 563 | + | releaseStaleDetach := make(chan struct{}) | |
| 564 | + | var releaseOnce sync.Once | |
| 565 | + | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) }) | |
| 566 | + | d.afterDetachRoleEffectsFrozen = func() { | |
| 567 | + | close(staleDetachFrozen) | |
| 568 | + | <-releaseStaleDetach | |
| 569 | + | } | |
| 570 | + | cleanupStarted := make(chan struct{}) | |
| 571 | + | d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) } | |
| 572 | + | ||
| 573 | + | paintDone := make(chan struct{}) | |
| 574 | + | go func() { | |
| 575 | + | d.paint(sess, old, true, lease) | |
| 576 | + | close(paintDone) | |
| 577 | + | }() | |
| 578 | + | awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send") | |
| 579 | + | ||
| 580 | + | newTransport := &closeTrackingTransport{} | |
| 581 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 582 | + | next.initOverlays() | |
| 583 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 584 | + | transitionErr := make(chan error, 1) | |
| 585 | + | go func() { | |
| 586 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 587 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 588 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 589 | + | }) | |
| 590 | + | transitionDone <- result | |
| 591 | + | transitionErr <- err | |
| 592 | + | }() | |
| 593 | + | ||
| 594 | + | var result attachmentTransitionResult | |
| 595 | + | select { | |
| 596 | + | case result = <-transitionDone: | |
| 597 | + | require.NoError(t, <-transitionErr) | |
| 598 | + | case <-time.After(time.Second): | |
| 599 | + | t.Fatal("replacement did not interrupt the blocked render") | |
| 600 | + | } | |
| 601 | + | awaitTestCompletion(t, paintDone, "interrupted render did not retire") | |
| 602 | + | awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate") | |
| 603 | + | ||
| 604 | + | d.deferAttachmentTransitionCleanups(result) | |
| 605 | + | awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze") | |
| 606 | + | releaseOnce.Do(func() { close(releaseStaleDetach) }) | |
| 607 | + | select { | |
| 608 | + | case <-waitGroupDone(&d.attachmentCleanupWg): | |
| 609 | + | case <-time.After(time.Second): | |
| 610 | + | t.Fatal("displaced cleanup deadlocked behind stale detach") | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | sess.mu.Lock() | |
| 614 | + | require.Same(t, next, sess.client) | |
| 615 | + | require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered") | |
| 616 | + | sess.mu.Unlock() | |
| 617 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 618 | + | require.Nil(t, old.transport(), "closed displaced transport remained attached") | |
| 619 | + | require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership") | |
| 620 | + | old.sendMu.Lock() | |
| 621 | + | require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures") | |
| 622 | + | old.sendMu.Unlock() | |
| 623 | + | require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner") | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) { | |
| 627 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 628 | + | staleTransport := &closeTrackingTransport{} | |
| 629 | + | old.replaceTransport(staleTransport) | |
| 630 | + | d.attachCoordinator(sess, nil, old, true) | |
| 631 | + | ||
| 632 | + | nextTransport := &closeTrackingTransport{} | |
| 633 | + | next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size} | |
| 634 | + | next.initOverlays() | |
| 635 | + | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 636 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 637 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 638 | + | }) | |
| 639 | + | require.NoError(t, err) | |
| 640 | + | stale := displaced.displaced | |
| 641 | + | ||
| 642 | + | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 643 | + | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 644 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 645 | + | }) | |
| 646 | + | require.NoError(t, err) | |
| 647 | + | freshTransport := &closeTrackingTransport{} | |
| 648 | + | old.replaceTransport(freshTransport) | |
| 649 | + | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 650 | + | target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 651 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 652 | + | }) | |
| 653 | + | require.NoError(t, err) | |
| 654 | + | require.NoError(t, staleTransport.Close()) | |
| 655 | + | ||
| 656 | + | require.False(t, d.cleanupInterruptedSnatchedAttachment(stale)) | |
| 657 | + | sess.mu.Lock() | |
| 658 | + | require.Same(t, old, sess.client) | |
| 659 | + | require.Contains(t, sess.snatched, next) | |
| 660 | + | sess.mu.Unlock() | |
| 661 | + | require.Equal(t, attachmentActive, sess.attachmentRole(old)) | |
| 662 | + | require.Same(t, freshTransport, old.transport()) | |
| 663 | + | require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport") | |
| 664 | + | require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner") | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | |
| 668 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 669 | + | oldTransport := newDatagramTestTransport() | |
| 670 | + | old.replaceTransport(oldTransport) | |
| 671 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 672 | + | token := sess.attachmentToken(old, oldTransport) | |
| 673 | + | token.lease = rc.attachmentLease(old) | |
| 674 | + | old.publishRoleCapability(token) | |
| 675 | + | ||
| 676 | + | ticket, ok := old.beginRoleEffect(token) | |
| 677 | + | require.True(t, ok) | |
| 678 | + | ||
| 679 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 680 | + | next.initOverlays() | |
| 681 | + | done := make(chan error, 1) | |
| 682 | + | go func() { | |
| 683 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 684 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 685 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 686 | + | }) | |
| 687 | + | done <- err | |
| 688 | + | }() | |
| 689 | + | ||
| 690 | + | select { | |
| 691 | + | case err := <-done: | |
| 692 | + | t.Fatalf("transition committed before admitted effect ended: %v", err) | |
| 693 | + | case <-time.After(20 * time.Millisecond): | |
| 694 | + | } | |
| 695 | + | ticket.End() | |
| 696 | + | require.NoError(t, <-done) | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) { | |
| 700 | + | tests := []struct { | |
| 701 | + | name string | |
| 702 | + | setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY) | |
| 703 | + | frame ports.Frame | |
| 704 | + | assert func(*testing.T, *attachedClient, *transactionalResizePTY) | |
| 705 | + | }{ | |
| 706 | + | { | |
| 707 | + | name: "PTY input", | |
| 708 | + | frame: frameInput([]byte("reserved")), | |
| 709 | + | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 710 | + | require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes()) | |
| 711 | + | }, | |
| 712 | + | }, | |
| 713 | + | { | |
| 714 | + | name: "mouse input", | |
| 715 | + | setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) { | |
| 716 | + | p := sess.activeTab().focusedPane() | |
| 717 | + | p.mu.Lock() | |
| 718 | + | p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 719 | + | p.mu.Unlock() | |
| 720 | + | }, | |
| 721 | + | frame: frameInput([]byte("\x1b[<0;1;2M")), | |
| 722 | + | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 723 | + | require.NotEmpty(t, pty.writes()) | |
| 724 | + | }, | |
| 725 | + | }, | |
| 726 | + | { | |
| 727 | + | name: "key action", | |
| 728 | + | frame: frameInput([]byte("\x1b ")), | |
| 729 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 730 | + | require.True(t, ac.overlays.paletteActive()) | |
| 731 | + | }, | |
| 732 | + | }, | |
| 733 | + | { | |
| 734 | + | name: "overlay mutation", | |
| 735 | + | setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 736 | + | d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil }) | |
| 737 | + | }, | |
| 738 | + | frame: frameInput([]byte("x")), | |
| 739 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 740 | + | ac.overlays.promptMu.Lock() | |
| 741 | + | defer ac.overlays.promptMu.Unlock() | |
| 742 | + | require.Equal(t, "x", ac.overlays.prompt.Value()) | |
| 743 | + | }, | |
| 744 | + | }, | |
| 745 | + | { | |
| 746 | + | name: "theme", | |
| 747 | + | frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 748 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 749 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 750 | + | })}, | |
| 751 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 752 | + | require.True(t, ac.getClientTheme().HasFG) | |
| 753 | + | }, | |
| 754 | + | }, | |
| 755 | + | { | |
| 756 | + | name: "ACK", | |
| 757 | + | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 758 | + | ac.output.next = 3 | |
| 759 | + | }, | |
| 760 | + | frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}, | |
| 761 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 762 | + | ac.sendMu.Lock() | |
| 763 | + | defer ac.sendMu.Unlock() | |
| 764 | + | require.Equal(t, uint64(1), ac.output.acked) | |
| 765 | + | }, | |
| 766 | + | }, | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | for _, tt := range tests { | |
| 770 | + | t.Run(tt.name, func(t *testing.T) { | |
| 771 | + | pty := &transactionalResizePTY{} | |
| 772 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 773 | + | oldTransport := newDatagramTestTransport() | |
| 774 | + | old.replaceTransport(oldTransport) | |
| 775 | + | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 776 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 777 | + | token := sess.attachmentToken(old, oldTransport) | |
| 778 | + | token.lease = rc.attachmentLease(old) | |
| 779 | + | if tt.setup != nil { | |
| 780 | + | tt.setup(d, sess, old, pty) | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | admitted := make(chan struct{}) | |
| 784 | + | release := make(chan struct{}) | |
| 785 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 786 | + | close(admitted) | |
| 787 | + | <-release | |
| 788 | + | } | |
| 789 | + | effectDone := make(chan struct{}) | |
| 790 | + | go func() { | |
| 791 | + | d.handleActiveClientFrame(token, tt.frame) | |
| 792 | + | close(effectDone) | |
| 793 | + | }() | |
| 794 | + | <-admitted | |
| 795 | + | ||
| 796 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 797 | + | next.initOverlays() | |
| 798 | + | transitionDone := make(chan error, 1) | |
| 799 | + | go func() { | |
| 800 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 801 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 802 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 803 | + | }) | |
| 804 | + | transitionDone <- err | |
| 805 | + | }() | |
| 806 | + | select { | |
| 807 | + | case err := <-transitionDone: | |
| 808 | + | t.Fatalf("transition overtook admitted effect: %v", err) | |
| 809 | + | case <-time.After(20 * time.Millisecond): | |
| 810 | + | } | |
| 811 | + | close(release) | |
| 812 | + | <-effectDone | |
| 813 | + | require.NoError(t, <-transitionDone) | |
| 814 | + | tt.assert(t, old, pty) | |
| 815 | + | }) | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) { | |
| 820 | + | d, sess, old, _, releases := newManualTabSession(t, 2) | |
| 821 | + | defer releaseAll(releases) | |
| 822 | + | sess.mu.Lock() | |
| 823 | + | sess.tabs[1].attention = true | |
| 824 | + | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| 825 | + | sess.mu.Unlock() | |
| 826 | + | ||
| 827 | + | oldTransport := old.transport() | |
| 828 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 829 | + | token := sess.attachmentToken(old, oldTransport) | |
| 830 | + | token.lease = rc.attachmentLease(old) | |
| 831 | + | old.publishRoleCapability(token) | |
| 832 | + | ||
| 833 | + | admissionEnded := make(chan struct{}) | |
| 834 | + | releaseAction := make(chan struct{}) | |
| 835 | + | release := releaseTestGate(t, releaseAction) | |
| 836 | + | var admissionEndedOnce sync.Once | |
| 837 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 838 | + | if action != "jump-attention" { | |
| 839 | + | return | |
| 840 | + | } | |
| 841 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 842 | + | <-releaseAction | |
| 843 | + | } | |
| 844 | + | actionDone := make(chan struct{}) | |
| 845 | + | go func() { | |
| 846 | + | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 847 | + | close(actionDone) | |
| 848 | + | }() | |
| 849 | + | <-admissionEnded | |
| 850 | + | ||
| 851 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 852 | + | next.initOverlays() | |
| 853 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 854 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 855 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 856 | + | }) | |
| 857 | + | require.NoError(t, err) | |
| 858 | + | sess.mu.Lock() | |
| 859 | + | sess.active = 0 | |
| 860 | + | sess.mu.Unlock() | |
| 861 | + | release() | |
| 862 | + | <-actionDone | |
| 863 | + | ||
| 864 | + | require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role") | |
| 865 | + | d.deferAttachmentTransitionCleanups(result) | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |
| 869 | + | p1, release1 := newBlockingPTY(t) | |
| 870 | + | p2, release2 := newBlockingPTY(t) | |
| 871 | + | p3, release3 := newBlockingPTY(t) | |
| 872 | + | defer release1() | |
| 873 | + | defer release2() | |
| 874 | + | defer release3() | |
| 875 | + | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 876 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 877 | + | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 878 | + | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 879 | + | }} | |
| 880 | + | target.tabs[1].attention = true | |
| 881 | + | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 882 | + | d.sessions[target.id] = target | |
| 883 | + | ||
| 884 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 885 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 886 | + | token.lease = rc.attachmentLease(ac) | |
| 887 | + | ac.publishRoleCapability(token) | |
| 888 | + | ||
| 889 | + | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 890 | + | ||
| 891 | + | require.Same(t, target, ac.currentSession()) | |
| 892 | + | require.Equal(t, 1, activeTabIndex(target)) | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) { | |
| 896 | + | p1, release1 := newBlockingPTY(t) | |
| 897 | + | p2, release2 := newBlockingPTY(t) | |
| 898 | + | p3, release3 := newBlockingPTY(t) | |
| 899 | + | defer release1() | |
| 900 | + | defer release2() | |
| 901 | + | defer release3() | |
| 902 | + | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 903 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 904 | + | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 905 | + | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 906 | + | }} | |
| 907 | + | target.tabs[1].attention = true | |
| 908 | + | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 909 | + | d.sessions[target.id] = target | |
| 910 | + | ||
| 911 | + | oldTransport := old.transport() | |
| 912 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 913 | + | token := source.attachmentToken(old, oldTransport) | |
| 914 | + | token.lease = rc.attachmentLease(old) | |
| 915 | + | old.publishRoleCapability(token) | |
| 916 | + | ||
| 917 | + | admissionEnded := make(chan struct{}) | |
| 918 | + | releaseAction := make(chan struct{}) | |
| 919 | + | release := releaseTestGate(t, releaseAction) | |
| 920 | + | var admissionEndedOnce sync.Once | |
| 921 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 922 | + | if action == "jump-attention" { | |
| 923 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 924 | + | <-releaseAction | |
| 925 | + | } | |
| 926 | + | } | |
| 927 | + | actionDone := make(chan struct{}) | |
| 928 | + | go func() { | |
| 929 | + | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 930 | + | close(actionDone) | |
| 931 | + | }() | |
| 932 | + | <-admissionEnded | |
| 933 | + | ||
| 934 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 935 | + | next.initOverlays() | |
| 936 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 937 | + | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 938 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 939 | + | }) | |
| 940 | + | require.NoError(t, err) | |
| 941 | + | release() | |
| 942 | + | <-actionDone | |
| 943 | + | ||
| 944 | + | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | |
| 945 | + | target.mu.Lock() | |
| 946 | + | targetClient := target.client | |
| 947 | + | target.mu.Unlock() | |
| 948 | + | require.Nil(t, targetClient, "stale handoff attached the replaced initiator") | |
| 949 | + | d.deferAttachmentTransitionCleanups(result) | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | |
| 953 | + | p, releasePTY := newBlockingPTY(t) | |
| 954 | + | defer releasePTY() | |
| 955 | + | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 956 | + | d.enterPicker(sess, old) | |
| 957 | + | ||
| 958 | + | oldTransport := old.transport() | |
| 959 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 960 | + | token := sess.attachmentToken(old, oldTransport) | |
| 961 | + | token.lease = rc.attachmentLease(old) | |
| 962 | + | old.publishRoleCapability(token) | |
| 963 | + | ||
| 964 | + | admissionEnded := make(chan struct{}) | |
| 965 | + | releaseAction := make(chan struct{}) | |
| 966 | + | release := releaseTestGate(t, releaseAction) | |
| 967 | + | var admissionEndedOnce sync.Once | |
| 968 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 969 | + | if action != "picker-delete" { | |
| 970 | + | return | |
| 971 | + | } | |
| 972 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 973 | + | <-releaseAction | |
| 974 | + | } | |
| 975 | + | actionDone := make(chan struct{}) | |
| 976 | + | go func() { | |
| 977 | + | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 978 | + | close(actionDone) | |
| 979 | + | }() | |
| 980 | + | <-admissionEnded | |
| 981 | + | ||
| 982 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 983 | + | next.initOverlays() | |
| 984 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 985 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 986 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 987 | + | }) | |
| 988 | + | require.NoError(t, err) | |
| 989 | + | release() | |
| 990 | + | <-actionDone | |
| 991 | + | ||
| 992 | + | d.mu.Lock() | |
| 993 | + | registered := d.sessions[sess.id] | |
| 994 | + | d.mu.Unlock() | |
| 995 | + | require.Same(t, sess, registered, "the replaced initiator deleted its former source session") | |
| 996 | + | d.deferAttachmentTransitionCleanups(result) | |
| 997 | + | } | |
| 998 | + | ||
| 999 | + | func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) { | |
| 1000 | + | p1, release1 := newBlockingPTY(t) | |
| 1001 | + | p2, release2 := newBlockingPTY(t) | |
| 1002 | + | defer release1() | |
| 1003 | + | defer release2() | |
| 1004 | + | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 1005 | + | source.name = "source" | |
| 1006 | + | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 1007 | + | d.sessions[target.id] = target | |
| 1008 | + | d.enterPicker(source, old) | |
| 1009 | + | old.overlays.pickerMu.Lock() | |
| 1010 | + | for { | |
| 1011 | + | selected, ok := old.overlays.picker.Selected() | |
| 1012 | + | if ok && selected.Session == target.id { | |
| 1013 | + | break | |
| 1014 | + | } | |
| 1015 | + | old.overlays.picker.Down() | |
| 1016 | + | } | |
| 1017 | + | old.overlays.pickerMu.Unlock() | |
| 1018 | + | ||
| 1019 | + | oldTransport := old.transport() | |
| 1020 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 1021 | + | token := source.attachmentToken(old, oldTransport) | |
| 1022 | + | token.lease = rc.attachmentLease(old) | |
| 1023 | + | old.publishRoleCapability(token) | |
| 1024 | + | ||
| 1025 | + | admissionEnded := make(chan struct{}) | |
| 1026 | + | releaseAction := make(chan struct{}) | |
| 1027 | + | release := releaseTestGate(t, releaseAction) | |
| 1028 | + | var admissionEndedOnce sync.Once | |
| 1029 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 1030 | + | if action == "picker-delete" { | |
| 1031 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 1032 | + | <-releaseAction | |
| 1033 | + | } | |
| 1034 | + | } | |
| 1035 | + | actionDone := make(chan struct{}) | |
| 1036 | + | go func() { | |
| 1037 | + | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1038 | + | close(actionDone) | |
| 1039 | + | }() | |
| 1040 | + | <-admissionEnded | |
| 1041 | + | ||
| 1042 | + | d.mu.Lock() | |
| 1043 | + | _, targetStillRegistered := d.sessions[target.id] | |
| 1044 | + | d.mu.Unlock() | |
| 1045 | + | require.True(t, targetStillRegistered, "picker deletion mutated the target before source-token preflight") | |
| 1046 | + | ||
| 1047 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1048 | + | next.initOverlays() | |
| 1049 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1050 | + | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1051 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1052 | + | }) | |
| 1053 | + | require.NoError(t, err) | |
| 1054 | + | release() | |
| 1055 | + | <-actionDone | |
| 1056 | + | d.mu.Lock() | |
| 1057 | + | _, targetStillRegistered = d.sessions[target.id] | |
| 1058 | + | d.mu.Unlock() | |
| 1059 | + | require.True(t, targetStillRegistered, "stale picker deletion mutated the target session") | |
| 1060 | + | d.deferAttachmentTransitionCleanups(result) | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |
| 1064 | + | p, release := newBlockingPTY(t) | |
| 1065 | + | defer release() | |
| 1066 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1067 | + | d.enterPicker(sess, ac) | |
| 1068 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 1069 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 1070 | + | token.lease = rc.attachmentLease(ac) | |
| 1071 | + | ac.publishRoleCapability(token) | |
| 1072 | + | ||
| 1073 | + | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1074 | + | ||
| 1075 | + | d.mu.Lock() | |
| 1076 | + | _, registered := d.sessions[sess.id] | |
| 1077 | + | d.mu.Unlock() | |
| 1078 | + | require.False(t, registered) | |
| 1079 | + | } | |
| 1080 | + | ||
| 1081 | + | func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T) { | |
| 1082 | + | writes := make(chan []byte, 1) | |
| 1083 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 1084 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 1085 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1086 | + | d.clock = clock | |
| 1087 | + | oldTransport := newDatagramTestTransport() | |
| 1088 | + | old.replaceTransport(oldTransport) | |
| 1089 | + | old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 1090 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1091 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1092 | + | token.lease = rc.attachmentLease(old) | |
| 1093 | + | old.publishRoleCapability(token) | |
| 1094 | + | ||
| 1095 | + | d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC})) | |
| 1096 | + | timer := <-clock.timers | |
| 1097 | + | admitted := make(chan struct{}) | |
| 1098 | + | release := make(chan struct{}) | |
| 1099 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1100 | + | close(admitted) | |
| 1101 | + | <-release | |
| 1102 | + | } | |
| 1103 | + | timer.ch <- time.Time{} | |
| 1104 | + | select { | |
| 1105 | + | case <-admitted: | |
| 1106 | + | case <-time.After(time.Second): | |
| 1107 | + | t.Fatal("delayed key callback did not acquire a fresh role-effect ticket") | |
| 1108 | + | } | |
| 1109 | + | ||
| 1110 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1111 | + | next.initOverlays() | |
| 1112 | + | transitionDone := make(chan error, 1) | |
| 1113 | + | go func() { | |
| 1114 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1115 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1116 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1117 | + | }) | |
| 1118 | + | transitionDone <- err | |
| 1119 | + | }() | |
| 1120 | + | select { | |
| 1121 | + | case err := <-transitionDone: | |
| 1122 | + | t.Fatalf("replacement overtook admitted delayed key callback: %v", err) | |
| 1123 | + | case <-time.After(20 * time.Millisecond): | |
| 1124 | + | } | |
| 1125 | + | close(release) | |
| 1126 | + | require.Equal(t, []byte{keys.ESC}, <-writes) | |
| 1127 | + | require.NoError(t, <-transitionDone) | |
| 1128 | + | } | |
| 1129 | + | ||
| 1130 | + | func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) { | |
| 1131 | + | writes := make(chan []byte, 1) | |
| 1132 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 1133 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 1134 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1135 | + | d.clock = clock | |
| 1136 | + | oldTransport := newDatagramTestTransport() | |
| 1137 | + | old.replaceTransport(oldTransport) | |
| 1138 | + | old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 1139 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1140 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1141 | + | token.lease = rc.attachmentLease(old) | |
| 1142 | + | old.publishRoleCapability(token) | |
| 1143 | + | ||
| 1144 | + | d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC})) | |
| 1145 | + | timer := <-clock.timers | |
| 1146 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1147 | + | next.initOverlays() | |
| 1148 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1149 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1150 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1151 | + | }) | |
| 1152 | + | require.NoError(t, err) | |
| 1153 | + | ||
| 1154 | + | attempted := make(chan bool, 1) | |
| 1155 | + | d.afterDelayedKeyEffectAttempt = func(admitted bool) { attempted <- admitted } | |
| 1156 | + | timer.ch <- time.Time{} | |
| 1157 | + | require.False(t, <-attempted, "stale delayed callback acquired authority after replacement") | |
| 1158 | + | select { | |
| 1159 | + | case got := <-writes: | |
| 1160 | + | t.Fatalf("stale delayed key callback reached the PTY: %q", got) | |
| 1161 | + | default: | |
| 1162 | + | } | |
| 1163 | + | } | |
| 1164 | + | ||
| 1165 | + | func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) { | |
| 1166 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1167 | + | oldTransport := newDatagramTestTransport() | |
| 1168 | + | old.replaceTransport(oldTransport) | |
| 1169 | + | d.attachCoordinator(sess, nil, old, true) | |
| 1170 | + | ||
| 1171 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1172 | + | next.initOverlays() | |
| 1173 | + | replaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1174 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1175 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1176 | + | }) | |
| 1177 | + | require.NoError(t, err) | |
| 1178 | + | token := replaced.displaced | |
| 1179 | + | ||
| 1180 | + | admitted := make(chan struct{}) | |
| 1181 | + | releaseGate := make(chan struct{}) | |
| 1182 | + | release := releaseTestGate(t, releaseGate) | |
| 1183 | + | afterFrozen := make(chan struct{}) | |
| 1184 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1185 | + | close(admitted) | |
| 1186 | + | <-releaseGate | |
| 1187 | + | } | |
| 1188 | + | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 1189 | + | if action == "" && ac == old { | |
| 1190 | + | close(afterFrozen) | |
| 1191 | + | } | |
| 1192 | + | } | |
| 1193 | + | effectDone := make(chan struct{}) | |
| 1194 | + | wantSize := domain.Size{Cols: 100, Rows: 30} | |
| 1195 | + | go func() { | |
| 1196 | + | d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})}) | |
| 1197 | + | close(effectDone) | |
| 1198 | + | }() | |
| 1199 | + | <-admitted | |
| 1200 | + | ||
| 1201 | + | transitionDone := make(chan error, 1) | |
| 1202 | + | go func() { | |
| 1203 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1204 | + | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 1205 | + | expectedTransport: old.transportSnapshot(), ready: true, | |
| 1206 | + | }) | |
| 1207 | + | transitionDone <- err | |
| 1208 | + | }() | |
| 1209 | + | <-afterFrozen | |
| 1210 | + | select { | |
| 1211 | + | case err := <-transitionDone: | |
| 1212 | + | t.Fatalf("promotion overtook admitted snatched panel: %v", err) | |
| 1213 | + | default: | |
| 1214 | + | } | |
| 1215 | + | release() | |
| 1216 | + | <-effectDone | |
| 1217 | + | require.NoError(t, <-transitionDone) | |
| 1218 | + | old.sendMu.Lock() | |
| 1219 | + | gotSize := old.size | |
| 1220 | + | old.sendMu.Unlock() | |
| 1221 | + | require.Equal(t, wantSize, gotSize) | |
| 1222 | + | } | |
| 1223 | + | ||
| 1224 | + | func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | |
| 1225 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1226 | + | oldTransport := newDatagramTestTransport() | |
| 1227 | + | old.replaceTransport(oldTransport) | |
| 1228 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1229 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1230 | + | token.lease = rc.attachmentLease(old) | |
| 1231 | + | ||
| 1232 | + | admitted := make(chan struct{}) | |
| 1233 | + | release := make(chan struct{}) | |
| 1234 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1235 | + | close(admitted) | |
| 1236 | + | <-release | |
| 1237 | + | } | |
| 1238 | + | paintDone := make(chan bool, 1) | |
| 1239 | + | go func() { paintDone <- d.firstPaintForTransition(token) }() | |
| 1240 | + | <-admitted | |
| 1241 | + | ||
| 1242 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1243 | + | next.initOverlays() | |
| 1244 | + | transitionDone := make(chan error, 1) | |
| 1245 | + | go func() { | |
| 1246 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1247 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1248 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1249 | + | }) | |
| 1250 | + | transitionDone <- err | |
| 1251 | + | }() | |
| 1252 | + | select { | |
| 1253 | + | case err := <-transitionDone: | |
| 1254 | + | t.Fatalf("replacement overtook admitted first paint: %v", err) | |
| 1255 | + | case <-time.After(20 * time.Millisecond): | |
| 1256 | + | } | |
| 1257 | + | close(release) | |
| 1258 | + | require.True(t, <-paintDone) | |
| 1259 | + | require.NoError(t, <-transitionDone) | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) { | |
| 1263 | + | d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1264 | + | aTransport := newDatagramTestTransport() | |
| 1265 | + | a.replaceTransport(aTransport) | |
| 1266 | + | d.attachCoordinator(first, nil, a, true) | |
| 1267 | + | first.attachmentToken(a, aTransport) | |
| 1268 | + | ||
| 1269 | + | bTransport := newDatagramTestTransport() | |
| 1270 | + | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1271 | + | b.initOverlays() | |
| 1272 | + | second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})} | |
| 1273 | + | b.setSession(second) | |
| 1274 | + | d.mu.Lock() | |
| 1275 | + | d.sessions[second.id] = second | |
| 1276 | + | d.mu.Unlock() | |
| 1277 | + | d.attachCoordinator(second, nil, b, true) | |
| 1278 | + | second.attachmentToken(b, bTransport) | |
| 1279 | + | ||
| 1280 | + | start := make(chan struct{}) | |
| 1281 | + | results := make(chan error, 2) | |
| 1282 | + | go func() { | |
| 1283 | + | <-start | |
| 1284 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1285 | + | source: first, target: second, next: a, | |
| 1286 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1287 | + | expectedTransport: a.transportSnapshot(), ready: true, | |
| 1288 | + | }) | |
| 1289 | + | results <- err | |
| 1290 | + | }() | |
| 1291 | + | go func() { | |
| 1292 | + | <-start | |
| 1293 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1294 | + | source: second, target: first, next: b, | |
| 1295 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1296 | + | expectedTransport: b.transportSnapshot(), ready: true, | |
| 1297 | + | }) | |
| 1298 | + | results <- err | |
| 1299 | + | }() | |
| 1300 | + | close(start) | |
| 1301 | + | ||
| 1302 | + | errs := make([]error, 0, 2) | |
| 1303 | + | for range 2 { | |
| 1304 | + | select { | |
| 1305 | + | case err := <-results: | |
| 1306 | + | errs = append(errs, err) | |
| 1307 | + | case <-time.After(time.Second): | |
| 1308 | + | t.Fatal("reversed transitions deadlocked") | |
| 1309 | + | } | |
| 1310 | + | } | |
| 1311 | + | successes := 0 | |
| 1312 | + | for _, err := range errs { | |
| 1313 | + | if err == nil { | |
| 1314 | + | successes++ | |
| 1315 | + | } | |
| 1316 | + | } | |
| 1317 | + | require.Equal(t, 1, successes) | |
| 1318 | + | } | |
| 1319 | + | ||
| 1320 | + | func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |
| 1321 | + | d, source, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1322 | + | aTransport := newDatagramTestTransport() | |
| 1323 | + | a.replaceTransport(aTransport) | |
| 1324 | + | aCoordinator := d.attachCoordinator(source, nil, a, true) | |
| 1325 | + | ||
| 1326 | + | bTransport := newDatagramTestTransport() | |
| 1327 | + | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1328 | + | b.initOverlays() | |
| 1329 | + | target := &session{ | |
| 1330 | + | id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {}, | |
| 1331 | + | client: b, snatched: make(map[*attachedClient]struct{}), | |
| 1332 | + | } | |
| 1333 | + | b.setSession(target) | |
| 1334 | + | d.mu.Lock() | |
| 1335 | + | d.sessions[target.id] = target | |
| 1336 | + | d.mu.Unlock() | |
| 1337 | + | d.attachCoordinator(target, nil, b, true) | |
| 1338 | + | ||
| 1339 | + | // Give B the earlier immutable identity. The reverse transition therefore | |
| 1340 | + | // freezes B then A, while the old picker-delete path froze A separately | |
| 1341 | + | // before discovering and freezing B. | |
| 1342 | + | b.roleEffects.immutableOrder() | |
| 1343 | + | a.roleEffects.immutableOrder() | |
| 1344 | + | require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load()) | |
| 1345 | + | ||
| 1346 | + | token := source.attachmentToken(a, aTransport) | |
| 1347 | + | token.lease = aCoordinator.attachmentLease(a) | |
| 1348 | + | a.publishRoleCapability(token) | |
| 1349 | + | effect, admitted := a.beginRoleEffect(token) | |
| 1350 | + | require.True(t, admitted) | |
| 1351 | + | token = effect.roleToken() | |
| 1352 | + | ||
| 1353 | + | deleteDiscovered := make(chan []*attachedClient, 1) | |
| 1354 | + | transitionDiscovered := make(chan []*attachedClient, 1) | |
| 1355 | + | releaseDelete := make(chan struct{}) | |
| 1356 | + | releaseTransition := make(chan struct{}) | |
| 1357 | + | transitionBFrozen := make(chan struct{}) | |
| 1358 | + | deleteFrozen := make(chan *attachedClient, 2) | |
| 1359 | + | d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) { | |
| 1360 | + | snapshot := append([]*attachedClient(nil), participants...) | |
| 1361 | + | switch action { | |
| 1362 | + | case "picker-delete": | |
| 1363 | + | deleteDiscovered <- snapshot | |
| 1364 | + | <-releaseDelete | |
| 1365 | + | case "reverse-delete-transition": | |
| 1366 | + | transitionDiscovered <- snapshot | |
| 1367 | + | <-releaseTransition | |
| 1368 | + | } | |
| 1369 | + | } | |
| 1370 | + | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 1371 | + | switch action { | |
| 1372 | + | case "reverse-delete-transition": | |
| 1373 | + | if ac == b { | |
| 1374 | + | close(transitionBFrozen) | |
| 1375 | + | } | |
| 1376 | + | case "picker-delete": | |
| 1377 | + | deleteFrozen <- ac | |
| 1378 | + | } | |
| 1379 | + | } | |
| 1380 | + | ||
| 1381 | + | deleteDone := make(chan error, 1) | |
| 1382 | + | go func() { | |
| 1383 | + | deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token) | |
| 1384 | + | }() | |
| 1385 | + | deleteParticipants := <-deleteDiscovered | |
| 1386 | + | require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants) | |
| 1387 | + | ||
| 1388 | + | transitionDone := make(chan error, 1) | |
| 1389 | + | go func() { | |
| 1390 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1391 | + | source: target, target: source, next: b, | |
| 1392 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1393 | + | expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true, | |
| 1394 | + | }) | |
| 1395 | + | if err == nil { | |
| 1396 | + | d.deferAttachmentTransitionCleanups(result) | |
| 1397 | + | } | |
| 1398 | + | transitionDone <- err | |
| 1399 | + | }() | |
| 1400 | + | transitionParticipants := <-transitionDiscovered | |
| 1401 | + | require.ElementsMatch(t, []*attachedClient{a, b}, transitionParticipants) | |
| 1402 | + | ||
| 1403 | + | close(releaseTransition) | |
| 1404 | + | select { | |
| 1405 | + | case <-transitionBFrozen: | |
| 1406 | + | case <-time.After(time.Second): | |
| 1407 | + | t.Fatal("reverse transition did not freeze B first") | |
| 1408 | + | } | |
| 1409 | + | close(releaseDelete) | |
| 1410 | + | ||
| 1411 | + | select { | |
| 1412 | + | case err := <-transitionDone: | |
| 1413 | + | require.NoError(t, err) | |
| 1414 | + | case <-time.After(time.Second): | |
| 1415 | + | t.Fatal("reverse transition deadlocked with picker deletion") | |
| 1416 | + | } | |
| 1417 | + | select { | |
| 1418 | + | case err := <-deleteDone: | |
| 1419 | + | require.NoError(t, err) | |
| 1420 | + | case <-time.After(time.Second): | |
| 1421 | + | t.Fatal("picker deletion deadlocked with reverse transition") | |
| 1422 | + | } | |
| 1423 | + | frozenByDelete := make([]*attachedClient, 0, 2) | |
| 1424 | + | for range 2 { | |
| 1425 | + | select { | |
| 1426 | + | case ac := <-deleteFrozen: | |
| 1427 | + | frozenByDelete = append(frozenByDelete, ac) | |
| 1428 | + | case <-time.After(time.Second): | |
| 1429 | + | t.Fatal("picker deletion did not freeze every participant") | |
| 1430 | + | } | |
| 1431 | + | } | |
| 1432 | + | require.ElementsMatch(t, []*attachedClient{a, b}, frozenByDelete, "picker deletion must freeze each deduplicated participant exactly once") | |
| 1433 | + | require.Empty(t, deleteFrozen) | |
| 1434 | + | ||
| 1435 | + | d.mu.Lock() | |
| 1436 | + | registered := d.sessions[target.id] | |
| 1437 | + | d.mu.Unlock() | |
| 1438 | + | require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle") | |
| 1439 | + | require.Equal(t, attachmentSnatched, source.attachmentRole(a)) | |
| 1440 | + | require.Equal(t, attachmentActive, source.attachmentRole(b)) | |
| 1441 | + | } | |
| 1442 | + | ||
| 1443 | + | func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) { | |
| 1444 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1445 | + | oldTransport := newDatagramTestTransport() | |
| 1446 | + | old.replaceTransport(oldTransport) | |
| 1447 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1448 | + | token := sess.attachmentToken(old, oldTransport) | |
| 1449 | + | token.lease = rc.attachmentLease(old) | |
| 1450 | + | old.publishRoleCapability(token) | |
| 1451 | + | ||
| 1452 | + | frozen := make(chan struct{}) | |
| 1453 | + | release := make(chan struct{}) | |
| 1454 | + | d.afterRoleEffectsFrozen = func() { | |
| 1455 | + | close(frozen) | |
| 1456 | + | <-release | |
| 1457 | + | } | |
| 1458 | + | ||
| 1459 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1460 | + | next.initOverlays() | |
| 1461 | + | done := make(chan error, 1) | |
| 1462 | + | go func() { | |
| 1463 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1464 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1465 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1466 | + | }) | |
| 1467 | + | done <- err | |
| 1468 | + | }() | |
| 1469 | + | <-frozen | |
| 1470 | + | ||
| 1471 | + | _, ok := old.beginRoleEffect(token) | |
| 1472 | + | require.False(t, ok, "a frozen capability must admit no new effect") | |
| 1473 | + | close(release) | |
| 1474 | + | require.NoError(t, <-done) | |
| 1475 | + | } | |
M internal/usecase/daemon/session.go +439 -83
| @@ -49,10 +49,11 @@ type session struct { | |||
| 49 | 49 | teardownMu sync.Mutex | |
| 50 | 50 | teardownActive bool | |
| 51 | 51 | teardownDone chan struct{} | |
| 52 | + | moveReservations uint | |
| 52 | 53 | teardownWaiters uint | |
| 53 | 54 | teardownChanged chan struct{} | |
| 54 | 55 | 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 | |
| 56 | 57 | // restoreDone remains attached to a restored session after it is published in | |
| 57 | 58 | // the live registry, so racing attaches cannot bypass restoration completion. | |
| 58 | 59 | restoreDone chan struct{} | |
| @@ -60,6 +61,7 @@ type session struct { | |||
| 60 | 61 | tabs []*tab | |
| 61 | 62 | active int | |
| 62 | 63 | client *attachedClient | |
| 64 | + | snatched map[*attachedClient]struct{} | |
| 63 | 65 | clipboardQueue []clipboardForward | |
| 64 | 66 | clipboardWorkerRunning bool | |
| 65 | 67 | cwd string | |
| @@ -250,13 +252,24 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 250 | 252 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | |
| 251 | 253 | } | |
| 252 | 254 | command, args := d.ptyCommand(env) | |
| 253 | - | pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | |
| 255 | + | lifetime := d.newPaneProcessLifetime(d.serveCtx) | |
| 256 | + | pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | |
| 254 | 257 | if err != nil { | |
| 258 | + | lifetime.abort() | |
| 259 | + | if pty != nil { | |
| 260 | + | _ = pty.Close() | |
| 261 | + | } | |
| 255 | 262 | closeTabs(tabs) | |
| 256 | 263 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | |
| 257 | 264 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err) | |
| 258 | 265 | } | |
| 259 | 266 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 267 | + | if !lifetime.publish(tb.focusedPane()) { | |
| 268 | + | lifetime.abort() | |
| 269 | + | _ = pty.Close() | |
| 270 | + | closeTabs(tabs) | |
| 271 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err()) | |
| 272 | + | } | |
| 260 | 273 | if i < len(names) { | |
| 261 | 274 | tb.name = names[i] | |
| 262 | 275 | } | |
| @@ -296,6 +309,11 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 296 | 309 | } | |
| 297 | 310 | sess.mruAt.Store(lastUsedSeq) | |
| 298 | 311 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 312 | + | // These tabs are still private construction state. Publish every tiled | |
| 313 | + | // owner before the session enters d.sessions or any PTY reader starts. | |
| 314 | + | for _, tb := range tabs { | |
| 315 | + | publishTiledPaneOwners(sess, tb) | |
| 316 | + | } | |
| 299 | 317 | if !ephemeral { | |
| 300 | 318 | if d.persistEnabled { | |
| 301 | 319 | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} | |
| @@ -338,9 +356,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 338 | 356 | ||
| 339 | 357 | func closeTabs(tabs []*tab) { | |
| 340 | 358 | for _, tb := range tabs { | |
| 341 | - | for _, p := range tb.panes { | |
| 342 | - | _ = p.pty.Close() | |
| 343 | - | } | |
| 359 | + | tb.closeAllPanes() | |
| 344 | 360 | } | |
| 345 | 361 | } | |
| 346 | 362 | ||
| @@ -361,55 +377,95 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 361 | 377 | d.mu.Unlock() | |
| 362 | 378 | return errSessionNameInUse | |
| 363 | 379 | } | |
| 364 | - | // The caller owns d.mu. Make the entire ownership handoff atomic with | |
| 365 | - | // global routing so it cannot select a client between source removal and | |
| 366 | - | // destination publication. | |
| 367 | - | d.notices.routingMu.Lock() | |
| 368 | 380 | from.mu.Lock() | |
| 369 | 381 | cwd := from.cwd | |
| 370 | 382 | term := from.terminal | |
| 371 | 383 | env := copyEnvironment(from.env) | |
| 372 | 384 | if from.client != ac { | |
| 373 | 385 | from.mu.Unlock() | |
| 374 | - | d.notices.routingMu.Unlock() | |
| 375 | 386 | d.mu.Unlock() | |
| 376 | 387 | return errors.New("client detached") | |
| 377 | 388 | } | |
| 378 | 389 | from.mu.Unlock() | |
| 379 | 390 | ||
| 380 | 391 | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | |
| 392 | + | d.mu.Unlock() | |
| 381 | 393 | if err != nil { | |
| 382 | - | d.notices.routingMu.Unlock() | |
| 383 | - | d.mu.Unlock() | |
| 384 | 394 | return err | |
| 385 | 395 | } | |
| 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 { | |
| 391 | 409 | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | |
| 392 | - | return errors.New("client detached") | |
| 410 | + | return err | |
| 393 | 411 | } | |
| 394 | - | from.client = nil | |
| 395 | - | ac.setSession(nil) | |
| 396 | - | from.mu.Unlock() | |
| 397 | - | ||
| 398 | - | // Prepare source invalidation, output rebase, and destination coordinator | |
| 399 | - | // identity before publishing the destination attachment. | |
| 400 | - | cleanups := d.handoffCoordinator(from, newSess, nil, ac) | |
| 401 | - | ac.setSession(newSess) | |
| 402 | - | newSess.mu.Lock() | |
| 403 | - | newSess.client = ac | |
| 404 | - | newSess.mu.Unlock() | |
| 405 | 412 | ||
| 406 | 413 | d.touchMRU(newSess) | |
| 407 | 414 | ac.recordPreviousSession(from) | |
| 408 | 415 | 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) | |
| 413 | 469 | return nil | |
| 414 | 470 | } | |
| 415 | 471 | ||
| @@ -427,8 +483,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 427 | 483 | return err | |
| 428 | 484 | } | |
| 429 | 485 | command, args := d.ptyCommand(env) | |
| 430 | - | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | |
| 486 | + | lifetime := d.newPaneProcessLifetime(sess.ctx) | |
| 487 | + | pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize) | |
| 431 | 488 | if err != nil { | |
| 489 | + | lifetime.abort() | |
| 490 | + | if pty != nil { | |
| 491 | + | _ = pty.Close() | |
| 492 | + | } | |
| 432 | 493 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 433 | 494 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | |
| 434 | 495 | } | |
| @@ -447,14 +508,22 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 447 | 508 | d.mu.Lock() | |
| 448 | 509 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | |
| 449 | 510 | d.mu.Unlock() | |
| 511 | + | lifetime.abort() | |
| 450 | 512 | _ = pty.Close() | |
| 451 | 513 | return errors.New("daemon: session closed") | |
| 452 | 514 | } | |
| 453 | 515 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | |
| 454 | - | for _, p := range tb.panes { | |
| 455 | - | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 456 | - | } | |
| 457 | 516 | sess.mu.Lock() | |
| 517 | + | p := tb.focusedPane() | |
| 518 | + | if !lifetime.publish(p) { | |
| 519 | + | sess.mu.Unlock() | |
| 520 | + | d.mu.Unlock() | |
| 521 | + | lifetime.abort() | |
| 522 | + | _ = pty.Close() | |
| 523 | + | return errors.New("daemon: session closed") | |
| 524 | + | } | |
| 525 | + | // Publish ownership before appending the tab to the visible session. | |
| 526 | + | publishPaneOwner(p, sess, tb, 0) | |
| 458 | 527 | sess.tabs = append(sess.tabs, tb) | |
| 459 | 528 | sess.active = len(sess.tabs) - 1 | |
| 460 | 529 | tabIndex := sess.active | |
| @@ -519,12 +588,7 @@ func (tb *tab) closeAllPanes() { | |||
| 519 | 588 | panes = append(panes, floating) | |
| 520 | 589 | } | |
| 521 | 590 | 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) | |
| 528 | 592 | } | |
| 529 | 593 | } | |
| 530 | 594 | ||
| @@ -573,15 +637,25 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | |||
| 573 | 637 | } | |
| 574 | 638 | ||
| 575 | 639 | 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") | |
| 581 | 651 | } | |
| 652 | + | sess.mu.Lock() | |
| 653 | + | name := sess.name | |
| 654 | + | sess.mu.Unlock() | |
| 655 | + | d.log.Info("pane created", "session", name, "pane", p.id) | |
| 582 | 656 | // Scheduler ownership was removed; this launch creates exactly one reader. | |
| 583 | 657 | d.sessWg.Add(1) | |
| 584 | - | go d.ptyReader(sess, tb, p) | |
| 658 | + | go d.readPanePTY(p) | |
| 585 | 659 | } | |
| 586 | 660 | ||
| 587 | 661 | // attachClient makes ac the session's current client, displacing any prior one | |
| @@ -598,13 +672,79 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||
| 598 | 672 | return false | |
| 599 | 673 | } | |
| 600 | 674 | ||
| 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. | |
| 604 | 677 | func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool { | |
| 678 | + | if sess == nil || ac == nil { | |
| 679 | + | return false | |
| 680 | + | } | |
| 681 | + | frozen := freezeRoleEffectGates(ac) | |
| 682 | + | defer frozen.unfreeze() | |
| 683 | + | if d.afterDetachRoleEffectsFrozen != nil { | |
| 684 | + | d.afterDetachRoleEffectsFrozen() | |
| 685 | + | } | |
| 605 | 686 | 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 | |
| 608 | 748 | } | |
| 609 | 749 | ||
| 610 | 750 | func (s *session) activeTab() *tab { | |
| @@ -921,7 +1061,145 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro | |||
| 921 | 1061 | } | |
| 922 | 1062 | ||
| 923 | 1063 | func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |
| 924 | - | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil) | |
| 1064 | + | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil) | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | type sessionKillAdmission struct { | |
| 1068 | + | token attachmentRoleToken | |
| 1069 | + | action string | |
| 1070 | + | } | |
| 1071 | + | ||
| 1072 | + | type sessionKillParticipants struct { | |
| 1073 | + | active *attachedClient | |
| 1074 | + | snatched map[*attachedClient]struct{} | |
| 1075 | + | transports map[*attachedClient]transportSnapshot | |
| 1076 | + | roleGates []*attachedClient | |
| 1077 | + | interrupts []roleTransportInterrupt | |
| 1078 | + | source *session | |
| 1079 | + | sourceToken *attachmentRoleToken | |
| 1080 | + | target *session | |
| 1081 | + | } | |
| 1082 | + | ||
| 1083 | + | func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error { | |
| 1084 | + | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action}) | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | // snapshotSessionKillParticipants captures role membership under the canonical | |
| 1088 | + | // architecture locks, but releases every lock before any role gate is frozen or | |
| 1089 | + | // drained. The initiator is part of the same deduplicated global gate set even | |
| 1090 | + | // when it belongs to a different source session. | |
| 1091 | + | func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) { | |
| 1092 | + | snapshot := sessionKillParticipants{target: target} | |
| 1093 | + | if admission != nil { | |
| 1094 | + | token := admission.token | |
| 1095 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 1096 | + | return sessionKillParticipants{}, false | |
| 1097 | + | } | |
| 1098 | + | snapshot.source = token.sess | |
| 1099 | + | snapshot.sourceToken = &admission.token | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | d.mu.Lock() | |
| 1103 | + | if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source { | |
| 1104 | + | d.mu.Unlock() | |
| 1105 | + | return sessionKillParticipants{}, false | |
| 1106 | + | } | |
| 1107 | + | d.notices.routingMu.Lock() | |
| 1108 | + | source := snapshot.source | |
| 1109 | + | if source == nil { | |
| 1110 | + | source = target | |
| 1111 | + | } | |
| 1112 | + | unlockSessions := lockAttachmentSessions(source, target) | |
| 1113 | + | snapshot.active = target.client | |
| 1114 | + | snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched)) | |
| 1115 | + | snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched)) | |
| 1116 | + | if snapshot.active != nil { | |
| 1117 | + | snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot() | |
| 1118 | + | } | |
| 1119 | + | for ac := range target.snatched { | |
| 1120 | + | snapshot.snatched[ac] = struct{}{} | |
| 1121 | + | snapshot.transports[ac] = ac.transportSnapshot() | |
| 1122 | + | } | |
| 1123 | + | unlockSessions() | |
| 1124 | + | d.notices.routingMu.Unlock() | |
| 1125 | + | d.mu.Unlock() | |
| 1126 | + | ||
| 1127 | + | seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched)) | |
| 1128 | + | add := func(ac *attachedClient) { | |
| 1129 | + | if ac == nil { | |
| 1130 | + | return | |
| 1131 | + | } | |
| 1132 | + | if _, duplicate := seen[ac]; duplicate { | |
| 1133 | + | return | |
| 1134 | + | } | |
| 1135 | + | seen[ac] = struct{}{} | |
| 1136 | + | snapshot.roleGates = append(snapshot.roleGates, ac) | |
| 1137 | + | } | |
| 1138 | + | if admission != nil { | |
| 1139 | + | add(admission.token.ac) | |
| 1140 | + | } | |
| 1141 | + | add(snapshot.active) | |
| 1142 | + | for ac := range snapshot.snatched { | |
| 1143 | + | add(ac) | |
| 1144 | + | } | |
| 1145 | + | for ac, transport := range snapshot.transports { | |
| 1146 | + | if transport.transport != nil { | |
| 1147 | + | snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 1148 | + | } | |
| 1149 | + | } | |
| 1150 | + | return snapshot, true | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact | |
| 1154 | + | // target-membership validation while every discovered role gate is frozen. | |
| 1155 | + | func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool { | |
| 1156 | + | d.mu.Lock() | |
| 1157 | + | defer d.mu.Unlock() | |
| 1158 | + | if d.sessions[snapshot.target.id] != snapshot.target || | |
| 1159 | + | snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source { | |
| 1160 | + | return false | |
| 1161 | + | } | |
| 1162 | + | d.notices.routingMu.Lock() | |
| 1163 | + | defer d.notices.routingMu.Unlock() | |
| 1164 | + | source := snapshot.source | |
| 1165 | + | if source == nil { | |
| 1166 | + | source = snapshot.target | |
| 1167 | + | } | |
| 1168 | + | sourceCoordinator := source.renderCoordinator() | |
| 1169 | + | targetCoordinator := snapshot.target.renderCoordinator() | |
| 1170 | + | unlockSessions := lockAttachmentSessions(source, snapshot.target) | |
| 1171 | + | defer unlockSessions() | |
| 1172 | + | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator) | |
| 1173 | + | defer unlockCoordinators() | |
| 1174 | + | return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator) | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | // sessionKillParticipantsCurrentLocked requires the ordered source/target | |
| 1178 | + | // session and coordinator locks. | |
| 1179 | + | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | |
| 1180 | + | target := snapshot.target | |
| 1181 | + | if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) { | |
| 1182 | + | return false | |
| 1183 | + | } | |
| 1184 | + | for ac := range snapshot.snatched { | |
| 1185 | + | if _, current := target.snatched[ac]; !current { | |
| 1186 | + | return false | |
| 1187 | + | } | |
| 1188 | + | } | |
| 1189 | + | for ac, transport := range snapshot.transports { | |
| 1190 | + | if !ac.transportSnapshotCurrent(transport) { | |
| 1191 | + | return false | |
| 1192 | + | } | |
| 1193 | + | } | |
| 1194 | + | if snapshot.sourceToken == nil { | |
| 1195 | + | return true | |
| 1196 | + | } | |
| 1197 | + | token := *snapshot.sourceToken | |
| 1198 | + | req := attachmentTransitionRequest{ | |
| 1199 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 1200 | + | expectedTransport: token.transport, | |
| 1201 | + | } | |
| 1202 | + | return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req) | |
| 925 | 1203 | } | |
| 926 | 1204 | ||
| 927 | 1205 | // stopInMemoryLifecycle cancels every session producer and closes every PTY | |
| @@ -933,7 +1211,9 @@ func (s *session) stopInMemoryLifecycle() { | |||
| 933 | 1211 | } | |
| 934 | 1212 | s.lifecycleStopOnce.Do(func() { | |
| 935 | 1213 | s.stopFloatingLaunches() | |
| 936 | - | s.cancel() | |
| 1214 | + | if s.cancel != nil { | |
| 1215 | + | s.cancel() | |
| 1216 | + | } | |
| 937 | 1217 | s.mu.Lock() | |
| 938 | 1218 | tabs := append([]*tab(nil), s.tabs...) | |
| 939 | 1219 | s.mu.Unlock() | |
| @@ -970,7 +1250,7 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool { | |||
| 970 | 1250 | } | |
| 971 | 1251 | ||
| 972 | 1252 | s.teardownMu.Lock() | |
| 973 | - | if !s.teardownActive { | |
| 1253 | + | if !s.teardownActive && s.moveReservations == 0 { | |
| 974 | 1254 | if deadline != nil { | |
| 975 | 1255 | select { | |
| 976 | 1256 | case <-deadline.Done(): | |
| @@ -985,17 +1265,17 @@ func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool { | |||
| 985 | 1265 | s.teardownMu.Unlock() | |
| 986 | 1266 | return true | |
| 987 | 1267 | } | |
| 988 | - | done := s.teardownDone | |
| 989 | 1268 | s.teardownWaiters++ | |
| 990 | 1269 | s.signalTeardownChangedLocked() | |
| 1270 | + | changed := s.teardownChangeLocked() | |
| 991 | 1271 | s.teardownMu.Unlock() | |
| 992 | 1272 | ||
| 993 | 1273 | timedOut := false | |
| 994 | 1274 | if deadline == nil { | |
| 995 | - | <-done | |
| 1275 | + | <-changed | |
| 996 | 1276 | } else { | |
| 997 | 1277 | select { | |
| 998 | - | case <-done: | |
| 1278 | + | case <-changed: | |
| 999 | 1279 | case <-deadline.Done(): | |
| 1000 | 1280 | timedOut = true | |
| 1001 | 1281 | } | |
| @@ -1023,10 +1303,52 @@ func (s *session) finishTeardown() { | |||
| 1023 | 1303 | // terminal checkpoint and coordinator join. A timed-out repository call keeps | |
| 1024 | 1304 | // only immutable capture state; it never observes closed worker channels or | |
| 1025 | 1305 | // session-owned cache that teardown has released. | |
| 1026 | - | func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error { | |
| 1306 | + | func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error { | |
| 1027 | 1307 | if sess == nil { | |
| 1028 | 1308 | return nil | |
| 1029 | 1309 | } | |
| 1310 | + | // Discover every role whose membership can be invalidated before freezing | |
| 1311 | + | // any one of them. The common gate helper deduplicates and freezes the whole | |
| 1312 | + | // immutable identity set in the same global order used by transitions. | |
| 1313 | + | participants, ok := d.snapshotSessionKillParticipants(sess, admission) | |
| 1314 | + | if !ok { | |
| 1315 | + | return nil | |
| 1316 | + | } | |
| 1317 | + | if admission != nil { | |
| 1318 | + | d.endActionRoleEffect(admission.token.effect, admission.action) | |
| 1319 | + | } | |
| 1320 | + | action := "" | |
| 1321 | + | if admission != nil { | |
| 1322 | + | action = admission.action | |
| 1323 | + | } | |
| 1324 | + | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 1325 | + | d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates) | |
| 1326 | + | } | |
| 1327 | + | var drainDone func() <-chan struct{} | |
| 1328 | + | var drainDeadline *roleEffectDrainDeadline | |
| 1329 | + | if deadline != nil { | |
| 1330 | + | drainDone = deadline.Done | |
| 1331 | + | } else { | |
| 1332 | + | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 1333 | + | drainDone = drainDeadline.Done | |
| 1334 | + | defer drainDeadline.stop() | |
| 1335 | + | } | |
| 1336 | + | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { | |
| 1337 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 1338 | + | d.afterRoleEffectGateFrozen(action, ac) | |
| 1339 | + | } | |
| 1340 | + | }}, participants.roleGates...) | |
| 1341 | + | defer func() { frozen.unfreeze() }() | |
| 1342 | + | if !frozen.acquired { | |
| 1343 | + | // The acquisition helper has already rolled back exactly the gates it | |
| 1344 | + | // owned. Do not enter architecture locks or publish terminal ownership | |
| 1345 | + | // changes unless the complete canonical participant set is frozen. | |
| 1346 | + | return nil | |
| 1347 | + | } | |
| 1348 | + | if !d.sessionKillParticipantsCurrent(participants) { | |
| 1349 | + | return nil | |
| 1350 | + | } | |
| 1351 | + | ||
| 1030 | 1352 | // Pane EOF, explicit close, and daemon shutdown can all converge on the same | |
| 1031 | 1353 | // session. Only one owner may create the terminal mutation and checkpoint; | |
| 1032 | 1354 | // later callers observe the completed registry transition instead of | |
| @@ -1119,21 +1441,62 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1119 | 1441 | sess.snapshotMu.Unlock() | |
| 1120 | 1442 | } | |
| 1121 | 1443 | } | |
| 1444 | + | // Revalidate the exact source capability, target lifecycle, and active plus | |
| 1445 | + | // snatched memberships under canonical architecture locks immediately before | |
| 1446 | + | // publication. A changed preflight aborts without mutating either lifecycle. | |
| 1122 | 1447 | d.mu.Lock() | |
| 1123 | - | if _, ok := d.sessions[sess.id]; !ok { | |
| 1448 | + | if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source { | |
| 1124 | 1449 | d.mu.Unlock() | |
| 1125 | 1450 | return nil | |
| 1126 | 1451 | } | |
| 1452 | + | d.notices.routingMu.Lock() | |
| 1453 | + | source := participants.source | |
| 1454 | + | if source == nil { | |
| 1455 | + | source = sess | |
| 1456 | + | } | |
| 1457 | + | sourceCoordinator := source.renderCoordinator() | |
| 1458 | + | targetCoordinator := sess.renderCoordinator() | |
| 1459 | + | unlockSessions := lockAttachmentSessions(source, sess) | |
| 1460 | + | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator) | |
| 1461 | + | if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) { | |
| 1462 | + | unlockCoordinators() | |
| 1463 | + | unlockSessions() | |
| 1464 | + | d.notices.routingMu.Unlock() | |
| 1465 | + | d.mu.Unlock() | |
| 1466 | + | return nil | |
| 1467 | + | } | |
| 1468 | + | ||
| 1469 | + | // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held | |
| 1470 | + | // here. Registry removal and every role invalidation are one publication | |
| 1471 | + | // while the complete immutable gate set remains frozen. | |
| 1127 | 1472 | delete(d.sessions, sess.id) | |
| 1128 | 1473 | 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) | |
| 1131 | 1492 | stoppedName := sess.name | |
| 1132 | 1493 | stoppedCwd := sess.cwd | |
| 1133 | 1494 | createdAt := sess.createdAt | |
| 1134 | - | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |
| 1495 | + | tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames | |
| 1135 | 1496 | ephemeral := sess.ephemeral | |
| 1136 | - | sess.mu.Unlock() | |
| 1497 | + | unlockCoordinators() | |
| 1498 | + | unlockSessions() | |
| 1499 | + | d.notices.routingMu.Unlock() | |
| 1137 | 1500 | if !ephemeral { | |
| 1138 | 1501 | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge} | |
| 1139 | 1502 | d.stopped[stoppedName] = stopped | |
| @@ -1146,18 +1509,12 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1146 | 1509 | d.closing = true | |
| 1147 | 1510 | } | |
| 1148 | 1511 | d.mu.Unlock() | |
| 1512 | + | finishParkedAttachmentRetirements(parkedRetirements) | |
| 1513 | + | frozen.unfreeze() | |
| 1514 | + | frozen = frozenRoleEffectGates{} | |
| 1149 | 1515 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| 1150 | 1516 | ||
| 1151 | 1517 | var purgeErr error | |
| 1152 | - | ||
| 1153 | - | // A global route that selected this attachment must publish before terminal | |
| 1154 | - | // teardown makes it stale. | |
| 1155 | - | d.notices.routingMu.Lock() | |
| 1156 | - | sess.mu.Lock() | |
| 1157 | - | ac := sess.client | |
| 1158 | - | sess.client = nil | |
| 1159 | - | sess.mu.Unlock() | |
| 1160 | - | d.notices.routingMu.Unlock() | |
| 1161 | 1518 | if rc := sess.renderCoordinator(); rc != nil { | |
| 1162 | 1519 | // Terminal teardown has two phases: this session owner first prevents any | |
| 1163 | 1520 | // new worker registration and detaches all tokens, then stops and waits | |
| @@ -1165,11 +1522,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1165 | 1522 | rc.beginSessionTeardown().finish() | |
| 1166 | 1523 | rc.waitForTimerWorkers() | |
| 1167 | 1524 | } | |
| 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() | |
| 1173 | 1529 | } | |
| 1174 | 1530 | ||
| 1175 | 1531 | // Prevent queued launches from entering Open, cancel the parent context, and | |
| @@ -1183,7 +1539,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1183 | 1539 | sess.mu.Unlock() | |
| 1184 | 1540 | for _, tb := range tabs { | |
| 1185 | 1541 | d.clearDestroyedTabPreview(tb) | |
| 1186 | - | d.teardownFloating(tb, ac) | |
| 1542 | + | d.teardownFloating(tb, detachedActive) | |
| 1187 | 1543 | } | |
| 1188 | 1544 | for _, path := range clipFiles { | |
| 1189 | 1545 | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| @@ -1212,8 +1568,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1212 | 1568 | d.repaintAllAttachedClients() | |
| 1213 | 1569 | } | |
| 1214 | 1570 | ||
| 1215 | - | if ac != nil { | |
| 1216 | - | d.notifyDetachedAsync(ac, reason) | |
| 1571 | + | for _, attachment := range attachments { | |
| 1572 | + | d.notifyDetachedSnapshotAsync(attachment, reason) | |
| 1217 | 1573 | } | |
| 1218 | 1574 | return errors.Join(purgeErr, terminalSnapshotErr) | |
| 1219 | 1575 | } | |
M internal/usecase/daemon/session_back.go +12 -0
| @@ -4,6 +4,18 @@ import "github.com/bnema/vev/internal/usecase/picker" | |||
| 4 | 4 | ||
| 5 | 5 | // backSession toggles this attachment between its current session and the | |
| 6 | 6 | // immediately preceding successfully activated session. | |
| 7 | + | func (d *Daemon) backSessionForRole(token attachmentRoleToken) error { | |
| 8 | + | if d == nil || token.sess == nil || token.ac == nil { | |
| 9 | + | return nil | |
| 10 | + | } | |
| 11 | + | target := token.ac.previousSession.Get() | |
| 12 | + | if target == nil || target == token.sess || d.sessionByID(target.id) != target { | |
| 13 | + | token.ac.clearPreviousSessionIf(target) | |
| 14 | + | return nil | |
| 15 | + | } | |
| 16 | + | return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session") | |
| 17 | + | } | |
| 18 | + | ||
| 7 | 19 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | |
| 8 | 20 | if d == nil || current == nil || ac == nil { | |
| 9 | 21 | return | |
A internal/usecase/daemon/session_move_locator_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) { | |
| 14 | + | sess := &session{ | |
| 15 | + | id: domain.SessionID("session-id"), | |
| 16 | + | incarnation: domain.IncarnationID{1}, | |
| 17 | + | name: "initial", | |
| 18 | + | } | |
| 19 | + | start := make(chan struct{}) | |
| 20 | + | var wg sync.WaitGroup | |
| 21 | + | wg.Add(2) | |
| 22 | + | go func() { | |
| 23 | + | defer wg.Done() | |
| 24 | + | <-start | |
| 25 | + | for i := range 10_000 { | |
| 26 | + | sess.mu.Lock() | |
| 27 | + | sess.name = fmt.Sprintf("session-%d", i) | |
| 28 | + | sess.mu.Unlock() | |
| 29 | + | } | |
| 30 | + | }() | |
| 31 | + | go func() { | |
| 32 | + | defer wg.Done() | |
| 33 | + | <-start | |
| 34 | + | for range 10_000 { | |
| 35 | + | _ = sessionMoveLocator(sess) | |
| 36 | + | } | |
| 37 | + | }() | |
| 38 | + | close(start) | |
| 39 | + | wg.Wait() | |
| 40 | + | ||
| 41 | + | locator := sessionMoveLocator(sess) | |
| 42 | + | require.Equal(t, sess.id, locator.ID) | |
| 43 | + | require.Equal(t, sess.incarnation, locator.Incarnation) | |
| 44 | + | require.Equal(t, "session-9999", locator.Name) | |
| 45 | + | } | |
M internal/usecase/daemon/session_test.go +10 -7
| @@ -283,7 +283,7 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||
| 283 | 283 | ||
| 284 | 284 | // --- attach replace --------------------------------------------------------- | |
| 285 | 285 | ||
| 286 | - | func TestAttachReplaceDetachesOld(t *testing.T) { | |
| 286 | + | func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |
| 287 | 287 | p, releasePTY := newBlockingPTY(t) | |
| 288 | 288 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 289 | 289 | ||
| @@ -295,21 +295,24 @@ func TestAttachReplaceDetachesOld(t *testing.T) { | |||
| 295 | 295 | awaitFrame(t, sendsA, ports.MsgOutput) | |
| 296 | 296 | sess := firstSession(d) | |
| 297 | 297 | require.NotNil(t, sess) | |
| 298 | + | sess.mu.Lock() | |
| 299 | + | acA := sess.client | |
| 300 | + | sess.mu.Unlock() | |
| 301 | + | require.NotNil(t, acA) | |
| 298 | 302 | ||
| 299 | 303 | // Client B attaches to the same session, displacing A. | |
| 300 | 304 | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |
| 301 | 305 | hg.Go(func() { d.handleConn(trB) }) | |
| 302 | 306 | awaitFrame(t, sendsB, ports.MsgWelcome) | |
| 307 | + | d.attachmentCleanupWg.Wait() | |
| 303 | 308 | ||
| 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. | |
| 310 | 310 | sess.mu.Lock() | |
| 311 | 311 | require.NotNil(t, sess.client) | |
| 312 | + | require.NotSame(t, acA, sess.client) | |
| 313 | + | require.Contains(t, sess.snatched, acA) | |
| 312 | 314 | sess.mu.Unlock() | |
| 315 | + | require.Same(t, sess, acA.currentSession()) | |
| 313 | 316 | ||
| 314 | 317 | releaseA() | |
| 315 | 318 | releaseB() | |
M internal/usecase/daemon/snapshot_restore.go +17 -4
| @@ -261,16 +261,23 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 261 | 261 | } | |
| 262 | 262 | } | |
| 263 | 263 | command, args := d.ptyCommand(restoreEnv) | |
| 264 | - | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize)) | |
| 264 | + | lifetime := d.newPaneProcessLifetime(ctx, tabCtx) | |
| 265 | + | pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize)) | |
| 265 | 266 | if err != nil { | |
| 267 | + | lifetime.abort() | |
| 268 | + | if pty != nil { | |
| 269 | + | _ = pty.Close() | |
| 270 | + | } | |
| 266 | 271 | return nil, err | |
| 267 | 272 | } | |
| 268 | 273 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize)) | |
| 269 | - | p.ctx, p.cancel = context.WithCancel(tabCtx) | |
| 274 | + | if !lifetime.publish(p) { | |
| 275 | + | _ = pty.Close() | |
| 276 | + | return nil, lifetime.ctx.Err() | |
| 277 | + | } | |
| 270 | 278 | p.rect = contentRect | |
| 271 | 279 | if err := restorePaneTerminal(p, paneSnap); err != nil { | |
| 272 | - | p.cancel() | |
| 273 | - | _ = pty.Close() | |
| 280 | + | closePaneProcess(p) | |
| 274 | 281 | return nil, err | |
| 275 | 282 | } | |
| 276 | 283 | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| @@ -284,6 +291,12 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 284 | 291 | ||
| 285 | 292 | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |
| 286 | 293 | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 294 | + | // Restored tabs remain private until persistAndRegisterRestoredSession. | |
| 295 | + | // Initialize owners now so registration and reader startup cannot expose an | |
| 296 | + | // ownerless pane. | |
| 297 | + | for _, tb := range tabs { | |
| 298 | + | publishTiledPaneOwners(sess, tb) | |
| 299 | + | } | |
| 287 | 300 | sess.snapEligible.Store(true) | |
| 288 | 301 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 289 | 302 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
A internal/usecase/daemon/snatched_cleanup.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // removeSnatchedAttachment removes exactly one role generation and transport | |
| 4 | + | // from session routing. It never touches the active attachment or closes a | |
| 5 | + | // transport, allowing callers that already interrupted a blocked send to avoid | |
| 6 | + | // closing the same link twice. | |
| 7 | + | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |
| 8 | + | return d.unrouteSnatchedAttachment(token, true) | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |
| 12 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 13 | + | return false | |
| 14 | + | } | |
| 15 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 16 | + | defer frozen.unfreeze() | |
| 17 | + | d.notices.routingMu.Lock() | |
| 18 | + | token.sess.mu.Lock() | |
| 19 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 20 | + | token.ac.roleGeneration.Load() == token.generation && | |
| 21 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 22 | + | if current { | |
| 23 | + | delete(token.sess.snatched, token.ac) | |
| 24 | + | token.ac.roleGeneration.Add(1) | |
| 25 | + | token.ac.invalidateFrozenRoleCapability() | |
| 26 | + | } | |
| 27 | + | token.sess.mu.Unlock() | |
| 28 | + | d.notices.routingMu.Unlock() | |
| 29 | + | if !current { | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | token.ac.clearSnatchedInput() | |
| 34 | + | d.unregisterPreview(token.ac) | |
| 35 | + | if terminal { | |
| 36 | + | token.ac.clearPreviousSession() | |
| 37 | + | } | |
| 38 | + | token.ac.setSession(nil) | |
| 39 | + | token.ac.clearCaptureFrames() | |
| 40 | + | return true | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |
| 44 | + | // retains its resume identity for a role-preserving reconnect. | |
| 45 | + | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |
| 46 | + | if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) { | |
| 47 | + | return false | |
| 48 | + | } | |
| 49 | + | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 50 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 51 | + | return true | |
| 52 | + | } | |
| 53 | + | token.ac.clearPreviousSession() | |
| 54 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 59 | + | if d.parkSnatchedAttachment(token) { | |
| 60 | + | return true | |
| 61 | + | } | |
| 62 | + | return d.dropSnatchedAttachment(token) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |
| 66 | + | // transport that was closed to drain an admitted send. It waits on the gate's | |
| 67 | + | // condition rather than ordinary effect admission, then removes only the exact | |
| 68 | + | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 69 | + | // or session locks while the terminal capability remains frozen. | |
| 70 | + | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 71 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 75 | + | defer frozen.unfreeze() | |
| 76 | + | ||
| 77 | + | d.notices.routingMu.Lock() | |
| 78 | + | token.sess.mu.Lock() | |
| 79 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 80 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 81 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 82 | + | token.sess.mu.Unlock() | |
| 83 | + | d.notices.routingMu.Unlock() | |
| 84 | + | if !current { | |
| 85 | + | return false | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // The frozen gate prevents a promotion from publishing between overlay | |
| 89 | + | // cleanup and terminal registry publication. | |
| 90 | + | d.clearForSnatch(token) | |
| 91 | + | ||
| 92 | + | d.notices.routingMu.Lock() | |
| 93 | + | token.sess.mu.Lock() | |
| 94 | + | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 95 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 96 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 97 | + | if current { | |
| 98 | + | delete(token.sess.snatched, token.ac) | |
| 99 | + | token.ac.roleGeneration.Add(1) | |
| 100 | + | token.ac.invalidateFrozenRoleCapability() | |
| 101 | + | } | |
| 102 | + | token.sess.mu.Unlock() | |
| 103 | + | d.notices.routingMu.Unlock() | |
| 104 | + | if !current { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | token.ac.setSession(nil) | |
| 109 | + | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 110 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 111 | + | token.ac.clearSnatchedInput() | |
| 112 | + | d.unregisterPreview(token.ac) | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 116 | + | token.ac.clearSnatchedInput() | |
| 117 | + | d.unregisterPreview(token.ac) | |
| 118 | + | token.ac.clearPreviousSession() | |
| 119 | + | token.ac.clearCaptureFrames() | |
| 120 | + | return true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // dropSnatchedAttachment removes and closes only the captured snatched link. | |
| 124 | + | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 125 | + | if !d.removeSnatchedAttachment(token) { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |
| 133 | + | // after ownership publication. Cleanup never gates the attaching handshake. A | |
| 134 | + | // displaced render that already owns sendMu is interrupted by closing only its | |
| 135 | + | // captured transport; an idle healthy snatched transport remains connected and | |
| 136 | + | // receives a dependency-free reset panel. | |
| 137 | + | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 138 | + | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 139 | + | blockedRender := result.displacedInterrupted | |
| 140 | + | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 141 | + | blockedRender = !token.ac.sendMu.TryLock() | |
| 142 | + | if !blockedRender { | |
| 143 | + | token.ac.sendMu.Unlock() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | d.attachmentCleanupWg.Go(func() { | |
| 147 | + | if d.afterDisplacedCleanupStarted != nil { | |
| 148 | + | d.afterDisplacedCleanupStarted() | |
| 149 | + | } | |
| 150 | + | if blockedRender { | |
| 151 | + | // This path owns terminal cleanup independently of ordinary role | |
| 152 | + | // admission: a stale send-error detach may transiently freeze the gate. | |
| 153 | + | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 154 | + | d.cleanupInterruptedSnatchedAttachment(token) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 158 | + | if !admitted { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | defer ticket.End() | |
| 162 | + | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |
| 163 | + | ticket.End() | |
| 164 | + | d.parkOrDropSnatchedAttachment(token) | |
| 165 | + | } | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | for _, cleanup := range result.cleanups { | |
| 169 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 170 | + | } | |
| 171 | + | } | |
A internal/usecase/daemon/snatched_client.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type initialSnatchedPanelAttempt struct { | |
| 10 | + | done chan struct{} | |
| 11 | + | err error | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func (ac *attachedClient) claimInitialSnatchedPanel(generation uint64) (*initialSnatchedPanelAttempt, bool) { | |
| 15 | + | ac.initialSnatchedMu.Lock() | |
| 16 | + | defer ac.initialSnatchedMu.Unlock() | |
| 17 | + | if generation < ac.initialSnatchedGeneration { | |
| 18 | + | return nil, false | |
| 19 | + | } | |
| 20 | + | if generation == ac.initialSnatchedGeneration && ac.initialSnatchedAttempt != nil { | |
| 21 | + | return ac.initialSnatchedAttempt, false | |
| 22 | + | } | |
| 23 | + | attempt := &initialSnatchedPanelAttempt{done: make(chan struct{})} | |
| 24 | + | ac.initialSnatchedGeneration = generation | |
| 25 | + | ac.initialSnatchedAttempt = attempt | |
| 26 | + | return attempt, true | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (ac *attachedClient) initialSnatchedPanelClaimed(generation uint64) bool { | |
| 30 | + | ac.initialSnatchedMu.Lock() | |
| 31 | + | defer ac.initialSnatchedMu.Unlock() | |
| 32 | + | return ac.initialSnatchedGeneration == generation && ac.initialSnatchedAttempt != nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (ac *attachedClient) completeInitialSnatchedPanel(attempt *initialSnatchedPanelAttempt, err error) { | |
| 36 | + | ac.initialSnatchedMu.Lock() | |
| 37 | + | attempt.err = err | |
| 38 | + | close(attempt.done) | |
| 39 | + | ac.initialSnatchedMu.Unlock() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *roleEffectTicket) error { | |
| 43 | + | attempt, owner := token.ac.claimInitialSnatchedPanel(token.generation) | |
| 44 | + | if attempt == nil { | |
| 45 | + | return errSnatchedOutputStale | |
| 46 | + | } | |
| 47 | + | if !owner { | |
| 48 | + | <-attempt.done | |
| 49 | + | return attempt.err | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) { | |
| 53 | + | token.ac.completeInitialSnatchedPanel(attempt, errSnatchedOutputStale) | |
| 54 | + | return errSnatchedOutputStale | |
| 55 | + | } | |
| 56 | + | err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket) | |
| 57 | + | token.ac.completeInitialSnatchedPanel(attempt, err) | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | |
| 62 | + | ac := token.ac | |
| 63 | + | ac.sendMu.Lock() | |
| 64 | + | defer ac.sendMu.Unlock() | |
| 65 | + | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | ac.captureFrames = nil | |
| 69 | + | return true | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 73 | + | ac := token.ac | |
| 74 | + | ac.sendMu.Lock() | |
| 75 | + | defer ac.sendMu.Unlock() | |
| 76 | + | if ac.roleGeneration.Load() != token.generation { | |
| 77 | + | return errSnatchedOutputStale | |
| 78 | + | } | |
| 79 | + | if !ac.transportSnapshotCurrent(token.transport) { | |
| 80 | + | return errTransportReplaced | |
| 81 | + | } | |
| 82 | + | if token.effect == nil || !token.effect.beginTransportSend(token.transport) { | |
| 83 | + | return errAttachmentTransition | |
| 84 | + | } | |
| 85 | + | err := token.transport.transport.Send(frame) | |
| 86 | + | if err != nil { | |
| 87 | + | token.effect.reportTransportFailure(token.transport) | |
| 88 | + | } | |
| 89 | + | token.effect.endTransportSend() | |
| 90 | + | return err | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // boundedSendSnatchedControl keeps a best-effort control acknowledgement bound | |
| 94 | + | // to the exact admitted role and link incarnation. On deadline it closes only | |
| 95 | + | // that captured link, releasing either the transport Send or a wait on sendMu. | |
| 96 | + | func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 97 | + | if token.ac == nil || token.effect == nil || token.transport.transport == nil { | |
| 98 | + | return errAttachmentTransition | |
| 99 | + | } | |
| 100 | + | _, err := d.boundedSendWith(token.transport.transport, func() error { | |
| 101 | + | return d.sendSnatchedControl(token, frame) | |
| 102 | + | }) | |
| 103 | + | if errors.Is(err, errSendTimedOut) { | |
| 104 | + | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 105 | + | } | |
| 106 | + | return err | |
| 107 | + | } | |
A internal/usecase/daemon/snatched_client_test.go +1145 -0
| @@ -0,0 +1,1145 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | + | "github.com/bnema/vev/pkg/renderer" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestSessionAttachmentRole(t *testing.T) { | |
| 21 | + | active := &attachedClient{} | |
| 22 | + | waiting := &attachedClient{} | |
| 23 | + | unknown := &attachedClient{} | |
| 24 | + | sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}} | |
| 25 | + | ||
| 26 | + | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 27 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 28 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(unknown)) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | |
| 32 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 33 | + | sess := &session{id: domain.SessionID("work")} | |
| 34 | + | oldTransport := &closeTrackingTransport{} | |
| 35 | + | old := &attachedClient{tr: oldTransport} | |
| 36 | + | old.setSession(sess) | |
| 37 | + | sess.client = old | |
| 38 | + | d.sessions[sess.id] = sess | |
| 39 | + | ||
| 40 | + | newTransport := &closeTrackingTransport{} | |
| 41 | + | next := &attachedClient{tr: newTransport} | |
| 42 | + | expected := next.transportSnapshot() | |
| 43 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 44 | + | target: sess, | |
| 45 | + | next: next, | |
| 46 | + | expectedRole: attachmentDetached, | |
| 47 | + | targetRole: attachmentActive, | |
| 48 | + | expectedTransport: expected, | |
| 49 | + | }) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | ||
| 52 | + | sess.mu.Lock() | |
| 53 | + | require.Same(t, next, sess.client) | |
| 54 | + | require.Contains(t, sess.snatched, old) | |
| 55 | + | _, nextSnatched := sess.snatched[next] | |
| 56 | + | require.False(t, nextSnatched) | |
| 57 | + | sess.mu.Unlock() | |
| 58 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 59 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(old)) | |
| 60 | + | require.True(t, result.published.current()) | |
| 61 | + | require.True(t, result.displaced.current()) | |
| 62 | + | require.Equal(t, uint64(1), result.published.generation) | |
| 63 | + | require.Equal(t, uint64(1), result.displaced.generation) | |
| 64 | + | require.Same(t, sess, next.currentSession()) | |
| 65 | + | require.Same(t, sess, old.currentSession()) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) { | |
| 69 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 70 | + | sess := &session{id: domain.SessionID("work")} | |
| 71 | + | oldTransport := &closeTrackingTransport{} | |
| 72 | + | old := &attachedClient{ | |
| 73 | + | tr: oldTransport, | |
| 74 | + | output: newOutputStateStream(), | |
| 75 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 76 | + | } | |
| 77 | + | old.initOverlays() | |
| 78 | + | old.setThemeForTest(themeui.Theme{}) | |
| 79 | + | old.setSession(sess) | |
| 80 | + | sess.client = old | |
| 81 | + | d.sessions[sess.id] = sess | |
| 82 | + | ||
| 83 | + | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 84 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 85 | + | target: sess, | |
| 86 | + | next: next, | |
| 87 | + | expectedRole: attachmentDetached, | |
| 88 | + | targetRole: attachmentActive, | |
| 89 | + | expectedTransport: next.transportSnapshot(), | |
| 90 | + | }) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | ||
| 93 | + | d.deferAttachmentTransitionCleanups(result) | |
| 94 | + | d.attachmentCleanupWg.Wait() | |
| 95 | + | ||
| 96 | + | require.False(t, oldTransport.Closed(), "snatched transport must stay open") | |
| 97 | + | frames := oldTransport.Sends() | |
| 98 | + | require.Len(t, frames, 1) | |
| 99 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 100 | + | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) { | |
| 106 | + | tests := []struct { | |
| 107 | + | name string | |
| 108 | + | setup func(*attachedClient, *session, *transactionalResizePTY) | |
| 109 | + | frame func() ports.Frame | |
| 110 | + | assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport) | |
| 111 | + | }{ | |
| 112 | + | { | |
| 113 | + | name: "forwarded input", | |
| 114 | + | frame: func() ports.Frame { return frameInput([]byte("stale")) }, | |
| 115 | + | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 116 | + | require.Empty(t, pty.writes(), "stale input reached the PTY") | |
| 117 | + | }, | |
| 118 | + | }, | |
| 119 | + | { | |
| 120 | + | name: "key action", | |
| 121 | + | frame: func() ports.Frame { return frameInput([]byte("\x1b ")) }, | |
| 122 | + | assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 123 | + | require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay") | |
| 124 | + | require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay") | |
| 125 | + | }, | |
| 126 | + | }, | |
| 127 | + | { | |
| 128 | + | name: "mouse input", | |
| 129 | + | setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) { | |
| 130 | + | pane := sess.activeTab().focusedPane() | |
| 131 | + | pane.mu.Lock() | |
| 132 | + | pane.screen.Write([]byte("\x1b[?1000h")) | |
| 133 | + | pane.mu.Unlock() | |
| 134 | + | }, | |
| 135 | + | frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) }, | |
| 136 | + | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 137 | + | require.Empty(t, pty.writes(), "stale mouse input reached the PTY") | |
| 138 | + | }, | |
| 139 | + | }, | |
| 140 | + | { | |
| 141 | + | name: "image push", | |
| 142 | + | frame: func() ports.Frame { | |
| 143 | + | return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})} | |
| 144 | + | }, | |
| 145 | + | assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 146 | + | sess.mu.Lock() | |
| 147 | + | files := append([]string(nil), sess.clipFiles...) | |
| 148 | + | sess.mu.Unlock() | |
| 149 | + | require.Empty(t, files, "stale image push created a session clipboard file") | |
| 150 | + | require.Empty(t, pty.writes(), "stale image path reached the PTY") | |
| 151 | + | }, | |
| 152 | + | }, | |
| 153 | + | { | |
| 154 | + | name: "resize", | |
| 155 | + | frame: func() ports.Frame { | |
| 156 | + | return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})} | |
| 157 | + | }, | |
| 158 | + | assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 159 | + | require.Empty(t, pty.requested(), "stale resize reached a session PTY") | |
| 160 | + | require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client") | |
| 161 | + | }, | |
| 162 | + | }, | |
| 163 | + | { | |
| 164 | + | name: "theme", | |
| 165 | + | frame: func() ports.Frame { | |
| 166 | + | return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 167 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 168 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 169 | + | })} | |
| 170 | + | }, | |
| 171 | + | assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 172 | + | require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client") | |
| 173 | + | }, | |
| 174 | + | }, | |
| 175 | + | { | |
| 176 | + | name: "client notice", | |
| 177 | + | frame: func() ports.Frame { | |
| 178 | + | return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})} | |
| 179 | + | }, | |
| 180 | + | assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 181 | + | require.Empty(t, d.notices.history(), "stale notice mutated daemon history") | |
| 182 | + | oldToasts, _ := visibleToasts(old) | |
| 183 | + | nextToasts, _ := visibleToasts(next) | |
| 184 | + | require.Empty(t, oldToasts) | |
| 185 | + | require.Empty(t, nextToasts) | |
| 186 | + | }, | |
| 187 | + | }, | |
| 188 | + | { | |
| 189 | + | name: "ack", | |
| 190 | + | setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) { | |
| 191 | + | old.output.next = 2 | |
| 192 | + | }, | |
| 193 | + | frame: func() ports.Frame { | |
| 194 | + | return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})} | |
| 195 | + | }, | |
| 196 | + | assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 197 | + | require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state") | |
| 198 | + | }, | |
| 199 | + | }, | |
| 200 | + | { | |
| 201 | + | name: "ping", | |
| 202 | + | frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} }, | |
| 203 | + | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) { | |
| 204 | + | select { | |
| 205 | + | case frame := <-tr.sends: | |
| 206 | + | t.Fatalf("stale ping wrote frame type %d", frame.Type) | |
| 207 | + | default: | |
| 208 | + | } | |
| 209 | + | }, | |
| 210 | + | }, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | for _, tt := range tests { | |
| 214 | + | t.Run(tt.name, func(t *testing.T) { | |
| 215 | + | pty := &transactionalResizePTY{} | |
| 216 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 217 | + | d.tempDir = t.TempDir() | |
| 218 | + | oldTransport := newDatagramTestTransport() | |
| 219 | + | old.replaceTransport(oldTransport) | |
| 220 | + | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 221 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 222 | + | token := sess.attachmentToken(old, oldTransport) | |
| 223 | + | token.lease = rc.attachmentLease(old) | |
| 224 | + | require.True(t, token.activeCurrent()) | |
| 225 | + | if tt.setup != nil { | |
| 226 | + | tt.setup(old, sess, pty) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 230 | + | next.initOverlays() | |
| 231 | + | var transition attachmentTransitionResult | |
| 232 | + | d.afterActiveFrameDispatch = func(attachmentRoleToken) { | |
| 233 | + | d.afterActiveFrameDispatch = nil | |
| 234 | + | var err error | |
| 235 | + | transition, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 236 | + | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 237 | + | expectedTransport: next.transportSnapshot(), ready: true, | |
| 238 | + | }) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | d.handleActiveClientFrame(token, tt.frame()) | |
| 243 | + | for _, cleanup := range transition.cleanups { | |
| 244 | + | cleanup.finish() | |
| 245 | + | } | |
| 246 | + | tt.assert(t, d, sess, old, next, pty, oldTransport) | |
| 247 | + | }) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { | |
| 252 | + | pty := &transactionalResizePTY{} | |
| 253 | + | d, sess, first, _ := newManualSessionWithPTYs(t, pty) | |
| 254 | + | firstTransport := newDatagramTestTransport() | |
| 255 | + | first.replaceTransport(firstTransport) | |
| 256 | + | ||
| 257 | + | secondTransport := newDatagramTestTransport() | |
| 258 | + | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 259 | + | second.initOverlays() | |
| 260 | + | firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 261 | + | target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 262 | + | expectedTransport: second.transportSnapshot(), ready: true, | |
| 263 | + | }) | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | for _, cleanup := range firstReplacement.cleanups { | |
| 266 | + | cleanup.finish() | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | thirdTransport := newDatagramTestTransport() | |
| 270 | + | third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size} | |
| 271 | + | third.initOverlays() | |
| 272 | + | secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 273 | + | target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 274 | + | expectedTransport: third.transportSnapshot(), ready: true, | |
| 275 | + | }) | |
| 276 | + | require.NoError(t, err) | |
| 277 | + | for _, cleanup := range secondReplacement.cleanups { | |
| 278 | + | cleanup.finish() | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | otherCtx, otherCancel := context.WithCancel(d.serveCtx) | |
| 282 | + | defer otherCancel() | |
| 283 | + | otherTransport := newDatagramTestTransport() | |
| 284 | + | otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size} | |
| 285 | + | otherClient.initOverlays() | |
| 286 | + | other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient} | |
| 287 | + | otherClient.setSession(other) | |
| 288 | + | d.mu.Lock() | |
| 289 | + | d.sessions[other.id] = other | |
| 290 | + | d.mu.Unlock() | |
| 291 | + | d.attachCoordinator(other, nil, otherClient, true) | |
| 292 | + | ||
| 293 | + | roles := []*attachedClient{first, second, third} | |
| 294 | + | generations := make([]uint64, len(roles)) | |
| 295 | + | for i, ac := range roles { | |
| 296 | + | generations[i] = ac.roleGeneration.Load() | |
| 297 | + | } | |
| 298 | + | var killedLoops sync.WaitGroup | |
| 299 | + | for _, ac := range roles { | |
| 300 | + | killedLoops.Go(func() { d.runConnLoop(ac) }) | |
| 301 | + | } | |
| 302 | + | otherDone := make(chan struct{}) | |
| 303 | + | go func() { | |
| 304 | + | d.runConnLoop(otherClient) | |
| 305 | + | close(otherDone) | |
| 306 | + | }() | |
| 307 | + | defer func() { | |
| 308 | + | _ = firstTransport.Close() | |
| 309 | + | _ = secondTransport.Close() | |
| 310 | + | _ = thirdTransport.Close() | |
| 311 | + | _ = otherTransport.Close() | |
| 312 | + | killedLoops.Wait() | |
| 313 | + | <-otherDone | |
| 314 | + | }() | |
| 315 | + | ||
| 316 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 317 | + | d.waitNotifies() | |
| 318 | + | select { | |
| 319 | + | case <-waitGroupDone(&killedLoops): | |
| 320 | + | case <-time.After(2 * time.Second): | |
| 321 | + | t.Fatal("killed active and snatched connection loops did not retire") | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | sess.mu.Lock() | |
| 325 | + | require.Nil(t, sess.client) | |
| 326 | + | require.Empty(t, sess.snatched) | |
| 327 | + | sess.mu.Unlock() | |
| 328 | + | for i, ac := range roles { | |
| 329 | + | require.Nil(t, ac.currentSession()) | |
| 330 | + | require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation") | |
| 331 | + | } | |
| 332 | + | for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} { | |
| 333 | + | detached := awaitFrame(t, transport.sends, ports.MsgDetached) | |
| 334 | + | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 335 | + | require.NoError(t, err) | |
| 336 | + | require.Equal(t, ports.ReasonSessionKilled, message.Reason) | |
| 337 | + | _, recvErr := transport.Recv() | |
| 338 | + | require.ErrorIs(t, recvErr, io.EOF) | |
| 339 | + | } | |
| 340 | + | require.Same(t, otherClient, other.client) | |
| 341 | + | require.Same(t, other, otherClient.currentSession()) | |
| 342 | + | require.Equal(t, attachmentActive, other.attachmentRole(otherClient)) | |
| 343 | + | ||
| 344 | + | otherTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 345 | + | require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type) | |
| 346 | + | select { | |
| 347 | + | case <-otherDone: | |
| 348 | + | t.Fatal("killing another session retired the active client's connection") | |
| 349 | + | default: | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) { | |
| 354 | + | pty, release := newBlockingPTY(t) | |
| 355 | + | defer release() | |
| 356 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 357 | + | ||
| 358 | + | parkedTransport := &closeTrackingTransport{} | |
| 359 | + | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 360 | + | require.NoError(t, err) | |
| 361 | + | parkedToken := parkedClient.resumeToken | |
| 362 | + | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 363 | + | ||
| 364 | + | activeTransport := &closeTrackingTransport{} | |
| 365 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 366 | + | require.NoError(t, err) | |
| 367 | + | d.mu.Lock() | |
| 368 | + | parked := d.parked[parkedToken] | |
| 369 | + | d.mu.Unlock() | |
| 370 | + | require.NotNil(t, parked) | |
| 371 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 372 | + | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 373 | + | ||
| 374 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 375 | + | d.waitNotifies() | |
| 376 | + | ||
| 377 | + | require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership") | |
| 378 | + | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation") | |
| 379 | + | require.Zero(t, parkedClient.resumeToken) | |
| 380 | + | require.False(t, parkedClient.parked) | |
| 381 | + | select { | |
| 382 | + | case <-parked.done: | |
| 383 | + | default: | |
| 384 | + | t.Fatal("terminal teardown left the parked expiry goroutine live") | |
| 385 | + | } | |
| 386 | + | require.True(t, parkedTransport.Closed()) | |
| 387 | + | require.Nil(t, parkedClient.currentSession()) | |
| 388 | + | require.Nil(t, active.currentSession()) | |
| 389 | + | require.True(t, activeTransport.Closed()) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func TestDaemonShutdownClosesSnatchedClients(t *testing.T) { | |
| 393 | + | pty, release := newBlockingPTY(t) | |
| 394 | + | defer release() | |
| 395 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 396 | + | ||
| 397 | + | parkedTransport := &closeTrackingTransport{} | |
| 398 | + | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 399 | + | require.NoError(t, err) | |
| 400 | + | parkedToken := parkedClient.resumeToken | |
| 401 | + | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 402 | + | ||
| 403 | + | waitingTransport := &closeTrackingTransport{} | |
| 404 | + | _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport) | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | activeTransport := &closeTrackingTransport{} | |
| 407 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 408 | + | require.NoError(t, err) | |
| 409 | + | d.attachmentCleanupWg.Wait() | |
| 410 | + | ||
| 411 | + | d.mu.Lock() | |
| 412 | + | parked := d.parked[parkedToken] | |
| 413 | + | d.mu.Unlock() | |
| 414 | + | require.NotNil(t, parked) | |
| 415 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 416 | + | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 417 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 418 | + | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 419 | + | ||
| 420 | + | require.False(t, d.shutdownAll(ports.ReasonServerShutdown)) | |
| 421 | + | d.waitNotifies() | |
| 422 | + | d.attachmentCleanupWg.Wait() | |
| 423 | + | ||
| 424 | + | require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership") | |
| 425 | + | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration) | |
| 426 | + | select { | |
| 427 | + | case <-parked.done: | |
| 428 | + | default: | |
| 429 | + | t.Fatal("shutdown left the parked expiry goroutine live") | |
| 430 | + | } | |
| 431 | + | for _, client := range []*attachedClient{parkedClient, waiting, active} { | |
| 432 | + | require.Nil(t, client.currentSession()) | |
| 433 | + | } | |
| 434 | + | for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} { | |
| 435 | + | require.True(t, transport.Closed()) | |
| 436 | + | } | |
| 437 | + | for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} { | |
| 438 | + | frames := transport.Sends() | |
| 439 | + | require.NotEmpty(t, frames) | |
| 440 | + | detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload) | |
| 441 | + | require.NoError(t, err) | |
| 442 | + | require.Equal(t, ports.ReasonServerShutdown, detached.Reason) | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) { | |
| 447 | + | pty := &transactionalResizePTY{} | |
| 448 | + | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 449 | + | waitingTransport := &closeTrackingTransport{} | |
| 450 | + | waiting.replaceTransport(waitingTransport) | |
| 451 | + | ||
| 452 | + | activeTransport := &closeTrackingTransport{} | |
| 453 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 454 | + | active.initOverlays() | |
| 455 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 456 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 457 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 458 | + | }) | |
| 459 | + | require.NoError(t, err) | |
| 460 | + | for _, cleanup := range transition.cleanups { | |
| 461 | + | cleanup.finish() | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | activeTheme := ports.Theme{ | |
| 465 | + | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 466 | + | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 467 | + | TrueColor: true, | |
| 468 | + | } | |
| 469 | + | d.applyTheme(sess, active, activeTheme) | |
| 470 | + | activeSize := domain.Size{Cols: 101, Rows: 37} | |
| 471 | + | d.resize(sess, active, activeSize) | |
| 472 | + | require.Equal(t, activeSize, active.size) | |
| 473 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 474 | + | requestedBeforeQuit := pty.requested() | |
| 475 | + | ||
| 476 | + | token := sess.attachmentToken(waiting, waitingTransport) | |
| 477 | + | require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 478 | + | Type: ports.MsgInput, | |
| 479 | + | Payload: ports.MarshalInput(ports.Input{ | |
| 480 | + | Data: []byte{'q'}, | |
| 481 | + | }), | |
| 482 | + | })) | |
| 483 | + | ||
| 484 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 485 | + | require.Same(t, active, sess.client) | |
| 486 | + | require.False(t, activeTransport.Closed()) | |
| 487 | + | require.True(t, waitingTransport.Closed()) | |
| 488 | + | require.Equal(t, activeSize, active.size) | |
| 489 | + | require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes") | |
| 490 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | |
| 494 | + | writes := make(chan []byte, 1) | |
| 495 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 496 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 497 | + | oldTransport := newDatagramTestTransport() | |
| 498 | + | old.replaceTransport(oldTransport) | |
| 499 | + | ||
| 500 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 501 | + | next.initOverlays() | |
| 502 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 503 | + | target: sess, | |
| 504 | + | next: next, | |
| 505 | + | expectedRole: attachmentDetached, | |
| 506 | + | targetRole: attachmentActive, | |
| 507 | + | expectedTransport: next.transportSnapshot(), | |
| 508 | + | }) | |
| 509 | + | require.NoError(t, err) | |
| 510 | + | d.deferAttachmentTransitionCleanups(result) | |
| 511 | + | d.attachmentCleanupWg.Wait() | |
| 512 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 513 | + | ||
| 514 | + | oldTransport.recv <- frameInput([]byte("restricted")) | |
| 515 | + | require.NoError(t, oldTransport.Close()) | |
| 516 | + | d.runConnLoop(old) | |
| 517 | + | ||
| 518 | + | select { | |
| 519 | + | case got := <-writes: | |
| 520 | + | t.Fatalf("snatched input reached PTY: %q", got) | |
| 521 | + | default: | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) { | |
| 526 | + | writes := make(chan []byte, 1) | |
| 527 | + | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 528 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 529 | + | old.replaceTransport(&closeTrackingTransport{}) | |
| 530 | + | ||
| 531 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 532 | + | next.initOverlays() | |
| 533 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 534 | + | target: sess, | |
| 535 | + | next: next, | |
| 536 | + | expectedRole: attachmentDetached, | |
| 537 | + | targetRole: attachmentActive, | |
| 538 | + | expectedTransport: next.transportSnapshot(), | |
| 539 | + | }) | |
| 540 | + | require.NoError(t, err) | |
| 541 | + | ||
| 542 | + | (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale")) | |
| 543 | + | ||
| 544 | + | select { | |
| 545 | + | case got := <-writes: | |
| 546 | + | t.Fatalf("stale active key forward reached PTY: %q", got) | |
| 547 | + | default: | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) { | |
| 552 | + | pty := &transactionalResizePTY{} | |
| 553 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 554 | + | oldTransport := newDatagramTestTransport() | |
| 555 | + | old.replaceTransport(oldTransport) | |
| 556 | + | ||
| 557 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 558 | + | next.initOverlays() | |
| 559 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 560 | + | target: sess, | |
| 561 | + | next: next, | |
| 562 | + | expectedRole: attachmentDetached, | |
| 563 | + | targetRole: attachmentActive, | |
| 564 | + | expectedTransport: next.transportSnapshot(), | |
| 565 | + | }) | |
| 566 | + | require.NoError(t, err) | |
| 567 | + | d.deferAttachmentTransitionCleanups(result) | |
| 568 | + | d.attachmentCleanupWg.Wait() | |
| 569 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 570 | + | ||
| 571 | + | want := domain.Size{Cols: 100, Rows: 30} | |
| 572 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})} | |
| 573 | + | require.NoError(t, oldTransport.Close()) | |
| 574 | + | d.runConnLoop(old) | |
| 575 | + | ||
| 576 | + | require.Equal(t, want, old.size) | |
| 577 | + | require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs") | |
| 578 | + | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 579 | + | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 580 | + | require.NoError(t, err) | |
| 581 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) { | |
| 585 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 586 | + | oldTransport := newDatagramTestTransport() | |
| 587 | + | old.replaceTransport(oldTransport) | |
| 588 | + | ||
| 589 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 590 | + | next.initOverlays() | |
| 591 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 592 | + | target: sess, | |
| 593 | + | next: next, | |
| 594 | + | expectedRole: attachmentDetached, | |
| 595 | + | targetRole: attachmentActive, | |
| 596 | + | expectedTransport: next.transportSnapshot(), | |
| 597 | + | }) | |
| 598 | + | require.NoError(t, err) | |
| 599 | + | d.deferAttachmentTransitionCleanups(result) | |
| 600 | + | d.attachmentCleanupWg.Wait() | |
| 601 | + | panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 602 | + | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 603 | + | require.NoError(t, err) | |
| 604 | + | require.Equal(t, uint64(1), old.output.outstanding()) | |
| 605 | + | ||
| 606 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})} | |
| 607 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 608 | + | require.NoError(t, oldTransport.Close()) | |
| 609 | + | d.runConnLoop(old) | |
| 610 | + | ||
| 611 | + | require.Zero(t, old.output.outstanding()) | |
| 612 | + | require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type) | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) { | |
| 616 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 617 | + | oldTransport := newDatagramTestTransport() | |
| 618 | + | old.replaceTransport(oldTransport) | |
| 619 | + | ||
| 620 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 621 | + | next.initOverlays() | |
| 622 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 623 | + | target: sess, | |
| 624 | + | next: next, | |
| 625 | + | expectedRole: attachmentDetached, | |
| 626 | + | targetRole: attachmentActive, | |
| 627 | + | expectedTransport: next.transportSnapshot(), | |
| 628 | + | }) | |
| 629 | + | require.NoError(t, err) | |
| 630 | + | d.deferAttachmentTransitionCleanups(result) | |
| 631 | + | d.attachmentCleanupWg.Wait() | |
| 632 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 633 | + | ||
| 634 | + | activeTheme := ports.Theme{ | |
| 635 | + | HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 636 | + | HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 637 | + | TrueColor: true, | |
| 638 | + | } | |
| 639 | + | d.applyTheme(sess, next, activeTheme) | |
| 640 | + | snatchedTheme := ports.Theme{ | |
| 641 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 642 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 643 | + | TrueColor: true, | |
| 644 | + | } | |
| 645 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)} | |
| 646 | + | require.NoError(t, oldTransport.Close()) | |
| 647 | + | d.runConnLoop(old) | |
| 648 | + | ||
| 649 | + | require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground) | |
| 650 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 651 | + | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 652 | + | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 653 | + | require.NoError(t, err) | |
| 654 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 658 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 659 | + | oldTransport := newDatagramTestTransport() | |
| 660 | + | old.replaceTransport(oldTransport) | |
| 661 | + | ||
| 662 | + | activeTransport := &closeTrackingTransport{} | |
| 663 | + | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 664 | + | next.initOverlays() | |
| 665 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 666 | + | target: sess, | |
| 667 | + | next: next, | |
| 668 | + | expectedRole: attachmentDetached, | |
| 669 | + | targetRole: attachmentActive, | |
| 670 | + | expectedTransport: next.transportSnapshot(), | |
| 671 | + | }) | |
| 672 | + | require.NoError(t, err) | |
| 673 | + | d.deferAttachmentTransitionCleanups(result) | |
| 674 | + | d.attachmentCleanupWg.Wait() | |
| 675 | + | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 676 | + | ||
| 677 | + | oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})} | |
| 678 | + | d.runConnLoop(old) | |
| 679 | + | ||
| 680 | + | detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached) | |
| 681 | + | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 682 | + | require.NoError(t, err) | |
| 683 | + | require.Equal(t, ports.ReasonDetach, message.Reason) | |
| 684 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 685 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 686 | + | require.Same(t, next, sess.client) | |
| 687 | + | require.False(t, activeTransport.Closed()) | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) { | |
| 691 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 692 | + | oldTransport := &closeTrackingTransport{} | |
| 693 | + | old.replaceTransport(oldTransport) | |
| 694 | + | ||
| 695 | + | d.enterPicker(sess, old) | |
| 696 | + | d.enterPalette(sess, old) | |
| 697 | + | d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil }) | |
| 698 | + | d.enterCopyMode(sess, old) | |
| 699 | + | d.enterNotices(sess, old) | |
| 700 | + | old.overlays.resizeMu.Lock() | |
| 701 | + | old.overlays.resizeActive = true | |
| 702 | + | old.overlays.resizePending = []byte("pending") | |
| 703 | + | old.overlays.resizeMu.Unlock() | |
| 704 | + | old.sendMu.Lock() | |
| 705 | + | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 706 | + | old.sendMu.Unlock() | |
| 707 | + | require.True(t, old.overlays.Active()) | |
| 708 | + | ||
| 709 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 710 | + | next.initOverlays() | |
| 711 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 712 | + | target: sess, | |
| 713 | + | next: next, | |
| 714 | + | expectedRole: attachmentDetached, | |
| 715 | + | targetRole: attachmentActive, | |
| 716 | + | expectedTransport: next.transportSnapshot(), | |
| 717 | + | }) | |
| 718 | + | require.NoError(t, err) | |
| 719 | + | d.deferAttachmentTransitionCleanups(result) | |
| 720 | + | d.attachmentCleanupWg.Wait() | |
| 721 | + | ||
| 722 | + | require.False(t, old.overlays.Active()) | |
| 723 | + | old.overlays.pickerMu.Lock() | |
| 724 | + | require.Nil(t, old.overlays.pickerPreview) | |
| 725 | + | require.Nil(t, old.overlays.pickerPreviewSession) | |
| 726 | + | old.overlays.pickerMu.Unlock() | |
| 727 | + | old.sendMu.Lock() | |
| 728 | + | require.Empty(t, old.captureFrames) | |
| 729 | + | old.sendMu.Unlock() | |
| 730 | + | require.False(t, oldTransport.Closed()) | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | type failingSnatchedTransport struct { | |
| 734 | + | mu sync.Mutex | |
| 735 | + | closed bool | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 739 | + | func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 740 | + | func (t *failingSnatchedTransport) Close() error { | |
| 741 | + | t.mu.Lock() | |
| 742 | + | t.closed = true | |
| 743 | + | t.mu.Unlock() | |
| 744 | + | return nil | |
| 745 | + | } | |
| 746 | + | func (t *failingSnatchedTransport) Closed() bool { | |
| 747 | + | t.mu.Lock() | |
| 748 | + | defer t.mu.Unlock() | |
| 749 | + | return t.closed | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { | |
| 753 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 754 | + | oldTransport := &failingSnatchedTransport{} | |
| 755 | + | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 756 | + | old.initOverlays() | |
| 757 | + | sess := &session{id: "work", client: old} | |
| 758 | + | old.setSession(sess) | |
| 759 | + | d.sessions[sess.id] = sess | |
| 760 | + | ||
| 761 | + | activeTransport := &closeTrackingTransport{} | |
| 762 | + | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 763 | + | next.initOverlays() | |
| 764 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 765 | + | target: sess, | |
| 766 | + | next: next, | |
| 767 | + | expectedRole: attachmentDetached, | |
| 768 | + | targetRole: attachmentActive, | |
| 769 | + | expectedTransport: next.transportSnapshot(), | |
| 770 | + | }) | |
| 771 | + | require.NoError(t, err) | |
| 772 | + | d.deferAttachmentTransitionCleanups(result) | |
| 773 | + | d.attachmentCleanupWg.Wait() | |
| 774 | + | ||
| 775 | + | require.True(t, oldTransport.Closed()) | |
| 776 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 777 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 778 | + | require.Same(t, next, sess.client) | |
| 779 | + | require.False(t, activeTransport.Closed()) | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) { | |
| 783 | + | pty := &transactionalResizePTY{} | |
| 784 | + | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 785 | + | failed := &failingSnatchedTransport{} | |
| 786 | + | waiting.replaceTransport(failed) | |
| 787 | + | ||
| 788 | + | activeTransport := &closeTrackingTransport{} | |
| 789 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 790 | + | active.initOverlays() | |
| 791 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 792 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 793 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 794 | + | }) | |
| 795 | + | require.NoError(t, err) | |
| 796 | + | ||
| 797 | + | activeTheme := ports.Theme{ | |
| 798 | + | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 799 | + | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 800 | + | TrueColor: true, | |
| 801 | + | } | |
| 802 | + | d.applyTheme(sess, active, activeTheme) | |
| 803 | + | activeSize := domain.Size{Cols: 103, Rows: 39} | |
| 804 | + | d.resize(sess, active, activeSize) | |
| 805 | + | require.Equal(t, activeSize, active.size) | |
| 806 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 807 | + | requestedBeforeFailure := pty.requested() | |
| 808 | + | ||
| 809 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 810 | + | d.attachmentCleanupWg.Wait() | |
| 811 | + | ||
| 812 | + | require.True(t, failed.Closed()) | |
| 813 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 814 | + | require.Same(t, active, sess.client) | |
| 815 | + | require.False(t, activeTransport.Closed()) | |
| 816 | + | require.Equal(t, activeSize, active.size) | |
| 817 | + | require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes") | |
| 818 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 822 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 823 | + | oldTransport := &failingSnatchedTransport{} | |
| 824 | + | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 825 | + | old.initOverlays() | |
| 826 | + | sess := &session{id: "work", client: old} | |
| 827 | + | old.setSession(sess) | |
| 828 | + | d.sessions[sess.id] = sess | |
| 829 | + | ||
| 830 | + | activeTransport := &closeTrackingTransport{} | |
| 831 | + | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 832 | + | next.initOverlays() | |
| 833 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 834 | + | target: sess, | |
| 835 | + | next: next, | |
| 836 | + | expectedRole: attachmentDetached, | |
| 837 | + | targetRole: attachmentActive, | |
| 838 | + | expectedTransport: next.transportSnapshot(), | |
| 839 | + | }) | |
| 840 | + | require.NoError(t, err) | |
| 841 | + | for _, cleanup := range result.cleanups { | |
| 842 | + | cleanup.finish() | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | theme := ports.Theme{ | |
| 846 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 847 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 848 | + | TrueColor: true, | |
| 849 | + | } | |
| 850 | + | require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{ | |
| 851 | + | Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme), | |
| 852 | + | })) | |
| 853 | + | ||
| 854 | + | require.True(t, oldTransport.Closed()) | |
| 855 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 856 | + | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 857 | + | require.Same(t, next, sess.client) | |
| 858 | + | require.False(t, activeTransport.Closed()) | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) { | |
| 862 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 863 | + | tr := &closeTrackingTransport{} | |
| 864 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 865 | + | ac.initOverlays() | |
| 866 | + | ac.roleGeneration.Store(1) | |
| 867 | + | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | |
| 868 | + | ac.setSession(sess) | |
| 869 | + | token := attachmentRoleToken{ | |
| 870 | + | sess: sess, ac: ac, role: attachmentSnatched, | |
| 871 | + | generation: 1, transport: ac.transportSnapshot(), | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | pickerCleared := make(chan struct{}) | |
| 875 | + | d.afterSnatchOverlayFamily = func(family string) { | |
| 876 | + | if family == "picker" { | |
| 877 | + | close(pickerCleared) | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | ac.overlays.resizeMu.Lock() | |
| 881 | + | resizeLocked := true | |
| 882 | + | defer func() { | |
| 883 | + | if resizeLocked { | |
| 884 | + | ac.overlays.resizeMu.Unlock() | |
| 885 | + | } | |
| 886 | + | }() | |
| 887 | + | cleared := make(chan bool, 1) | |
| 888 | + | go func() { cleared <- d.clearForSnatch(token) }() | |
| 889 | + | select { | |
| 890 | + | case <-pickerCleared: | |
| 891 | + | case <-time.After(2 * time.Second): | |
| 892 | + | t.Fatal("picker family did not clear before blocked resize family") | |
| 893 | + | } | |
| 894 | + | require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu") | |
| 895 | + | ac.overlays.pickerMu.Unlock() | |
| 896 | + | ||
| 897 | + | ac.overlays.resizeMu.Unlock() | |
| 898 | + | resizeLocked = false | |
| 899 | + | require.True(t, <-cleared) | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) { | |
| 903 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 904 | + | tr := &closeTrackingTransport{} | |
| 905 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 906 | + | ac.initOverlays() | |
| 907 | + | ac.roleGeneration.Store(1) | |
| 908 | + | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | |
| 909 | + | ac.setSession(sess) | |
| 910 | + | token := attachmentRoleToken{ | |
| 911 | + | sess: sess, ac: ac, role: attachmentSnatched, | |
| 912 | + | generation: 1, transport: ac.transportSnapshot(), | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | ac.overlays.pickerMu.Lock() | |
| 916 | + | started := make(chan struct{}) | |
| 917 | + | cleared := make(chan bool, 1) | |
| 918 | + | go func() { | |
| 919 | + | close(started) | |
| 920 | + | cleared <- d.clearForSnatch(token) | |
| 921 | + | }() | |
| 922 | + | <-started | |
| 923 | + | ac.roleGeneration.Add(1) | |
| 924 | + | ac.overlays.resizeMu.Lock() | |
| 925 | + | ac.overlays.resizeActive = true | |
| 926 | + | ac.overlays.resizeMu.Unlock() | |
| 927 | + | ac.overlays.pickerMu.Unlock() | |
| 928 | + | ||
| 929 | + | require.False(t, <-cleared) | |
| 930 | + | require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation") | |
| 931 | + | } | |
| 932 | + | ||
| 933 | + | func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |
| 934 | + | tests := []struct { | |
| 935 | + | name string | |
| 936 | + | mutate func(*Daemon, *session, *attachedClient) | |
| 937 | + | }{ | |
| 938 | + | { | |
| 939 | + | name: "stale transport", | |
| 940 | + | mutate: func(_ *Daemon, _ *session, next *attachedClient) { | |
| 941 | + | next.replaceTransport(&closeTrackingTransport{}) | |
| 942 | + | }, | |
| 943 | + | }, | |
| 944 | + | { | |
| 945 | + | name: "session lifecycle ended", | |
| 946 | + | mutate: func(d *Daemon, sess *session, _ *attachedClient) { | |
| 947 | + | delete(d.sessions, sess.id) | |
| 948 | + | }, | |
| 949 | + | }, | |
| 950 | + | { | |
| 951 | + | name: "unexpected role", | |
| 952 | + | mutate: func(_ *Daemon, sess *session, next *attachedClient) { | |
| 953 | + | sess.mu.Lock() | |
| 954 | + | sess.addSnatchedLocked(next) | |
| 955 | + | sess.mu.Unlock() | |
| 956 | + | }, | |
| 957 | + | }, | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | for _, tt := range tests { | |
| 961 | + | t.Run(tt.name, func(t *testing.T) { | |
| 962 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 963 | + | sess := &session{id: domain.SessionID("work")} | |
| 964 | + | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 965 | + | old.setSession(sess) | |
| 966 | + | sess.client = old | |
| 967 | + | d.sessions[sess.id] = sess | |
| 968 | + | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 969 | + | expected := next.transportSnapshot() | |
| 970 | + | tt.mutate(d, sess, next) | |
| 971 | + | ||
| 972 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 973 | + | target: sess, | |
| 974 | + | next: next, | |
| 975 | + | expectedRole: attachmentDetached, | |
| 976 | + | targetRole: attachmentActive, | |
| 977 | + | expectedTransport: expected, | |
| 978 | + | }) | |
| 979 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 980 | + | require.Same(t, old, sess.client) | |
| 981 | + | require.Zero(t, old.roleGeneration.Load()) | |
| 982 | + | require.Zero(t, next.roleGeneration.Load()) | |
| 983 | + | }) | |
| 984 | + | } | |
| 985 | + | } | |
| 986 | + | ||
| 987 | + | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | |
| 988 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 989 | + | from := &session{id: "z-source"} | |
| 990 | + | target := &session{id: "a-target"} | |
| 991 | + | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 992 | + | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | |
| 993 | + | moving.initOverlays() | |
| 994 | + | displaced.initOverlays() | |
| 995 | + | moving.setSession(from) | |
| 996 | + | displaced.setSession(target) | |
| 997 | + | from.client = moving | |
| 998 | + | target.client = displaced | |
| 999 | + | d.sessions[from.id] = from | |
| 1000 | + | d.sessions[target.id] = target | |
| 1001 | + | sourceCoordinator := d.attachCoordinator(from, nil, moving, true) | |
| 1002 | + | targetCoordinator := d.attachCoordinator(target, nil, displaced, true) | |
| 1003 | + | ||
| 1004 | + | moving.sendMu.Lock() | |
| 1005 | + | locked := true | |
| 1006 | + | defer func() { | |
| 1007 | + | if locked { | |
| 1008 | + | moving.sendMu.Unlock() | |
| 1009 | + | } | |
| 1010 | + | }() | |
| 1011 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 1012 | + | transitionErr := make(chan error, 1) | |
| 1013 | + | go func() { | |
| 1014 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1015 | + | source: from, target: target, next: moving, | |
| 1016 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1017 | + | expectedTransport: moving.transportSnapshot(), ready: true, | |
| 1018 | + | }) | |
| 1019 | + | transitionDone <- result | |
| 1020 | + | transitionErr <- err | |
| 1021 | + | }() | |
| 1022 | + | ||
| 1023 | + | var result attachmentTransitionResult | |
| 1024 | + | select { | |
| 1025 | + | case result = <-transitionDone: | |
| 1026 | + | require.NoError(t, <-transitionErr) | |
| 1027 | + | case <-time.After(2 * time.Second): | |
| 1028 | + | t.Fatal("ownership publication waited for blocked sendMu") | |
| 1029 | + | } | |
| 1030 | + | ||
| 1031 | + | from.mu.Lock() | |
| 1032 | + | require.Nil(t, from.client) | |
| 1033 | + | from.mu.Unlock() | |
| 1034 | + | target.mu.Lock() | |
| 1035 | + | require.Same(t, moving, target.client) | |
| 1036 | + | require.Contains(t, target.snatched, displaced) | |
| 1037 | + | target.mu.Unlock() | |
| 1038 | + | require.Same(t, target, moving.currentSession()) | |
| 1039 | + | sourceCoordinator.mu.Lock() | |
| 1040 | + | require.NotNil(t, sourceCoordinator.lease) | |
| 1041 | + | require.False(t, sourceCoordinator.lease.active) | |
| 1042 | + | sourceCoordinator.mu.Unlock() | |
| 1043 | + | targetCoordinator.mu.Lock() | |
| 1044 | + | require.Same(t, result.published.lease, targetCoordinator.lease) | |
| 1045 | + | require.True(t, targetCoordinator.lease.active) | |
| 1046 | + | require.Same(t, moving, targetCoordinator.lease.attachment) | |
| 1047 | + | targetCoordinator.mu.Unlock() | |
| 1048 | + | require.True(t, result.published.activeCurrent()) | |
| 1049 | + | ||
| 1050 | + | beforeSendWait := make(chan struct{}) | |
| 1051 | + | d.beforeFirstPaintSendWait = func(token attachmentRoleToken) { | |
| 1052 | + | if token.ac == moving { | |
| 1053 | + | close(beforeSendWait) | |
| 1054 | + | } | |
| 1055 | + | } | |
| 1056 | + | paintDone := make(chan bool, 1) | |
| 1057 | + | go func() { | |
| 1058 | + | paintDone <- d.firstPaintForTransition(result.published) | |
| 1059 | + | }() | |
| 1060 | + | <-beforeSendWait | |
| 1061 | + | require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu") | |
| 1062 | + | d.mu.Unlock() | |
| 1063 | + | require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu") | |
| 1064 | + | d.notices.routingMu.Unlock() | |
| 1065 | + | require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu") | |
| 1066 | + | from.mu.Unlock() | |
| 1067 | + | require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu") | |
| 1068 | + | target.mu.Unlock() | |
| 1069 | + | require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu") | |
| 1070 | + | sourceCoordinator.mu.Unlock() | |
| 1071 | + | require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu") | |
| 1072 | + | targetCoordinator.mu.Unlock() | |
| 1073 | + | ||
| 1074 | + | moving.sendMu.Unlock() | |
| 1075 | + | locked = false | |
| 1076 | + | require.True(t, <-paintDone) | |
| 1077 | + | } | |
| 1078 | + | ||
| 1079 | + | func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) { | |
| 1080 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1081 | + | tr := &closeTrackingTransport{} | |
| 1082 | + | ac := &attachedClient{ | |
| 1083 | + | tr: tr, | |
| 1084 | + | output: newOutputStateStream(), | |
| 1085 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 1086 | + | } | |
| 1087 | + | ac.initOverlays() | |
| 1088 | + | ac.roleGeneration.Store(2) | |
| 1089 | + | ac.output.next = 4 | |
| 1090 | + | ac.output.acked = 1 | |
| 1091 | + | sess := &session{id: "work", client: ac} | |
| 1092 | + | ac.setSession(sess) | |
| 1093 | + | d.sessions[sess.id] = sess | |
| 1094 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 1095 | + | rebased := false | |
| 1096 | + | ac.renderStages.handoffRebase = func() { rebased = true } | |
| 1097 | + | ||
| 1098 | + | stale := attachmentRoleToken{ | |
| 1099 | + | sess: sess, ac: ac, role: attachmentActive, | |
| 1100 | + | generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true, | |
| 1101 | + | } | |
| 1102 | + | require.False(t, d.firstPaintForTransition(stale)) | |
| 1103 | + | ||
| 1104 | + | require.False(t, rebased) | |
| 1105 | + | require.Equal(t, uint64(4), ac.output.next) | |
| 1106 | + | require.Equal(t, uint64(1), ac.output.acked) | |
| 1107 | + | require.Empty(t, tr.Sends(), "stale first paint emitted a reset") | |
| 1108 | + | } | |
| 1109 | + | ||
| 1110 | + | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | |
| 1111 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1112 | + | from := &session{id: domain.SessionID("z-source")} | |
| 1113 | + | target := &session{id: domain.SessionID("a-target")} | |
| 1114 | + | moving := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1115 | + | displaced := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1116 | + | moving.setSession(from) | |
| 1117 | + | displaced.setSession(target) | |
| 1118 | + | from.client = moving | |
| 1119 | + | target.client = displaced | |
| 1120 | + | d.sessions[from.id] = from | |
| 1121 | + | d.sessions[target.id] = target | |
| 1122 | + | ||
| 1123 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1124 | + | source: from, | |
| 1125 | + | target: target, | |
| 1126 | + | next: moving, | |
| 1127 | + | expectedRole: attachmentActive, | |
| 1128 | + | targetRole: attachmentActive, | |
| 1129 | + | expectedTransport: moving.transportSnapshot(), | |
| 1130 | + | ready: true, | |
| 1131 | + | }) | |
| 1132 | + | require.NoError(t, err) | |
| 1133 | + | ||
| 1134 | + | from.mu.Lock() | |
| 1135 | + | require.Nil(t, from.client) | |
| 1136 | + | require.NotContains(t, from.snatched, moving) | |
| 1137 | + | from.mu.Unlock() | |
| 1138 | + | target.mu.Lock() | |
| 1139 | + | require.Same(t, moving, target.client) | |
| 1140 | + | require.Contains(t, target.snatched, displaced) | |
| 1141 | + | target.mu.Unlock() | |
| 1142 | + | require.True(t, result.published.current()) | |
| 1143 | + | require.True(t, result.displaced.current()) | |
| 1144 | + | require.Same(t, target, moving.currentSession()) | |
| 1145 | + | } | |
A internal/usecase/daemon/snatched_input.go +261 -0
| @@ -0,0 +1,261 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const snatchedInputSequenceLimit = 64 | |
| 11 | + | ||
| 12 | + | type snatchedInputAction uint8 | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | snatchedInputNone snatchedInputAction = iota | |
| 16 | + | snatchedInputReclaim | |
| 17 | + | snatchedInputQuit | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // clearSnatchedInput invalidates every parser continuation before stopping its | |
| 21 | + | // timer. Callers must not hold daemon, routing, session, coordinator, send, or | |
| 22 | + | // transport locks because Timer.Stop is an external call. | |
| 23 | + | func (ac *attachedClient) clearSnatchedInput() { | |
| 24 | + | if ac == nil { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | ac.snatchedInputMu.Lock() | |
| 28 | + | ac.snatchedInputPending = nil | |
| 29 | + | ac.snatchedInputDrain = false | |
| 30 | + | pendingESC := ac.snatchedInputESC | |
| 31 | + | ac.snatchedInputESC = pendingByteTimer{} | |
| 32 | + | ac.snatchedInputMu.Unlock() | |
| 33 | + | pendingESC.stop() | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // handleSnatchedInput accepts actions only as exact one-byte input events. | |
| 37 | + | // Terminal escape sequences and arbitrary coalesced input are consumed as | |
| 38 | + | // indivisible events so a byte in a paste, sequence final, or frame tail can | |
| 39 | + | // never become an action. | |
| 40 | + | func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool { | |
| 41 | + | action := d.parseSnatchedInput(token, input.Data) | |
| 42 | + | switch action { | |
| 43 | + | case snatchedInputReclaim: | |
| 44 | + | d.reclaimSnatchedAttachment(token) | |
| 45 | + | case snatchedInputQuit: | |
| 46 | + | return d.quitSnatchedAttachment(token) | |
| 47 | + | } | |
| 48 | + | return false | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction { | |
| 52 | + | ac := token.ac | |
| 53 | + | if ac == nil || len(data) == 0 { | |
| 54 | + | return snatchedInputNone | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | ac.snatchedInputMu.Lock() | |
| 58 | + | ||
| 59 | + | if ac.snatchedInputDrain { | |
| 60 | + | if snatchedSequenceFinal(data) >= 0 { | |
| 61 | + | ac.snatchedInputDrain = false | |
| 62 | + | } | |
| 63 | + | ac.snatchedInputMu.Unlock() | |
| 64 | + | return snatchedInputNone | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | if len(ac.snatchedInputPending) != 0 { | |
| 68 | + | pendingESC := ac.snatchedInputESC | |
| 69 | + | ac.snatchedInputESC = pendingByteTimer{} | |
| 70 | + | combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data)) | |
| 71 | + | combined = append(combined, ac.snatchedInputPending...) | |
| 72 | + | combined = append(combined, data...) | |
| 73 | + | ac.snatchedInputPending = nil | |
| 74 | + | d.retainSnatchedSequenceLocked(ac, combined) | |
| 75 | + | ac.snatchedInputMu.Unlock() | |
| 76 | + | pendingESC.stop() | |
| 77 | + | return snatchedInputNone | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | if len(data) == 1 { | |
| 81 | + | var action snatchedInputAction | |
| 82 | + | switch data[0] { | |
| 83 | + | case 'r', 'R': | |
| 84 | + | action = snatchedInputReclaim | |
| 85 | + | case 'q', 'Q': | |
| 86 | + | action = snatchedInputQuit | |
| 87 | + | case keys.ESC: | |
| 88 | + | ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC) | |
| 89 | + | ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 90 | + | d.fireSnatchedEscape(token, timer) | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ac.snatchedInputMu.Unlock() | |
| 94 | + | return action | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // A complete/coalesced event is always ignored. Only an incomplete CSI or | |
| 98 | + | // SS3 prefix is retained to prevent its future final byte from acting. | |
| 99 | + | d.retainSnatchedSequenceLocked(ac, data) | |
| 100 | + | ac.snatchedInputMu.Unlock() | |
| 101 | + | return snatchedInputNone | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) { | |
| 105 | + | if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | if len(data) > snatchedInputSequenceLimit { | |
| 109 | + | ac.snatchedInputDrain = true | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func snatchedSequencePrefix(data []byte) bool { | |
| 116 | + | return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O') | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func snatchedSequenceFinal(data []byte) int { | |
| 120 | + | for i, b := range data { | |
| 121 | + | if b >= 0x40 && b <= 0x7e { | |
| 122 | + | return i | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return -1 | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) { | |
| 129 | + | ac := token.ac | |
| 130 | + | if ac == nil { | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | ac.snatchedInputMu.Lock() | |
| 134 | + | if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC { | |
| 135 | + | ac.snatchedInputMu.Unlock() | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | ac.snatchedInputPending = nil | |
| 139 | + | ac.snatchedInputESC.timer = nil | |
| 140 | + | ac.snatchedInputESC.done = nil | |
| 141 | + | ac.snatchedInputMu.Unlock() | |
| 142 | + | ||
| 143 | + | if d.afterSnatchedEscapeAccepted != nil { | |
| 144 | + | d.afterSnatchedEscapeAccepted() | |
| 145 | + | } | |
| 146 | + | if !token.current() { | |
| 147 | + | if d.afterSnatchedEscapeAttempt != nil { | |
| 148 | + | d.afterSnatchedEscapeAttempt(false) | |
| 149 | + | } | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | ticket, admitted := ac.beginRoleEffect(token) | |
| 153 | + | if d.afterSnatchedEscapeAttempt != nil { | |
| 154 | + | d.afterSnatchedEscapeAttempt(admitted) | |
| 155 | + | } | |
| 156 | + | if !admitted { | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | token.effect = ticket | |
| 160 | + | defer token.endRoleEffect() | |
| 161 | + | d.quitSnatchedAttachment(token) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // acquireActivationBarrier waits for the requester's serialized output path | |
| 165 | + | // without retaining role, daemon, routing, session, coordinator, or pane locks. | |
| 166 | + | // The caller has already frozen and drained the requester's role gate, so no new | |
| 167 | + | // role-bound sender can race this acquisition. A deadline closes only the exact | |
| 168 | + | // captured link, which releases a transport Send currently holding sendMu. | |
| 169 | + | func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) { | |
| 170 | + | if token.ac == nil || token.transport.transport == nil || !token.current() { | |
| 171 | + | return nil, errAttachmentTransition | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | acquired := make(chan struct{}) | |
| 175 | + | cancel := make(chan struct{}) | |
| 176 | + | go func() { | |
| 177 | + | token.ac.sendMu.Lock() | |
| 178 | + | select { | |
| 179 | + | case acquired <- struct{}{}: | |
| 180 | + | // The receiver now owns the matching Unlock obligation. | |
| 181 | + | case <-cancel: | |
| 182 | + | token.ac.sendMu.Unlock() | |
| 183 | + | } | |
| 184 | + | }() | |
| 185 | + | ||
| 186 | + | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 187 | + | select { | |
| 188 | + | case <-acquired: | |
| 189 | + | timer.Stop() | |
| 190 | + | if token.ac.roleGeneration.Load() != token.generation || | |
| 191 | + | !token.ac.transportSnapshotCurrent(token.transport) { | |
| 192 | + | token.ac.sendMu.Unlock() | |
| 193 | + | return nil, errAttachmentTransition | |
| 194 | + | } | |
| 195 | + | return token.ac.sendMu.Unlock, nil | |
| 196 | + | case <-timer.C(): | |
| 197 | + | close(cancel) | |
| 198 | + | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 199 | + | return nil, errSendTimedOut | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool { | |
| 204 | + | if token.role != attachmentSnatched || token.effect == nil { | |
| 205 | + | return false | |
| 206 | + | } | |
| 207 | + | token.effect.bindActionEnd(d, "reclaim-snatched") | |
| 208 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 209 | + | source: token.sess, target: token.sess, next: token.ac, | |
| 210 | + | expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 211 | + | expectedTransport: token.transport, sourceToken: &token, | |
| 212 | + | action: "reclaim-snatched", ready: true, activationBarrier: true, | |
| 213 | + | }) | |
| 214 | + | if err != nil { | |
| 215 | + | if errors.Is(err, errSendTimedOut) { | |
| 216 | + | d.attachmentCleanupWg.Go(func() { d.parkOrDropSnatchedAttachment(token) }) | |
| 217 | + | } else { | |
| 218 | + | d.showSnatchedUnavailable(token) | |
| 219 | + | } | |
| 220 | + | return false | |
| 221 | + | } | |
| 222 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 223 | + | if d.beforeReclaimFirstPaint != nil { | |
| 224 | + | d.beforeReclaimFirstPaint(transition.published) | |
| 225 | + | } | |
| 226 | + | d.firstPaintForTransition(transition.published) | |
| 227 | + | return true | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role | |
| 231 | + | // admission and the generation-aware sender make it inert after a concurrent | |
| 232 | + | // reclaim, quit, transport replacement, or session teardown. | |
| 233 | + | func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) { | |
| 234 | + | if !token.current() { | |
| 235 | + | return | |
| 236 | + | } | |
| 237 | + | fresh := token | |
| 238 | + | fresh.effect = nil | |
| 239 | + | ticket, admitted := token.ac.beginRoleEffect(fresh) | |
| 240 | + | if !admitted { | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | fresh.effect = ticket | |
| 244 | + | defer fresh.endRoleEffect() | |
| 245 | + | if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil { | |
| 246 | + | fresh.endRoleEffect() | |
| 247 | + | d.parkOrDropSnatchedAttachment(token) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool { | |
| 252 | + | if token.role != attachmentSnatched || token.effect == nil { | |
| 253 | + | return false | |
| 254 | + | } | |
| 255 | + | token.effect.bindActionEnd(d, "quit-snatched") | |
| 256 | + | _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 257 | + | // Terminal cleanup freezes the same role gate, so release this action's | |
| 258 | + | // admission before attempting the generation-bound removal. | |
| 259 | + | token.endRoleEffect() | |
| 260 | + | return d.dropSnatchedAttachment(token) | |
| 261 | + | } | |
A internal/usecase/daemon/snatched_input_test.go +365 -0
| @@ -0,0 +1,365 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type snatchedInputTimer struct { | |
| 19 | + | ch chan time.Time | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func newSnatchedInputTimer() *snatchedInputTimer { | |
| 23 | + | return &snatchedInputTimer{ch: make(chan time.Time, 1)} | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch } | |
| 27 | + | func (*snatchedInputTimer) Reset(time.Duration) bool { return false } | |
| 28 | + | func (*snatchedInputTimer) Stop() bool { return true } | |
| 29 | + | func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} } | |
| 30 | + | ||
| 31 | + | type snatchedInputClock struct { | |
| 32 | + | timers chan *snatchedInputTimer | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func newSnatchedInputClock() *snatchedInputClock { | |
| 36 | + | return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (*snatchedInputClock) Now() time.Time { return time.Time{} } | |
| 40 | + | func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer { | |
| 41 | + | if delay != keys.ESCDelay { | |
| 42 | + | return stubTimer{} | |
| 43 | + | } | |
| 44 | + | timer := newSnatchedInputTimer() | |
| 45 | + | c.timers <- timer | |
| 46 | + | return timer | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type snatchedCloseSignalTransport struct { | |
| 50 | + | mu sync.Mutex | |
| 51 | + | closed bool | |
| 52 | + | closedCh chan struct{} | |
| 53 | + | closeOnce sync.Once | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport { | |
| 57 | + | return &snatchedCloseSignalTransport{closedCh: make(chan struct{})} | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil } | |
| 61 | + | func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) { | |
| 62 | + | return ports.Frame{}, errors.New("closed") | |
| 63 | + | } | |
| 64 | + | func (t *snatchedCloseSignalTransport) Close() error { | |
| 65 | + | t.mu.Lock() | |
| 66 | + | t.closed = true | |
| 67 | + | t.mu.Unlock() | |
| 68 | + | t.closeOnce.Do(func() { close(t.closedCh) }) | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | func (t *snatchedCloseSignalTransport) Closed() bool { | |
| 72 | + | t.mu.Lock() | |
| 73 | + | defer t.mu.Unlock() | |
| 74 | + | return t.closed | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | type blockedSnatchedQuitTransport struct { | |
| 78 | + | recv chan ports.Frame | |
| 79 | + | sendStarted chan struct{} | |
| 80 | + | sendDone chan struct{} | |
| 81 | + | closed chan struct{} | |
| 82 | + | closeOnce sync.Once | |
| 83 | + | sendOnce sync.Once | |
| 84 | + | mu sync.Mutex | |
| 85 | + | isClosed bool | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport { | |
| 89 | + | return &blockedSnatchedQuitTransport{ | |
| 90 | + | recv: make(chan ports.Frame, 1), | |
| 91 | + | sendStarted: make(chan struct{}), | |
| 92 | + | sendDone: make(chan struct{}), | |
| 93 | + | closed: make(chan struct{}), | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error { | |
| 98 | + | if frame.Type != ports.MsgDetached { | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | t.sendOnce.Do(func() { close(t.sendStarted) }) | |
| 102 | + | <-t.closed | |
| 103 | + | close(t.sendDone) | |
| 104 | + | return io.ErrClosedPipe | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) { | |
| 108 | + | select { | |
| 109 | + | case frame := <-t.recv: | |
| 110 | + | return frame, nil | |
| 111 | + | case <-t.closed: | |
| 112 | + | return ports.Frame{}, io.ErrClosedPipe | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (t *blockedSnatchedQuitTransport) Close() error { | |
| 117 | + | t.mu.Lock() | |
| 118 | + | t.isClosed = true | |
| 119 | + | t.mu.Unlock() | |
| 120 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (t *blockedSnatchedQuitTransport) Closed() bool { | |
| 125 | + | t.mu.Lock() | |
| 126 | + | defer t.mu.Unlock() | |
| 127 | + | return t.isClosed | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) { | |
| 131 | + | t.Helper() | |
| 132 | + | d := newTestDaemon(t, nil, clock) | |
| 133 | + | waitingTransport := newSnatchedCloseSignalTransport() | |
| 134 | + | waiting := &attachedClient{ | |
| 135 | + | tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, | |
| 136 | + | } | |
| 137 | + | active := &attachedClient{ | |
| 138 | + | tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size, | |
| 139 | + | } | |
| 140 | + | waiting.initOverlays() | |
| 141 | + | active.initOverlays() | |
| 142 | + | sess := &session{ | |
| 143 | + | id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}, | |
| 144 | + | } | |
| 145 | + | waiting.setSession(sess) | |
| 146 | + | active.setSession(sess) | |
| 147 | + | d.sessions[sess.id] = sess | |
| 148 | + | d.attachCoordinator(sess, nil, active, true) | |
| 149 | + | return d, sess, waiting, active, waitingTransport | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool { | |
| 153 | + | tr := ac.transport() | |
| 154 | + | token := sess.attachmentToken(ac, tr) | |
| 155 | + | return d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 156 | + | Type: ports.MsgInput, | |
| 157 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}), | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestSnatchedInputStrictActions(t *testing.T) { | |
| 162 | + | tests := []struct { | |
| 163 | + | name string | |
| 164 | + | chunks []string | |
| 165 | + | fireEscape bool | |
| 166 | + | wantRole attachmentRole | |
| 167 | + | wantClosed bool | |
| 168 | + | }{ | |
| 169 | + | {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive}, | |
| 170 | + | {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive}, | |
| 171 | + | {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true}, | |
| 172 | + | {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true}, | |
| 173 | + | {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true}, | |
| 174 | + | {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched}, | |
| 175 | + | {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched}, | |
| 176 | + | {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched}, | |
| 177 | + | {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched}, | |
| 178 | + | {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched}, | |
| 179 | + | {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched}, | |
| 180 | + | {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched}, | |
| 181 | + | {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched}, | |
| 182 | + | {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched}, | |
| 183 | + | {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched}, | |
| 184 | + | {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched}, | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | for _, tt := range tests { | |
| 188 | + | t.Run(tt.name, func(t *testing.T) { | |
| 189 | + | clock := newSnatchedInputClock() | |
| 190 | + | d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock) | |
| 191 | + | for _, chunk := range tt.chunks { | |
| 192 | + | sendSnatchedInput(d, sess, waiting, chunk) | |
| 193 | + | } | |
| 194 | + | if tt.fireEscape { | |
| 195 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay") | |
| 196 | + | require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay") | |
| 197 | + | timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer") | |
| 198 | + | timer.Fire() | |
| 199 | + | awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit") | |
| 200 | + | } | |
| 201 | + | d.attachmentCleanupWg.Wait() | |
| 202 | + | ||
| 203 | + | require.Equal(t, tt.wantRole, sess.attachmentRole(waiting)) | |
| 204 | + | require.Equal(t, tt.wantClosed, waitingTransport.Closed()) | |
| 205 | + | if tt.wantRole == attachmentActive { | |
| 206 | + | require.Same(t, waiting, sess.client) | |
| 207 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 208 | + | } else { | |
| 209 | + | require.Same(t, active, sess.client) | |
| 210 | + | } | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) { | |
| 216 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 217 | + | waitingTransport := newBlockedSnatchedQuitTransport() | |
| 218 | + | defer func() { _ = waitingTransport.Close() }() | |
| 219 | + | activeTransport := &closeTrackingTransport{} | |
| 220 | + | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 221 | + | ||
| 222 | + | otherTransport := &closeTrackingTransport{} | |
| 223 | + | other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size} | |
| 224 | + | other.initOverlays() | |
| 225 | + | other.setSession(sess) | |
| 226 | + | sess.mu.Lock() | |
| 227 | + | sess.addSnatchedLocked(other) | |
| 228 | + | sess.mu.Unlock() | |
| 229 | + | otherToken := sess.attachmentToken(other, otherTransport) | |
| 230 | + | require.Equal(t, attachmentSnatched, otherToken.role) | |
| 231 | + | ||
| 232 | + | ownerLease := sess.renderCoordinator().attachmentLease(active) | |
| 233 | + | require.NotNil(t, ownerLease) | |
| 234 | + | activeGeneration := active.roleGeneration.Load() | |
| 235 | + | otherGeneration := other.roleGeneration.Load() | |
| 236 | + | ||
| 237 | + | loopDone := make(chan struct{}) | |
| 238 | + | go func() { | |
| 239 | + | d.runConnLoop(waiting) | |
| 240 | + | close(loopDone) | |
| 241 | + | }() | |
| 242 | + | waitingTransport.recv <- ports.Frame{ | |
| 243 | + | Type: ports.MsgInput, | |
| 244 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}), | |
| 245 | + | } | |
| 246 | + | awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement") | |
| 247 | + | select { | |
| 248 | + | case <-loopDone: | |
| 249 | + | t.Fatal("snatched quit returned before its acknowledgement deadline") | |
| 250 | + | default: | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline") | |
| 254 | + | require.Equal(t, detachNotifyTimeout, timer.duration) | |
| 255 | + | timer.ch <- time.Time{} | |
| 256 | + | awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send") | |
| 257 | + | awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline") | |
| 258 | + | ||
| 259 | + | require.True(t, waitingTransport.Closed()) | |
| 260 | + | requireRoleGateRetired(t, waiting) | |
| 261 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 262 | + | require.Nil(t, waiting.currentSession()) | |
| 263 | + | require.Nil(t, waiting.transport()) | |
| 264 | + | require.Same(t, active, sess.client) | |
| 265 | + | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 266 | + | require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active)) | |
| 267 | + | require.Equal(t, activeGeneration, active.roleGeneration.Load()) | |
| 268 | + | require.False(t, activeTransport.Closed()) | |
| 269 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(other)) | |
| 270 | + | require.Equal(t, otherGeneration, other.roleGeneration.Load()) | |
| 271 | + | require.Same(t, otherTransport, other.transport()) | |
| 272 | + | require.False(t, otherTransport.Closed()) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) { | |
| 276 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 277 | + | staleTransport := newBlockedSnatchedQuitTransport() | |
| 278 | + | defer func() { _ = staleTransport.Close() }() | |
| 279 | + | ownerTransport := &closeTrackingTransport{} | |
| 280 | + | d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport) | |
| 281 | + | ||
| 282 | + | ticketEnded := make(chan struct{}) | |
| 283 | + | releaseQuit := make(chan struct{}) | |
| 284 | + | d.afterActionRoleEffectEnded = func(action string) { | |
| 285 | + | if action == "quit-snatched" { | |
| 286 | + | close(ticketEnded) | |
| 287 | + | <-releaseQuit | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | loopDone := make(chan struct{}) | |
| 291 | + | go func() { | |
| 292 | + | d.runConnLoop(waiting) | |
| 293 | + | close(loopDone) | |
| 294 | + | }() | |
| 295 | + | staleTransport.recv <- ports.Frame{ | |
| 296 | + | Type: ports.MsgInput, | |
| 297 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}), | |
| 298 | + | } | |
| 299 | + | awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport") | |
| 300 | + | timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline") | |
| 301 | + | timer.ch <- time.Time{} | |
| 302 | + | awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send") | |
| 303 | + | awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup") | |
| 304 | + | ||
| 305 | + | freshTransport := &closeTrackingTransport{} | |
| 306 | + | waiting.replaceTransport(freshTransport) | |
| 307 | + | reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 308 | + | target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 309 | + | expectedTransport: waiting.transportSnapshot(), ready: true, | |
| 310 | + | }) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | d.deferAttachmentTransitionCleanups(reclaimed) | |
| 313 | + | freshGeneration := reclaimed.published.generation | |
| 314 | + | freshLease := reclaimed.published.lease | |
| 315 | + | ||
| 316 | + | close(releaseQuit) | |
| 317 | + | awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim") | |
| 318 | + | d.attachmentCleanupWg.Wait() | |
| 319 | + | ||
| 320 | + | require.True(t, staleTransport.Closed()) | |
| 321 | + | requireRoleGateRetired(t, waiting) | |
| 322 | + | require.Same(t, waiting, sess.client) | |
| 323 | + | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 324 | + | require.Equal(t, freshGeneration, waiting.roleGeneration.Load()) | |
| 325 | + | require.Same(t, freshTransport, waiting.transport()) | |
| 326 | + | require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport") | |
| 327 | + | require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting)) | |
| 328 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(owner)) | |
| 329 | + | require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner") | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) { | |
| 333 | + | clock := newSnatchedInputClock() | |
| 334 | + | d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock) | |
| 335 | + | ||
| 336 | + | sendSnatchedInput(d, sess, waiting, "\x1b") | |
| 337 | + | timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer") | |
| 338 | + | accepted := make(chan struct{}) | |
| 339 | + | release := make(chan struct{}) | |
| 340 | + | attempted := make(chan bool, 1) | |
| 341 | + | d.afterSnatchedEscapeAccepted = func() { | |
| 342 | + | close(accepted) | |
| 343 | + | <-release | |
| 344 | + | } | |
| 345 | + | d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted } | |
| 346 | + | timer.Fire() | |
| 347 | + | awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state") | |
| 348 | + | ||
| 349 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 350 | + | target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 351 | + | expectedTransport: waiting.transportSnapshot(), ready: true, | |
| 352 | + | }) | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | d.deferAttachmentTransitionCleanups(result) | |
| 355 | + | newTransport := newSnatchedCloseSignalTransport() | |
| 356 | + | waiting.replaceTransport(newTransport) | |
| 357 | + | close(release) | |
| 358 | + | require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role")) | |
| 359 | + | d.attachmentCleanupWg.Wait() | |
| 360 | + | ||
| 361 | + | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 362 | + | require.Same(t, newTransport, waiting.transport()) | |
| 363 | + | require.False(t, newTransport.Closed(), "stale timer closed the replacement transport") | |
| 364 | + | require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation") | |
| 365 | + | } | |
A internal/usecase/daemon/snatched_reclaim_test.go +445 -0
| @@ -0,0 +1,445 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type failingReclaimTransport struct { | |
| 18 | + | mu sync.Mutex | |
| 19 | + | closed bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 23 | + | func (*failingReclaimTransport) Recv() (ports.Frame, error) { | |
| 24 | + | return ports.Frame{}, errors.New("closed") | |
| 25 | + | } | |
| 26 | + | func (t *failingReclaimTransport) Close() error { | |
| 27 | + | t.mu.Lock() | |
| 28 | + | t.closed = true | |
| 29 | + | t.mu.Unlock() | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | func (t *failingReclaimTransport) Closed() bool { | |
| 33 | + | t.mu.Lock() | |
| 34 | + | defer t.mu.Unlock() | |
| 35 | + | return t.closed | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) { | |
| 39 | + | t.Helper() | |
| 40 | + | d := newTestDaemon(t, nil, clock) | |
| 41 | + | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 42 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 43 | + | waiting.initOverlays() | |
| 44 | + | active.initOverlays() | |
| 45 | + | sess := &session{ | |
| 46 | + | id: "atomic-reclaim", client: active, | |
| 47 | + | snatched: map[*attachedClient]struct{}{waiting: {}}, | |
| 48 | + | } | |
| 49 | + | waiting.setSession(sess) | |
| 50 | + | active.setSession(sess) | |
| 51 | + | d.sessions[sess.id] = sess | |
| 52 | + | d.attachCoordinator(sess, nil, active, true) | |
| 53 | + | return d, sess, waiting, active | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) { | |
| 57 | + | t.Helper() | |
| 58 | + | sess.mu.Lock() | |
| 59 | + | owner := sess.client | |
| 60 | + | snatched := make(map[*attachedClient]struct{}, len(sess.snatched)) | |
| 61 | + | for ac := range sess.snatched { | |
| 62 | + | snatched[ac] = struct{}{} | |
| 63 | + | } | |
| 64 | + | sess.mu.Unlock() | |
| 65 | + | require.NotNil(t, owner) | |
| 66 | + | ||
| 67 | + | activeCount := 0 | |
| 68 | + | for _, ac := range clients { | |
| 69 | + | if ac == owner { | |
| 70 | + | activeCount++ | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | _, waiting := snatched[ac] | |
| 74 | + | require.True(t, waiting, "every non-owner must remain snatched") | |
| 75 | + | } | |
| 76 | + | require.Equal(t, 1, activeCount) | |
| 77 | + | ||
| 78 | + | rc := sess.renderCoordinator() | |
| 79 | + | rc.mu.Lock() | |
| 80 | + | lease := rc.lease | |
| 81 | + | rc.mu.Unlock() | |
| 82 | + | require.NotNil(t, lease) | |
| 83 | + | require.True(t, lease.active) | |
| 84 | + | require.Same(t, owner, lease.attachment) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) { | |
| 88 | + | d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 89 | + | requesterTransport := &closeTrackingTransport{} | |
| 90 | + | requester.replaceTransport(requesterTransport) | |
| 91 | + | d.attachCoordinator(sess, nil, requester, true) | |
| 92 | + | ||
| 93 | + | active := &attachedClient{ | |
| 94 | + | tr: &closeTrackingTransport{}, | |
| 95 | + | output: newOutputStateStream(), | |
| 96 | + | size: requester.size, | |
| 97 | + | } | |
| 98 | + | active.initOverlays() | |
| 99 | + | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 100 | + | target: sess, next: active, | |
| 101 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 102 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 103 | + | }) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | d.deferAttachmentTransitionCleanups(displaced) | |
| 106 | + | d.attachmentCleanupWg.Wait() | |
| 107 | + | ||
| 108 | + | requester.sendMu.Lock() | |
| 109 | + | beforeReclaimState := requester.output.next | |
| 110 | + | requester.sendMu.Unlock() | |
| 111 | + | requesterTheme := themeui.Theme{ | |
| 112 | + | Foreground: renderer.RGB{R: 11, G: 22, B: 33}, | |
| 113 | + | Background: renderer.RGB{R: 44, G: 55, B: 66}, | |
| 114 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 115 | + | } | |
| 116 | + | requester.setClientTheme(requesterTheme) | |
| 117 | + | ||
| 118 | + | token := sess.attachmentToken(requester, requesterTransport) | |
| 119 | + | require.Equal(t, attachmentSnatched, token.role) | |
| 120 | + | require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 121 | + | Type: ports.MsgInput, | |
| 122 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 123 | + | })) | |
| 124 | + | ||
| 125 | + | sess.mu.Lock() | |
| 126 | + | require.Same(t, requester, sess.client) | |
| 127 | + | _, activeIsSnatched := sess.snatched[active] | |
| 128 | + | _, requesterIsSnatched := sess.snatched[requester] | |
| 129 | + | sess.mu.Unlock() | |
| 130 | + | require.True(t, activeIsSnatched) | |
| 131 | + | require.False(t, requesterIsSnatched) | |
| 132 | + | rc := sess.renderCoordinator() | |
| 133 | + | lease := rc.attachmentLease(requester) | |
| 134 | + | require.NotNil(t, lease) | |
| 135 | + | require.Nil(t, rc.attachmentLease(active)) | |
| 136 | + | require.True(t, lease.active) | |
| 137 | + | ||
| 138 | + | requester.sendMu.Lock() | |
| 139 | + | require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint") | |
| 140 | + | require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain") | |
| 141 | + | requester.sendMu.Unlock() | |
| 142 | + | assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background) | |
| 143 | + | ||
| 144 | + | d.attachmentCleanupWg.Wait() | |
| 145 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 146 | + | activeTransport, ok := active.transport().(*closeTrackingTransport) | |
| 147 | + | require.True(t, ok, "active transport has unexpected type") | |
| 148 | + | require.False(t, activeTransport.Closed()) | |
| 149 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) { | |
| 153 | + | waitingTransport := &closeTrackingTransport{} | |
| 154 | + | activeTransport := &closeTrackingTransport{} | |
| 155 | + | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 156 | + | token := sess.attachmentToken(waiting, waitingTransport) | |
| 157 | + | ||
| 158 | + | d.mu.Lock() | |
| 159 | + | delete(d.sessions, sess.id) | |
| 160 | + | d.mu.Unlock() | |
| 161 | + | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 162 | + | Type: ports.MsgInput, | |
| 163 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 164 | + | }) | |
| 165 | + | ||
| 166 | + | requireSingleOwnerAndLease(t, sess, waiting, active) | |
| 167 | + | require.Same(t, active, sess.client) | |
| 168 | + | require.False(t, waitingTransport.Closed()) | |
| 169 | + | frames := waitingTransport.Sends() | |
| 170 | + | require.Len(t, frames, 1) | |
| 171 | + | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | require.Contains(t, string(output.Data), "Session is no longer available.") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) { | |
| 177 | + | waitingTransport := &closeTrackingTransport{} | |
| 178 | + | activeTransport := &failingReclaimTransport{} | |
| 179 | + | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 180 | + | token := sess.attachmentToken(waiting, waitingTransport) | |
| 181 | + | ||
| 182 | + | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 183 | + | Type: ports.MsgInput, | |
| 184 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 185 | + | }) | |
| 186 | + | d.attachmentCleanupWg.Wait() | |
| 187 | + | ||
| 188 | + | require.Same(t, waiting, sess.client) | |
| 189 | + | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 190 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(active)) | |
| 191 | + | require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting)) | |
| 192 | + | require.Nil(t, sess.renderCoordinator().attachmentLease(active)) | |
| 193 | + | require.False(t, waitingTransport.Closed()) | |
| 194 | + | require.True(t, activeTransport.Closed()) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) { | |
| 198 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 199 | + | waitingTransport := newSnatchedCloseSignalTransport() | |
| 200 | + | activeTransport := &closeTrackingTransport{} | |
| 201 | + | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 202 | + | ||
| 203 | + | waiting.sendMu.Lock() | |
| 204 | + | locked := true | |
| 205 | + | defer func() { | |
| 206 | + | if locked { | |
| 207 | + | waiting.sendMu.Unlock() | |
| 208 | + | } | |
| 209 | + | }() | |
| 210 | + | done := make(chan struct{}) | |
| 211 | + | go func() { | |
| 212 | + | token := sess.attachmentToken(waiting, waitingTransport) | |
| 213 | + | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 214 | + | Type: ports.MsgInput, | |
| 215 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 216 | + | }) | |
| 217 | + | close(done) | |
| 218 | + | }() | |
| 219 | + | ||
| 220 | + | timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline") | |
| 221 | + | require.Equal(t, detachNotifyTimeout, timer.duration) | |
| 222 | + | timer.ch <- time.Time{} | |
| 223 | + | awaitTestCompletion(t, done, "deadline did not release blocked reclaim") | |
| 224 | + | require.Same(t, active, sess.client) | |
| 225 | + | require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment) | |
| 226 | + | require.True(t, waitingTransport.Closed()) | |
| 227 | + | ||
| 228 | + | waiting.sendMu.Unlock() | |
| 229 | + | locked = false | |
| 230 | + | d.attachmentCleanupWg.Wait() | |
| 231 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 232 | + | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) { | |
| 236 | + | waitingTransport := &closeTrackingTransport{} | |
| 237 | + | activeTransport := &closeTrackingTransport{} | |
| 238 | + | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 239 | + | ||
| 240 | + | finalizerStarted := make(chan struct{}) | |
| 241 | + | releaseFinalizer := make(chan struct{}) | |
| 242 | + | d.afterDisplacedCleanupStarted = func() { | |
| 243 | + | close(finalizerStarted) | |
| 244 | + | <-releaseFinalizer | |
| 245 | + | } | |
| 246 | + | waitingToken := sess.attachmentToken(waiting, waitingTransport) | |
| 247 | + | d.handleSnatchedClientFrame(waitingToken, ports.Frame{ | |
| 248 | + | Type: ports.MsgInput, | |
| 249 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 250 | + | }) | |
| 251 | + | awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause") | |
| 252 | + | ||
| 253 | + | activeToken := sess.attachmentToken(active, activeTransport) | |
| 254 | + | require.Equal(t, attachmentSnatched, activeToken.role) | |
| 255 | + | require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{ | |
| 256 | + | Type: ports.MsgInput, | |
| 257 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}), | |
| 258 | + | })) | |
| 259 | + | close(releaseFinalizer) | |
| 260 | + | d.attachmentCleanupWg.Wait() | |
| 261 | + | ||
| 262 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(active)) | |
| 263 | + | frames := activeTransport.Sends() | |
| 264 | + | require.Len(t, frames, 1, "stale finalizer emitted output after quit") | |
| 265 | + | require.Equal(t, ports.MsgDetached, frames[0].Type) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) { | |
| 269 | + | d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 270 | + | requesterTransport := &closeTrackingTransport{} | |
| 271 | + | requester.replaceTransport(requesterTransport) | |
| 272 | + | d.attachCoordinator(sess, nil, requester, true) | |
| 273 | + | ||
| 274 | + | activeTransport := &closeTrackingTransport{} | |
| 275 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size} | |
| 276 | + | active.initOverlays() | |
| 277 | + | initial, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 278 | + | target: sess, next: active, | |
| 279 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 280 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 281 | + | }) | |
| 282 | + | require.NoError(t, err) | |
| 283 | + | d.deferAttachmentTransitionCleanups(initial) | |
| 284 | + | d.attachmentCleanupWg.Wait() | |
| 285 | + | baselineActiveFrames := len(activeTransport.Sends()) | |
| 286 | + | ||
| 287 | + | paintAdmitted := make(chan struct{}) | |
| 288 | + | releasePaint := make(chan struct{}) | |
| 289 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 290 | + | if token.ac == active && token.role == attachmentActive { | |
| 291 | + | close(paintAdmitted) | |
| 292 | + | <-releasePaint | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | activeToken := sess.attachmentToken(active, activeTransport) | |
| 296 | + | paintDone := make(chan bool, 1) | |
| 297 | + | go func() { paintDone <- d.firstPaintForTransition(activeToken) }() | |
| 298 | + | awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted") | |
| 299 | + | ||
| 300 | + | reclaimFrozen := make(chan struct{}) | |
| 301 | + | var frozenOnce sync.Once | |
| 302 | + | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 303 | + | if action == "reclaim-snatched" { | |
| 304 | + | frozenOnce.Do(func() { close(reclaimFrozen) }) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | reclaimDone := make(chan struct{}) | |
| 308 | + | go func() { | |
| 309 | + | token := sess.attachmentToken(requester, requesterTransport) | |
| 310 | + | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 311 | + | Type: ports.MsgInput, | |
| 312 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 313 | + | }) | |
| 314 | + | close(reclaimDone) | |
| 315 | + | }() | |
| 316 | + | awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint") | |
| 317 | + | close(releasePaint) | |
| 318 | + | require.True(t, awaitTestValue(t, paintDone, "old paint did not finish")) | |
| 319 | + | awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint") | |
| 320 | + | d.attachmentCleanupWg.Wait() | |
| 321 | + | ||
| 322 | + | frames := activeTransport.Sends()[baselineActiveFrames:] | |
| 323 | + | require.NotEmpty(t, frames) | |
| 324 | + | last := frames[len(frames)-1] | |
| 325 | + | require.Equal(t, ports.MsgOutput, last.Type) | |
| 326 | + | output, err := ports.UnmarshalOutput(last.Payload) | |
| 327 | + | require.NoError(t, err) | |
| 328 | + | require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel") | |
| 329 | + | requireSingleOwnerAndLease(t, sess, requester, active) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) { | |
| 333 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 334 | + | firstTransport := &closeTrackingTransport{} | |
| 335 | + | first.replaceTransport(firstTransport) | |
| 336 | + | d.attachCoordinator(sess, nil, first, true) | |
| 337 | + | ||
| 338 | + | secondTransport := &closeTrackingTransport{} | |
| 339 | + | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 340 | + | second.initOverlays() | |
| 341 | + | initial, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 342 | + | target: sess, next: second, | |
| 343 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 344 | + | expectedTransport: second.transportSnapshot(), ready: true, | |
| 345 | + | }) | |
| 346 | + | require.NoError(t, err) | |
| 347 | + | d.deferAttachmentTransitionCleanups(initial) | |
| 348 | + | d.attachmentCleanupWg.Wait() | |
| 349 | + | baselineFirstFrames := len(firstTransport.Sends()) | |
| 350 | + | ||
| 351 | + | firstPaintPaused := make(chan struct{}) | |
| 352 | + | releaseFirstPaint := make(chan struct{}) | |
| 353 | + | d.beforeReclaimFirstPaint = func(token attachmentRoleToken) { | |
| 354 | + | if token.ac != first { | |
| 355 | + | return | |
| 356 | + | } | |
| 357 | + | close(firstPaintPaused) | |
| 358 | + | <-releaseFirstPaint | |
| 359 | + | } | |
| 360 | + | firstDone := make(chan struct{}) | |
| 361 | + | go func() { | |
| 362 | + | token := sess.attachmentToken(first, firstTransport) | |
| 363 | + | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 364 | + | Type: ports.MsgInput, | |
| 365 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 366 | + | }) | |
| 367 | + | close(firstDone) | |
| 368 | + | }() | |
| 369 | + | awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint") | |
| 370 | + | require.Same(t, first, sess.client, "first reclaim was not committed before its paint") | |
| 371 | + | ||
| 372 | + | secondToken := sess.attachmentToken(second, secondTransport) | |
| 373 | + | require.Equal(t, attachmentSnatched, secondToken.role) | |
| 374 | + | d.handleSnatchedClientFrame(secondToken, ports.Frame{ | |
| 375 | + | Type: ports.MsgInput, | |
| 376 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 377 | + | }) | |
| 378 | + | require.Same(t, second, sess.client) | |
| 379 | + | close(releaseFirstPaint) | |
| 380 | + | awaitTestCompletion(t, firstDone, "stale first paint did not return") | |
| 381 | + | d.attachmentCleanupWg.Wait() | |
| 382 | + | ||
| 383 | + | newFirstFrames := firstTransport.Sends()[baselineFirstFrames:] | |
| 384 | + | require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim") | |
| 385 | + | require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type) | |
| 386 | + | output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload) | |
| 387 | + | require.NoError(t, err) | |
| 388 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 389 | + | requireSingleOwnerAndLease(t, sess, first, second) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) { | |
| 393 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 394 | + | firstTransport := &closeTrackingTransport{} | |
| 395 | + | first.replaceTransport(firstTransport) | |
| 396 | + | d.attachCoordinator(sess, nil, first, true) | |
| 397 | + | ||
| 398 | + | secondTransport := &closeTrackingTransport{} | |
| 399 | + | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 400 | + | second.initOverlays() | |
| 401 | + | toSecond, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 402 | + | target: sess, next: second, | |
| 403 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 404 | + | expectedTransport: second.transportSnapshot(), ready: true, | |
| 405 | + | }) | |
| 406 | + | require.NoError(t, err) | |
| 407 | + | d.deferAttachmentTransitionCleanups(toSecond) | |
| 408 | + | d.attachmentCleanupWg.Wait() | |
| 409 | + | ||
| 410 | + | thirdTransport := &closeTrackingTransport{} | |
| 411 | + | third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size} | |
| 412 | + | third.initOverlays() | |
| 413 | + | toThird, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 414 | + | target: sess, next: third, | |
| 415 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 416 | + | expectedTransport: third.transportSnapshot(), ready: true, | |
| 417 | + | }) | |
| 418 | + | require.NoError(t, err) | |
| 419 | + | d.deferAttachmentTransitionCleanups(toThird) | |
| 420 | + | d.attachmentCleanupWg.Wait() | |
| 421 | + | requireSingleOwnerAndLease(t, sess, first, second, third) | |
| 422 | + | ||
| 423 | + | firstToken := sess.attachmentToken(first, firstTransport) | |
| 424 | + | secondToken := sess.attachmentToken(second, secondTransport) | |
| 425 | + | require.Equal(t, attachmentSnatched, firstToken.role) | |
| 426 | + | require.Equal(t, attachmentSnatched, secondToken.role) | |
| 427 | + | start := make(chan struct{}) | |
| 428 | + | done := make(chan struct{}, 2) | |
| 429 | + | for _, token := range []attachmentRoleToken{firstToken, secondToken} { | |
| 430 | + | go func() { | |
| 431 | + | <-start | |
| 432 | + | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 433 | + | Type: ports.MsgInput, | |
| 434 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 435 | + | }) | |
| 436 | + | done <- struct{}{} | |
| 437 | + | }() | |
| 438 | + | } | |
| 439 | + | close(start) | |
| 440 | + | awaitTestCompletion(t, done, "first concurrent reclaim did not return") | |
| 441 | + | awaitTestCompletion(t, done, "second concurrent reclaim did not return") | |
| 442 | + | d.attachmentCleanupWg.Wait() | |
| 443 | + | ||
| 444 | + | requireSingleOwnerAndLease(t, sess, first, second, third) | |
| 445 | + | } | |
A internal/usecase/daemon/snatched_render.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | var snatchedModal = ui.Modal{ | |
| 13 | + | FixedWidth: 42, FixedHeight: 7, | |
| 14 | + | Title: " Session snatched ", Anchor: domain.AnchorCenter, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame { | |
| 18 | + | width, height := max(size.Cols, 1), max(size.Rows, 1) | |
| 19 | + | frame := renderer.NewFrame(width, height) | |
| 20 | + | ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase}) | |
| 21 | + | if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight { | |
| 22 | + | drawSnatchedCompact(frame, styles, feedback) | |
| 23 | + | return frame | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height}) | |
| 27 | + | inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase) | |
| 28 | + | message := "This session is now active elsewhere." | |
| 29 | + | if feedback != "" { | |
| 30 | + | message = feedback | |
| 31 | + | } | |
| 32 | + | drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase) | |
| 33 | + | drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection) | |
| 34 | + | return frame | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) { | |
| 38 | + | status := "Session snatched" | |
| 39 | + | if feedback != "" { | |
| 40 | + | status = "Session unavailable" | |
| 41 | + | } | |
| 42 | + | lines := []string{status, "r Resume", "q Quit"} | |
| 43 | + | startY := max((frame.Height-len(lines))/2, 0) | |
| 44 | + | for i, line := range lines { | |
| 45 | + | drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) { | |
| 50 | + | width := 0 | |
| 51 | + | for _, r := range text { | |
| 52 | + | width += renderer.RuneWidth(r) | |
| 53 | + | } | |
| 54 | + | x := bounds.X + max((bounds.Width-width)/2, 0) | |
| 55 | + | ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var errSnatchedOutputStale = errors.New("snatched output role changed") | |
| 59 | + | ||
| 60 | + | // sendSnatchedPanel serializes a dependency-free panel reset onto one exact | |
| 61 | + | // attachment role and transport lifetime. It does not inspect session state or | |
| 62 | + | // capture pane content. | |
| 63 | + | func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error { | |
| 64 | + | if expected.transport == nil { | |
| 65 | + | return errors.New("snatched client transport is nil") | |
| 66 | + | } | |
| 67 | + | var effect *roleEffectTicket | |
| 68 | + | if len(effects) != 0 { | |
| 69 | + | effect = effects[0] | |
| 70 | + | } | |
| 71 | + | tr, err := d.boundedSendWith(expected.transport, func() error { | |
| 72 | + | ac.sendMu.Lock() | |
| 73 | + | defer ac.sendMu.Unlock() | |
| 74 | + | if !ac.transportSnapshotCurrent(expected) { | |
| 75 | + | return errTransportReplaced | |
| 76 | + | } | |
| 77 | + | if ac.roleGeneration.Load() != generation { | |
| 78 | + | return errSnatchedOutputStale | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | applied := ac.getAppliedTheme() | |
| 82 | + | frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback) | |
| 83 | + | ac.output.rebase() | |
| 84 | + | prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true) | |
| 85 | + | if err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...) | |
| 89 | + | if effect != nil && !effect.beginTransportSend(expected) { | |
| 90 | + | return errAttachmentTransition | |
| 91 | + | } | |
| 92 | + | err = prepared.send(data, ac.echoAck.Load(), expected.transport.Send) | |
| 93 | + | if effect != nil { | |
| 94 | + | if err != nil { | |
| 95 | + | effect.reportTransportFailure(expected) | |
| 96 | + | } | |
| 97 | + | effect.endTransportSend() | |
| 98 | + | } | |
| 99 | + | return err | |
| 100 | + | }) | |
| 101 | + | if errors.Is(err, errSendTimedOut) { | |
| 102 | + | _ = ac.closeCapturedTransport(tr) | |
| 103 | + | } | |
| 104 | + | return err | |
| 105 | + | } | |
A internal/usecase/daemon/snatched_render_test.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestSnatchedPanelFrame(t *testing.T) { | |
| 19 | + | styles := themeui.Styles{ | |
| 20 | + | PickerBase: renderer.Style{Foreground: 1}, | |
| 21 | + | PickerSelection: renderer.Style{Foreground: 2}, | |
| 22 | + | BorderMuted: renderer.Style{Foreground: 3}, | |
| 23 | + | } | |
| 24 | + | tests := []struct { | |
| 25 | + | name string | |
| 26 | + | size domain.Size | |
| 27 | + | feedback string | |
| 28 | + | wantSize domain.Size | |
| 29 | + | want []string | |
| 30 | + | wantBounds domain.Rect | |
| 31 | + | compact bool | |
| 32 | + | }{ | |
| 33 | + | { | |
| 34 | + | name: "centered bordered panel", | |
| 35 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 36 | + | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 37 | + | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 38 | + | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "compact fallback", | |
| 42 | + | size: domain.Size{Cols: 24, Rows: 4}, | |
| 43 | + | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 44 | + | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 45 | + | compact: true, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "compact unavailable feedback", | |
| 49 | + | size: domain.Size{Cols: 24, Rows: 4}, | |
| 50 | + | feedback: "Session is no longer available.", | |
| 51 | + | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 52 | + | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 53 | + | compact: true, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "bordered unavailable feedback", | |
| 57 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 58 | + | feedback: "Session is no longer available.", | |
| 59 | + | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 60 | + | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 61 | + | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "zero dimensions clamp", | |
| 65 | + | size: domain.Size{}, | |
| 66 | + | wantSize: domain.Size{Cols: 1, Rows: 1}, | |
| 67 | + | compact: true, | |
| 68 | + | }, | |
| 69 | + | { | |
| 70 | + | name: "negative dimensions clamp", | |
| 71 | + | size: domain.Size{Cols: -10, Rows: -20}, | |
| 72 | + | wantSize: domain.Size{Cols: 1, Rows: 1}, | |
| 73 | + | compact: true, | |
| 74 | + | }, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | for _, tt := range tests { | |
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |
| 79 | + | frame := snatchedPanelFrame(tt.size, styles, tt.feedback) | |
| 80 | + | require.Equal(t, tt.wantSize.Cols, frame.Width) | |
| 81 | + | require.Equal(t, tt.wantSize.Rows, frame.Height) | |
| 82 | + | require.NoError(t, frame.Validate()) | |
| 83 | + | ||
| 84 | + | text := snatchedFrameText(frame) | |
| 85 | + | for _, want := range tt.want { | |
| 86 | + | require.Contains(t, text, want) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if tt.compact { | |
| 90 | + | require.NotContains(t, text, "┌") | |
| 91 | + | require.NotContains(t, text, "┘") | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds) | |
| 96 | + | topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y) | |
| 97 | + | bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1) | |
| 98 | + | require.Equal(t, '┌', topLeft.Rune) | |
| 99 | + | require.Equal(t, '┘', bottomRight.Rune) | |
| 100 | + | require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style") | |
| 101 | + | require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style") | |
| 102 | + | requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase) | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestSendSnatchedPanelRebasesOutput(t *testing.T) { | |
| 108 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 109 | + | tr := &closeTrackingTransport{} | |
| 110 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 111 | + | ac.output.next = 7 | |
| 112 | + | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 113 | + | ac.roleGeneration.Store(1) | |
| 114 | + | ||
| 115 | + | expected := ac.transportSnapshot() | |
| 116 | + | require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, "")) | |
| 117 | + | ||
| 118 | + | frames := tr.Sends() | |
| 119 | + | require.Len(t, frames, 1) | |
| 120 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 121 | + | out, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | require.Zero(t, out.BaseStateNum) | |
| 124 | + | require.Equal(t, uint64(8), out.NewStateNum) | |
| 125 | + | require.Contains(t, string(out.Data), "Session snatched") | |
| 126 | + | require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) { | |
| 130 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 131 | + | d := newTestDaemon(t, nil, clock) | |
| 132 | + | old := newBlockingSnatchedTransport() | |
| 133 | + | fresh := &closeTrackingTransport{} | |
| 134 | + | ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 135 | + | ac.roleGeneration.Store(1) | |
| 136 | + | expected := ac.transportSnapshot() | |
| 137 | + | ||
| 138 | + | result := make(chan error, 1) | |
| 139 | + | go func() { | |
| 140 | + | result <- d.sendSnatchedPanel(ac, expected, 1, "") | |
| 141 | + | }() | |
| 142 | + | ||
| 143 | + | timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout") | |
| 144 | + | awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport") | |
| 145 | + | ac.replaceTransport(fresh) | |
| 146 | + | timer.ch <- time.Time{} | |
| 147 | + | ||
| 148 | + | require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut) | |
| 149 | + | defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails. | |
| 150 | + | require.True(t, old.Closed(), "timed-out captured transport was not closed") | |
| 151 | + | require.False(t, fresh.Closed(), "replacement transport must remain open") | |
| 152 | + | ||
| 153 | + | sendMuReleased := make(chan uint64, 1) | |
| 154 | + | go func() { | |
| 155 | + | ac.sendMu.Lock() | |
| 156 | + | generation := ac.roleGeneration.Load() | |
| 157 | + | ac.sendMu.Unlock() | |
| 158 | + | sendMuReleased <- generation | |
| 159 | + | }() | |
| 160 | + | require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu")) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | type blockingSnatchedTransport struct { | |
| 164 | + | sendEntered chan struct{} | |
| 165 | + | closed chan struct{} | |
| 166 | + | sendOnce sync.Once | |
| 167 | + | closeOnce sync.Once | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func newBlockingSnatchedTransport() *blockingSnatchedTransport { | |
| 171 | + | return &blockingSnatchedTransport{ | |
| 172 | + | sendEntered: make(chan struct{}), | |
| 173 | + | closed: make(chan struct{}), | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (t *blockingSnatchedTransport) Send(ports.Frame) error { | |
| 178 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 179 | + | <-t.closed | |
| 180 | + | return errors.New("transport closed") | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (*blockingSnatchedTransport) Recv() (ports.Frame, error) { | |
| 184 | + | return ports.Frame{}, errors.New("not implemented") | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (t *blockingSnatchedTransport) Close() error { | |
| 188 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (t *blockingSnatchedTransport) Closed() bool { | |
| 193 | + | select { | |
| 194 | + | case <-t.closed: | |
| 195 | + | return true | |
| 196 | + | default: | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) { | |
| 202 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 203 | + | old := &closeTrackingTransport{} | |
| 204 | + | fresh := &closeTrackingTransport{} | |
| 205 | + | ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 206 | + | ac.roleGeneration.Store(1) | |
| 207 | + | stale := ac.transportSnapshot() | |
| 208 | + | ac.replaceTransport(fresh) | |
| 209 | + | ||
| 210 | + | require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced) | |
| 211 | + | require.Empty(t, old.Sends()) | |
| 212 | + | require.Empty(t, fresh.Sends()) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) { | |
| 216 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 217 | + | tr := &closeTrackingTransport{} | |
| 218 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 219 | + | ac.roleGeneration.Store(2) | |
| 220 | + | expected := ac.transportSnapshot() | |
| 221 | + | ||
| 222 | + | require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale) | |
| 223 | + | require.Empty(t, tr.Sends()) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) { | |
| 227 | + | t.Helper() | |
| 228 | + | for y := range frame.Height { | |
| 229 | + | for x := range frame.Width { | |
| 230 | + | if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height { | |
| 231 | + | continue | |
| 232 | + | } | |
| 233 | + | cell := frame.At(x, y) | |
| 234 | + | require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y) | |
| 235 | + | require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func snatchedFrameText(frame renderer.Frame) string { | |
| 241 | + | var out strings.Builder | |
| 242 | + | for y := 0; y < frame.Height; y++ { | |
| 243 | + | for _, cell := range frame.Row(y) { | |
| 244 | + | if cell.Continuation { | |
| 245 | + | continue | |
| 246 | + | } | |
| 247 | + | r := cell.Rune | |
| 248 | + | if r == 0 { | |
| 249 | + | r = ' ' | |
| 250 | + | } | |
| 251 | + | out.WriteRune(r) | |
| 252 | + | } | |
| 253 | + | out.WriteByte('\n') | |
| 254 | + | } | |
| 255 | + | return out.String() | |
| 256 | + | } | |
M internal/usecase/daemon/stalled_attachment_test.go +32 -21 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +4 -2
| @@ -528,7 +528,8 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) { | |||
| 528 | 528 | } | |
| 529 | 529 | tr, _ := newCapturingTransport(t) | |
| 530 | 530 | ||
| 531 | - | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 531 | + | ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 532 | + | require.NoError(t, err) | |
| 532 | 533 | ||
| 533 | 534 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| 534 | 535 | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| @@ -717,7 +718,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |||
| 717 | 718 | require.True(t, sess.detachIfCurrent(ac)) | |
| 718 | 719 | ||
| 719 | 720 | tr, _ := newCapturingTransport(t) | |
| 720 | - | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 721 | + | newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 722 | + | require.NoError(t, err) | |
| 721 | 723 | d.applyTheme(sess, newAC, ports.Theme{ | |
| 722 | 724 | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |
| 723 | 725 | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | |
M internal/usecase/daemon/theme_resolve.go +8 -0 collapsed to keep the page light
M internal/usecase/daemon/transactional_resize.go +98 -21 collapsed to keep the page light
M internal/usecase/daemon/transactional_resize_test.go +72 -11 collapsed to keep the page light
M internal/usecase/keys/router.go +65 -34
| @@ -63,11 +63,12 @@ type Router struct { | |||
| 63 | 63 | h Handler | |
| 64 | 64 | bindings *atomic.Pointer[Bindings] | |
| 65 | 65 | ||
| 66 | - | mu sync.Mutex | |
| 67 | - | pending bool | |
| 68 | - | pendingAlt []byte | |
| 69 | - | timer ports.Timer | |
| 70 | - | pendingDone chan struct{} | |
| 66 | + | mu sync.Mutex | |
| 67 | + | pending bool | |
| 68 | + | pendingAlt []byte | |
| 69 | + | pendingHandler Handler | |
| 70 | + | timer ports.Timer | |
| 71 | + | pendingDone chan struct{} | |
| 71 | 72 | } | |
| 72 | 73 | ||
| 73 | 74 | // NewRouter constructs a Router. bindings may be nil, in which case | |
| @@ -76,33 +77,49 @@ func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) | |||
| 76 | 77 | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings} | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 79 | - | // Route routes one transport read. | |
| 80 | - | func (r *Router) Route(data []byte) { | |
| 80 | + | // Route routes one transport read with the router's default handler. | |
| 81 | + | func (r *Router) Route(data []byte) { r.RouteWithHandler(data, r.h) } | |
| 82 | + | ||
| 83 | + | // RouteWithHandler binds every synchronous or delayed result from data to the | |
| 84 | + | // supplied handler. A retained ESC therefore cannot gain authority from a | |
| 85 | + | // later transport frame after the original frame's ownership token is stale. | |
| 86 | + | func (r *Router) RouteWithHandler(data []byte, h Handler) { | |
| 81 | 87 | if len(data) == 0 { | |
| 82 | 88 | return | |
| 83 | 89 | } | |
| 90 | + | if h == nil { | |
| 91 | + | h = r.h | |
| 92 | + | } | |
| 84 | 93 | r.mu.Lock() | |
| 85 | - | defer r.mu.Unlock() | |
| 94 | + | var pendingAlt []byte | |
| 86 | 95 | if r.pending { | |
| 87 | - | pendingAlt := r.pendingAlt | |
| 96 | + | pendingAlt = append([]byte(nil), r.pendingAlt...) | |
| 97 | + | } | |
| 98 | + | pendingHandler := r.pendingHandler | |
| 99 | + | wasPending := r.pending | |
| 100 | + | if wasPending { | |
| 88 | 101 | r.stopTimer() | |
| 89 | 102 | r.pending = false | |
| 90 | 103 | r.pendingAlt = nil | |
| 104 | + | r.pendingHandler = nil | |
| 105 | + | } | |
| 106 | + | r.mu.Unlock() | |
| 107 | + | if wasPending { | |
| 91 | 108 | combined := append(append([]byte(nil), pendingAlt...), data...) | |
| 92 | - | if consumed := r.routeAfterPendingESC(combined, len(pendingAlt)); consumed > len(pendingAlt) { | |
| 109 | + | if consumed := r.routeAfterPendingESC(combined, len(pendingAlt), pendingHandler); consumed > len(pendingAlt) { | |
| 93 | 110 | data = data[consumed-len(pendingAlt):] | |
| 94 | 111 | } | |
| 95 | 112 | } | |
| 96 | - | r.route(data) | |
| 113 | + | r.route(data, h) | |
| 97 | 114 | } | |
| 98 | 115 | ||
| 99 | - | func (r *Router) route(data []byte) { | |
| 116 | + | func (r *Router) route(data []byte, h Handler) { | |
| 100 | 117 | buf := make([]byte, 0, len(data)) | |
| 101 | 118 | flush := func() { | |
| 102 | 119 | if len(buf) == 0 { | |
| 103 | 120 | return | |
| 104 | 121 | } | |
| 105 | - | r.forward(buf) | |
| 122 | + | r.forward(buf, h) | |
| 106 | 123 | buf = buf[:0] | |
| 107 | 124 | } | |
| 108 | 125 | for i := 0; i < len(data); { | |
| @@ -113,7 +130,7 @@ func (r *Router) route(data []byte) { | |||
| 113 | 130 | } | |
| 114 | 131 | if i == len(data)-1 { | |
| 115 | 132 | flush() | |
| 116 | - | r.retainESC() | |
| 133 | + | r.retainESC(h) | |
| 117 | 134 | return | |
| 118 | 135 | } | |
| 119 | 136 | remaining := data[i+1:] | |
| @@ -134,12 +151,12 @@ func (r *Router) route(data []byte) { | |||
| 134 | 151 | } | |
| 135 | 152 | if action, size, ok, partial := r.altArrowCSI(remaining); ok { | |
| 136 | 153 | flush() | |
| 137 | - | r.h.Action(action) | |
| 154 | + | h.Action(action) | |
| 138 | 155 | i += 1 + size | |
| 139 | 156 | continue | |
| 140 | 157 | } else if partial { | |
| 141 | 158 | flush() | |
| 142 | - | r.retainESC(remaining) | |
| 159 | + | r.retainESC(h, remaining) | |
| 143 | 160 | return | |
| 144 | 161 | } | |
| 145 | 162 | next := data[i+1] | |
| @@ -150,13 +167,13 @@ func (r *Router) route(data []byte) { | |||
| 150 | 167 | } | |
| 151 | 168 | if action, size, ok := r.binding(remaining); ok { | |
| 152 | 169 | flush() | |
| 153 | - | r.h.Action(action) | |
| 170 | + | h.Action(action) | |
| 154 | 171 | i += 1 + size | |
| 155 | 172 | continue | |
| 156 | 173 | } | |
| 157 | 174 | if partialUTF8Rune(remaining) { | |
| 158 | 175 | flush() | |
| 159 | - | r.retainESC(remaining) | |
| 176 | + | r.retainESC(h, remaining) | |
| 160 | 177 | return | |
| 161 | 178 | } | |
| 162 | 179 | buf = append(buf, ESC) | |
| @@ -165,63 +182,77 @@ func (r *Router) route(data []byte) { | |||
| 165 | 182 | flush() | |
| 166 | 183 | } | |
| 167 | 184 | ||
| 168 | - | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |
| 185 | + | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int { | |
| 169 | 186 | if action, size, ok, partial := r.altArrowCSI(data); ok { | |
| 170 | - | r.h.Action(action) | |
| 187 | + | h.Action(action) | |
| 171 | 188 | return size | |
| 172 | 189 | } else if partial { | |
| 173 | - | r.retainESC(data) | |
| 190 | + | r.retainESC(h, data) | |
| 174 | 191 | return len(data) | |
| 175 | 192 | } | |
| 176 | 193 | next := data[0] | |
| 177 | 194 | if action, size, ok := r.binding(data); ok { | |
| 178 | - | r.h.Action(action) | |
| 195 | + | h.Action(action) | |
| 179 | 196 | return size | |
| 180 | 197 | } | |
| 181 | 198 | if partialUTF8Rune(data) { | |
| 182 | - | r.retainESC(data) | |
| 199 | + | r.retainESC(h, data) | |
| 183 | 200 | return len(data) | |
| 184 | 201 | } | |
| 185 | 202 | _, size := utf8.DecodeRune(data) | |
| 186 | 203 | if size > 1 { | |
| 187 | - | r.forward(append([]byte{ESC}, data[:size]...)) | |
| 204 | + | r.forward(append([]byte{ESC}, data[:size]...), h) | |
| 188 | 205 | return size | |
| 189 | 206 | } | |
| 190 | 207 | if pendingAltLen > 0 { | |
| 191 | - | r.forward(append([]byte{ESC}, data[:pendingAltLen]...)) | |
| 208 | + | r.forward(append([]byte{ESC}, data[:pendingAltLen]...), h) | |
| 192 | 209 | return pendingAltLen | |
| 193 | 210 | } | |
| 194 | 211 | if passThroughPrefix(next) { | |
| 195 | - | r.forward([]byte{ESC, next}) | |
| 212 | + | r.forward([]byte{ESC, next}, h) | |
| 196 | 213 | return 1 | |
| 197 | 214 | } | |
| 198 | - | r.forward([]byte{ESC}) | |
| 215 | + | r.forward([]byte{ESC}, h) | |
| 199 | 216 | return 0 | |
| 200 | 217 | } | |
| 201 | 218 | ||
| 202 | - | func (r *Router) retainESC(altBytes ...[]byte) { | |
| 219 | + | func (r *Router) retainESC(h Handler, altBytes ...[]byte) { | |
| 220 | + | r.mu.Lock() | |
| 203 | 221 | r.pending = true | |
| 204 | 222 | r.pendingAlt = nil | |
| 223 | + | r.pendingHandler = h | |
| 205 | 224 | if len(altBytes) > 0 { | |
| 206 | 225 | r.pendingAlt = append([]byte(nil), altBytes[0]...) | |
| 207 | 226 | } | |
| 208 | 227 | r.timer = r.clock.NewTimer(r.delay) | |
| 209 | 228 | r.pendingDone = make(chan struct{}) | |
| 229 | + | timer, done := r.timer, r.pendingDone | |
| 230 | + | r.mu.Unlock() | |
| 210 | 231 | go func(timer ports.Timer, done <-chan struct{}) { | |
| 211 | 232 | select { | |
| 212 | 233 | case <-timer.C(): | |
| 213 | 234 | case <-done: | |
| 214 | 235 | return | |
| 215 | 236 | } | |
| 237 | + | var ( | |
| 238 | + | data []byte | |
| 239 | + | h Handler | |
| 240 | + | ) | |
| 216 | 241 | r.mu.Lock() | |
| 217 | - | defer r.mu.Unlock() | |
| 218 | 242 | if r.pending && r.timer == timer { | |
| 219 | 243 | r.pending = false | |
| 220 | - | data := append([]byte{ESC}, r.pendingAlt...) | |
| 244 | + | data = append([]byte{ESC}, r.pendingAlt...) | |
| 245 | + | h = r.pendingHandler | |
| 221 | 246 | r.pendingAlt = nil | |
| 222 | - | r.forward(data) | |
| 247 | + | r.pendingHandler = nil | |
| 248 | + | r.timer = nil | |
| 249 | + | r.pendingDone = nil | |
| 250 | + | } | |
| 251 | + | r.mu.Unlock() | |
| 252 | + | if h != nil { | |
| 253 | + | r.forward(data, h) | |
| 223 | 254 | } | |
| 224 | - | }(r.timer, r.pendingDone) | |
| 255 | + | }(timer, done) | |
| 225 | 256 | } | |
| 226 | 257 | ||
| 227 | 258 | func (r *Router) stopTimer() { | |
| @@ -243,9 +274,9 @@ func partialUTF8Rune(data []byte) bool { | |||
| 243 | 274 | return key == utf8.RuneError && size == 1 | |
| 244 | 275 | } | |
| 245 | 276 | ||
| 246 | - | func (r *Router) forward(data []byte) { | |
| 277 | + | func (r *Router) forward(data []byte, h Handler) { | |
| 247 | 278 | cp := append([]byte(nil), data...) | |
| 248 | - | r.h.Forward(cp) | |
| 279 | + | h.Forward(cp) | |
| 249 | 280 | } | |
| 250 | 281 | ||
| 251 | 282 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |