1package daemon
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/layout"
18 appports "github.com/bnema/vev/internal/ports"
19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 "github.com/bnema/vev/internal/protocol"
21 "github.com/bnema/vev/internal/protocol/catalogue"
22 "github.com/bnema/vev/internal/protocol/wire"
23 "github.com/bnema/vev/internal/usecase/command"
24 "github.com/stretchr/testify/mock"
25 "github.com/stretchr/testify/require"
26)
27
28func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
29 tests := []struct {
30 name string
31 palette func(paletteExec) error
32 control func(controlExec) error
33 kind daemonActionKind
34 direction layout.Direction
35 }{
36 {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
37 {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left},
38 {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right},
39 }
40
41 for _, tt := range tests {
42 t.Run(tt.name, func(t *testing.T) {
43 d := newTestDaemon(t, nil, stubClock{})
44 sess := addControlSession(d, "work", "t_work", "p_work")
45 tb := testAttachmentTab(sess)
46 tb.mu.Lock()
47 pane := tb.focusedPane()
48 tb.mu.Unlock()
49 spy := newMockdaemonActionRunner(t)
50 requests := recordActionRuns(spy, nil)
51 target := daemonActionTarget{session: sess, tab: tb, pane: pane}
52
53 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
54 require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
55
56 require.Len(t, *requests, 2)
57 for _, request := range *requests {
58 require.Equal(t, tt.kind, request.kind)
59 require.Same(t, sess, request.target.session)
60 require.Same(t, tb, request.target.tab)
61 require.Same(t, pane, request.target.pane)
62 require.Equal(t, tt.direction, request.direction)
63 }
64 })
65 }
66}
67
68func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
69 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
70 h.tab.mu.Lock()
71 h.tab.tree.Focus = "pane-1"
72 beforeGeneration := h.tab.layoutGeneration
73 tabStableID := h.tab.stableID
74 targetStableID := h.panes["pane-3"].stableID
75 h.tab.mu.Unlock()
76
77 result := sendCommand(t, h.daemon, protocol.CommandRequest{
78 Slug: "consume-or-expel-pane-left",
79 Self: true,
80 TargetSession: "work",
81 TargetTab: tabStableID,
82 TargetPane: targetStableID,
83 })
84
85 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
86 require.Empty(t, result.Output)
87 require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
88 h.tab.mu.Lock()
89 require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
90 require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
91 require.Len(t, h.tab.tree.Root.Children, 2)
92 h.tab.mu.Unlock()
93}
94
95func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
96 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
97 ac := &attachedClient{}
98 ac.setSession(h.session)
99 h.session.mu.Lock()
100 h.session.registerAttachmentLocked(ac)
101 h.session.mu.Unlock()
102 invalidations := make(chan renderInvalidation, 1)
103 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
104 rc.attach(ac)
105 h.session.installRenderCoordinator(rc)
106 before := h.snapshot()
107
108 result := sendCommand(t, h.daemon, protocol.CommandRequest{
109 Slug: "consume-or-expel-pane-left",
110 Self: true,
111 TargetSession: "work",
112 TargetTab: h.tab.stableID,
113 TargetPane: h.panes["pane-1"].stableID,
114 })
115
116 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
117 require.Empty(t, result.Output)
118 require.Equal(t, before, h.snapshot())
119 require.Empty(t, h.daemon.notices.history())
120 requireNoInvalidation(t, invalidations)
121}
122
123// recordActionRuns makes runner return err and records every request it runs.
124func recordActionRuns(runner *mockdaemonActionRunner, err error) *[]daemonActionRequest {
125 var requests []daemonActionRequest
126 runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
127 requests = append(requests, request)
128 return err
129 })
130 return &requests
131}
132
133func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
134 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
135 clock := &signalClock{timers: make(chan *signalTimer, 1)}
136 d := newTestDaemon(t, factory, clock)
137 addControlSession(d, "work", "t_work", "p_work")
138 frame := commandFrame(t, protocol.CommandRequest{
139 RequestID: 17, Slug: "new-tab", TargetSession: "work",
140 })
141 tr, sends, releaseConn := newConn(t, frame)
142 defer releaseConn()
143 done := make(chan error, 1)
144 go func() {
145 done <- d.handleCommandFrame(tr, frame)
146 }()
147 <-factory.entered
148 timer := <-clock.timers
149 require.Equal(t, CommandRequestTimeout, timer.duration)
150 timer.ch <- time.Time{}
151 result := awaitCommandResult(t, sends)
152 require.Equal(t, uint64(17), result.RequestID)
153 require.Equal(t, uint16(0), result.Code)
154 require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
155 require.NoError(t, <-done)
156 close(factory.release)
157}
158
159func TestHandleCommandResponseSendTimeoutClosesTransport(t *testing.T) {
160 clock := &signalClock{timers: make(chan *signalTimer, 1)}
161 d := newTestDaemon(t, nil, clock)
162 tr := newBlockingControlSendTransport()
163 frame := commandFrame(t, protocol.CommandRequest{RequestID: 41, Attached: true})
164 done := make(chan error, 1)
165 go func() { done <- d.handleCommandFrame(tr, frame) }()
166
167 <-tr.started
168 timer := <-clock.timers
169 timer.ch <- time.Time{}
170 select {
171 case err := <-done:
172 require.ErrorIs(t, err, errSendTimedOut)
173 case <-time.After(testWaitTimeout):
174 t.Fatal("bounded control response did not return")
175 }
176 select {
177 case <-tr.closed:
178 default:
179 t.Fatal("timed-out control response did not close its transport")
180 }
181}
182
183func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
184 d := newTestDaemon(t, nil, stubClock{})
185 var logs bytes.Buffer
186 d.log = slog.New(slog.NewTextHandler(&logs, nil))
187 sendErr := errors.New("command result send failed")
188 tr := &commandSendErrorTransport{err: sendErr}
189
190 err := d.handleCommandFrame(tr, commandFrame(t, protocol.CommandRequest{
191 RequestID: 41,
192 Attached: true,
193 }))
194
195 require.ErrorIs(t, err, sendErr)
196 require.Contains(t, logs.String(), "command response send failed")
197 require.True(t, tr.closed, "the failed one-shot transport must still close")
198}
199
200func TestHandleConnRoutesCommand(t *testing.T) {
201 d := newTestDaemon(t, nil, stubClock{})
202 request := protocol.CommandRequest{Version: protocol.Version, Slug: "list-sessions"}
203 tr, sends, _ := newConn(t, mustClientEnvelope(request))
204
205 d.handleConn(tr)
206
207 result := awaitCommandResult(t, sends)
208 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
209}
210
211func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
212 tests := []struct {
213 name string
214 arrange func(*Daemon)
215 request protocol.CommandRequest
216 code uint16
217 }{
218 {name: "unknown slug", request: protocol.CommandRequest{Slug: "no-such"}, code: protocol.ErrUnknownCommand},
219 {name: "non-scriptable command", request: protocol.CommandRequest{Slug: "session-picker"}, code: protocol.ErrNotScriptable},
220 {name: "no sessions", request: protocol.CommandRequest{Slug: "split-right"}, code: protocol.ErrNoSuchTarget},
221 {name: "ambiguous sessions", arrange: func(d *Daemon) {
222 addControlSession(d, "one", "t_one", "p_one")
223 addControlSession(d, "two", "t_two", "p_two")
224 }, request: protocol.CommandRequest{Slug: "split-right"}, code: protocol.ErrAmbiguousTarget},
225 {name: "missing explicit session", arrange: func(d *Daemon) {
226 addControlSession(d, "work", "t_work", "p_work")
227 }, request: protocol.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: protocol.ErrNoSuchTarget},
228 {name: "missing stable IDs", arrange: func(d *Daemon) {
229 addControlSession(d, "work", "t_work", "p_work")
230 }, request: protocol.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: protocol.ErrNoSuchTarget},
231 {name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) {
232 addControlSession(d, "one", "t_shared", "p_shared")
233 addControlSession(d, "two", "t_shared", "p_shared")
234 }, request: protocol.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: protocol.ErrAmbiguousTarget},
235 {name: "self requires both IDs", arrange: func(d *Daemon) {
236 addControlSession(d, "work", "t_work", "p_work")
237 }, request: protocol.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: protocol.ErrNoSuchTarget},
238 {name: "self rejects pane from another tab", arrange: func(d *Daemon) {
239 sess := addControlSession(d, "work", "t_work", "p_work")
240 other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
241 other.ctx, other.cancel = context.WithCancel(d.serveCtx)
242 sess.tabs = append(sess.tabs, other)
243 }, request: protocol.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: protocol.ErrNoSuchTarget},
244 }
245
246 for _, tt := range tests {
247 t.Run(tt.name, func(t *testing.T) {
248 d := newTestDaemon(t, nil, stubClock{})
249 if tt.arrange != nil {
250 tt.arrange(d)
251 }
252 result := sendCommand(t, d, tt.request)
253 require.False(t, result.Outcome == protocol.CommandSucceeded)
254 require.Equal(t, tt.code, result.Code)
255 })
256 }
257}
258
259func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
260 d := newTestDaemon(t, nil, stubClock{})
261 sess := addControlSession(d, "renamed", "t_work", "p_work")
262
263 result := sendCommand(t, d, protocol.CommandRequest{
264 Slug: "toast", Args: []string{"hello"},
265 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
266 })
267
268 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
269 require.Equal(t, uint64(1), sess.mruAt.Load())
270}
271
272func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
273 d := newTestDaemon(t, nil, stubClock{})
274 sess := addControlSession(d, "work", "t_first", "p_first")
275 first := sess.tabs[0]
276 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
277 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
278 sess.mu.Lock()
279 sess.tabs = append(sess.tabs, second)
280 selectTestAttachmentTabLocked(sess, 0)
281 sess.mu.Unlock()
282
283 result := sendCommand(t, d, protocol.CommandRequest{
284 Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
285 TargetTab: "t_second", TargetPane: "p_second",
286 })
287
288 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
289 first.mu.Lock()
290 require.Equal(t, "targeted", first.name)
291 first.mu.Unlock()
292 second.mu.Lock()
293 require.Empty(t, second.name)
294 second.mu.Unlock()
295 sess.mu.Lock()
296 require.Zero(t, testAttachmentTabIndexLocked(sess))
297 sess.mu.Unlock()
298}
299
300func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
301 factory := &controlPTYFactory{}
302 d := newTestDaemon(t, factory, stubClock{})
303 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
304 sess := addControlSession(d, "work", "t_active", "p_active")
305 active := sess.tabs[0]
306 originFocus := active.tree.Focus
307 origin := active.focusedPane()
308 second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
309 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
310 sess.mu.Lock()
311 sess.tabs = append(sess.tabs, second)
312 selectTestAttachmentTabLocked(sess, 0)
313 sess.mu.Unlock()
314
315 result := sendCommand(t, d, protocol.CommandRequest{
316 Slug: "split-right", TargetSession: "work",
317 TargetTab: "t_origin", TargetPane: "p_origin",
318 })
319
320 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
321 sess.mu.Lock()
322 activeIndex := testAttachmentTabIndexLocked(sess)
323 sess.mu.Unlock()
324 require.Zero(t, activeIndex)
325 active.mu.Lock()
326 activePaneCount := len(active.panes)
327 activeFocus := active.tree.Focus
328 originRetained := active.panes[origin.id] == origin
329 active.mu.Unlock()
330 require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
331 require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
332 require.True(t, originRetained)
333 second.mu.Lock()
334 secondPaneCount := len(second.panes)
335 second.mu.Unlock()
336 require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
337}
338
339func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
340 factory := &controlPTYFactory{}
341 d := newTestDaemon(t, factory, stubClock{})
342 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
343 sess := addControlSession(d, "work", "t_active", "p_active")
344 active := sess.tabs[0]
345 invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
346 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
347 sess.mu.Lock()
348 sess.tabs = append(sess.tabs, invoking)
349 selectTestAttachmentTabLocked(sess, 0)
350 sess.mu.Unlock()
351
352 rename := sendCommand(t, d, protocol.CommandRequest{
353 Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
354 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
355 })
356 require.True(t, rename.Outcome == protocol.CommandSucceeded, rename.Text)
357 active.mu.Lock()
358 require.Empty(t, active.name)
359 active.mu.Unlock()
360 invoking.mu.Lock()
361 require.Equal(t, "invoking", invoking.name)
362 invoking.mu.Unlock()
363
364 split := sendCommand(t, d, protocol.CommandRequest{
365 Slug: "split-right", Self: true,
366 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
367 })
368 require.True(t, split.Outcome == protocol.CommandSucceeded, split.Text)
369 active.mu.Lock()
370 require.Len(t, active.panes, 1)
371 active.mu.Unlock()
372 invoking.mu.Lock()
373 require.Len(t, invoking.panes, 2)
374 beforeFocus := invoking.tree.Focus
375 beforeGeneration := invoking.layoutGeneration
376 invoking.mu.Unlock()
377
378 grow := sendCommand(t, d, protocol.CommandRequest{
379 Slug: "grow-pane-width", Self: true,
380 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
381 })
382 require.True(t, grow.Outcome == protocol.CommandSucceeded, grow.Text)
383 invoking.mu.Lock()
384 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
385 require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
386 require.NotZero(t, invoking.tree.Root.Children[0].Weight, "resize must update the invoking tab's shares")
387 invoking.mu.Unlock()
388 active.mu.Lock()
389 require.Len(t, active.panes, 1, "--self resize must not mutate the active tab")
390 active.mu.Unlock()
391
392 equalize := sendCommand(t, d, protocol.CommandRequest{
393 Slug: "equalize-panes", Self: true,
394 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
395 })
396 require.True(t, equalize.Outcome == protocol.CommandSucceeded, equalize.Text)
397 invoking.mu.Lock()
398 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
399 for _, child := range invoking.tree.Root.Children {
400 require.Zero(t, child.Weight, "equalize must clear each target-tab share")
401 }
402 invoking.mu.Unlock()
403}
404
405func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
406 targets := []struct {
407 name string
408 request func() protocol.CommandRequest
409 }{
410 {"named", func() protocol.CommandRequest { return protocol.CommandRequest{TargetSession: "work"} }},
411 {"unique", func() protocol.CommandRequest { return protocol.CommandRequest{} }},
412 }
413 for _, target := range targets {
414 t.Run(target.name, func(t *testing.T) {
415 for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
416 t.Run(slug, func(t *testing.T) {
417 factory := &controlPTYFactory{}
418 d := newTestDaemon(t, factory, stubClock{})
419 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
420 sess := addControlSession(d, "work", "t_work", "p_work")
421 require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
422 require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
423 tb := testAttachmentTab(sess)
424 tb.mu.Lock()
425 generation := tb.layoutGeneration
426 tb.mu.Unlock()
427 sess.snapEligible.Store(true)
428 sess.snapshotMu.Lock()
429 snapshotGeneration := sess.snapshotGeneration
430 sess.snapshotMu.Unlock()
431 request := target.request()
432 request.Slug = slug
433 result := sendCommand(t, d, request)
434 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
435 require.Empty(t, result.Output)
436 tb.mu.Lock()
437 require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
438 tb.mu.Unlock()
439 sess.snapshotMu.Lock()
440 require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
441 sess.snapshotMu.Unlock()
442 sess.mu.Lock()
443 require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless")
444 sess.mu.Unlock()
445 })
446 }
447 })
448 }
449}
450
451func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
452 factory := &controlPTYFactory{}
453 d := newTestDaemon(t, factory, stubClock{})
454 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
455 sess := addControlSession(d, "work", "t_active", "p_active")
456 require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
457 active := sess.tabs[0]
458 locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
459 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
460 sess.mu.Lock()
461 sess.tabs = append(sess.tabs, locator)
462 selectTestAttachmentTabLocked(sess, 0)
463 sess.mu.Unlock()
464 active.mu.Lock()
465 before := active.layoutGeneration
466 active.mu.Unlock()
467
468 result := sendCommand(t, d, protocol.CommandRequest{
469 Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
470 })
471 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
472 active.mu.Lock()
473 require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
474 active.mu.Unlock()
475 locator.mu.Lock()
476 require.Zero(t, locator.layoutGeneration)
477 locator.mu.Unlock()
478 sess.mu.Lock()
479 require.Zero(t, testAttachmentTabIndexLocked(sess))
480 sess.mu.Unlock()
481}
482
483func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
484 factory := &controlPTYFactory{}
485 d := newTestDaemon(t, factory, stubClock{})
486 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
487 sess := addControlSession(d, "work", "t_work", "p_work")
488 require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
489 tb := testAttachmentTab(sess)
490 tb.mu.Lock()
491 tb.size.Cols = 41
492 tb.bumpLayoutGenerationLocked()
493 tb.mu.Unlock()
494
495 result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
496 require.False(t, result.Outcome == protocol.CommandSucceeded)
497 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)
498 require.Equal(t, "pane cannot be resized further", result.Text)
499}
500
501func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
502 d := newTestDaemon(t, nil, stubClock{})
503 sess := addControlSession(d, "work", "t_active", "p_active")
504 invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
505 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
506 sess.mu.Lock()
507 sess.tabs = append(sess.tabs, invoking)
508 selectTestAttachmentTabLocked(sess, 0)
509 sess.mu.Unlock()
510
511 result := sendCommand(t, d, protocol.CommandRequest{
512 Slug: "list-panes", Self: true, JSON: true,
513 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
514 })
515 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
516 require.Contains(t, result.Output, "p_invoking")
517 require.NotContains(t, result.Output, "p_active")
518}
519
520func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
521 d := newTestDaemon(t, nil, stubClock{})
522 addControlSession(d, "work", "t_work", "p_work")
523
524 result := sendCommand(t, d, protocol.CommandRequest{
525 Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
526 })
527
528 require.False(t, result.Outcome == protocol.CommandSucceeded)
529 require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code)
530}
531
532func TestCloseCommandsReportMutationOutcome(t *testing.T) {
533 t.Run("stale close tab target fails", func(t *testing.T) {
534 d := newTestDaemon(t, nil, stubClock{})
535 sess := addControlSession(d, "work", "t_work", "p_work")
536 stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
537 cmd, ok := command.BySlug("close-tab")
538 require.True(t, ok)
539
540 result := d.runControl(cmd, controlExec{
541 d: d, sess: sess, tab: stale,
542 target: daemonActionTarget{session: sess, tab: stale},
543 }, protocol.CommandRequest{Slug: "close-tab"})
544
545 require.False(t, result.Outcome == protocol.CommandSucceeded)
546 require.Equal(t, protocol.ErrInternal, result.Code)
547 require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
548 })
549
550 t.Run("failed final pane close fails and retains session", func(t *testing.T) {
551 d := newTestDaemon(t, &controlPTYFactory{}, stubClock{})
552 d.snapsEnabled = true
553 d.persistEnabled = true
554 d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t)
555 sess := addControlSession(d, "work", "t_work", "p_work")
556 sess.mu.Lock()
557 sess.ephemeral = false
558 sess.mu.Unlock()
559 sess.snapEligible.Store(true)
560
561 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"})
562
563 require.False(t, result.Outcome == protocol.CommandSucceeded)
564 require.Equal(t, protocol.ErrInternal, result.Code)
565 d.mu.Lock()
566 require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
567 d.mu.Unlock()
568 sess.mu.Lock()
569 require.Len(t, sess.tabs, 1)
570 sess.mu.Unlock()
571 })
572
573 t.Run("successful close tab remains successful", func(t *testing.T) {
574 d := newTestDaemon(t, nil, stubClock{})
575 sess := addControlSession(d, "work", "t_first", "p_first")
576 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
577 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
578 sess.mu.Lock()
579 sess.tabs = append(sess.tabs, second)
580 selectTestAttachmentTabLocked(sess, 1)
581 sess.mu.Unlock()
582
583 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
584
585 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
586 sess.mu.Lock()
587 require.Len(t, sess.tabs, 1)
588 require.Equal(t, "t_second", sess.tabs[0].stableID)
589 sess.mu.Unlock()
590 })
591}
592
593func TestHandleCommandHeadlessMutations(t *testing.T) {
594 tests := []struct {
595 name string
596 slug string
597 args []string
598 verify func(*testing.T, *Daemon, *session)
599 }{
600 {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
601 tb := testAttachmentTab(sess)
602 tb.mu.Lock()
603 defer tb.mu.Unlock()
604 require.Len(t, tb.panes, 2)
605 }},
606 {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
607 sess.mu.Lock()
608 defer sess.mu.Unlock()
609 require.Len(t, sess.tabs, 2)
610 require.Equal(t, 0, testAttachmentTabIndexLocked(sess))
611 }},
612 {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
613 tb := testAttachmentTab(sess)
614 tb.mu.Lock()
615 defer tb.mu.Unlock()
616 require.Equal(t, "logs", tb.name)
617 }},
618 }
619 for _, tt := range tests {
620 t.Run(tt.name, func(t *testing.T) {
621 factory := &controlPTYFactory{}
622 d := newTestDaemon(t, factory, stubClock{})
623 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
624 sess := addControlSession(d, "work", "t_work", "p_work")
625
626 result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
627
628 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
629 tt.verify(t, d, sess)
630 })
631 }
632}
633
634func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
635 factory := &controlPTYFactory{}
636 d := newTestDaemon(t, factory, stubClock{})
637 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
638 source := addControlSession(d, "work", "t_work", "p_work")
639 source.mu.Lock()
640 source.cwd = "/tmp/work"
641 source.env = []string{"INHERITED=yes"}
642 source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
643 source.mu.Unlock()
644
645 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
646
647 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
648 d.mu.Lock()
649 created := d.findByNameLocked("scripted")
650 d.mu.Unlock()
651 require.NotNil(t, created)
652 created.mu.Lock()
653 require.Empty(t, created.snapshotAttachmentsLocked())
654 require.Equal(t, "/tmp/work", created.cwd)
655 require.Equal(t, []string{"INHERITED=yes"}, created.env)
656 tb := created.tabs[0]
657 created.mu.Unlock()
658 tb.mu.Lock()
659 require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
660 tb.mu.Unlock()
661 require.Empty(t, source.snapshotAttachments())
662
663 taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
664 require.False(t, taken.Outcome == protocol.CommandSucceeded)
665 require.Equal(t, protocol.ErrNameTaken, taken.Code)
666}
667
668func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) {
669 factory := &controlPTYFactory{}
670 d := newTestDaemon(t, factory, stubClock{})
671 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
672 source := addControlSession(d, "work", "t_work", "p_work")
673 client := &attachedClient{}
674 client.setGeometry(domain.Geometry{
675 Size: domain.Size{Cols: 120, Rows: 40}, PixelWidth: 1200, PixelHeight: 800,
676 })
677 require.True(t, source.registerAttachment(client))
678
679 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
680
681 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
682 d.mu.Lock()
683 created := d.findByNameLocked("scripted")
684 d.mu.Unlock()
685 require.NotNil(t, created)
686 created.mu.Lock()
687 tb := created.tabs[0]
688 created.mu.Unlock()
689 tb.mu.Lock()
690 pane := tb.focusedPane()
691 require.NotNil(t, pane)
692 require.Equal(t, domain.Geometry{
693 Size: domain.Size{Cols: 120, Rows: 38}, PixelWidth: 1200, PixelHeight: 760,
694 }, pane.geometry)
695 tb.mu.Unlock()
696}
697
698func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
699 d := newTestDaemon(t, nil, stubClock{})
700 sess := addControlSession(d, "work", "t_work", "p_work")
701
702 bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
703 require.False(t, bad.Outcome == protocol.CommandSucceeded)
704 require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code)
705
706 good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
707 require.True(t, good.Outcome == protocol.CommandSucceeded, good.Text)
708 d.notices.mu.Lock()
709 require.Len(t, d.notices.pending, 1)
710 require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
711 require.Equal(t, sess.id, d.notices.pending[0].SessionID)
712 d.notices.mu.Unlock()
713}
714
715func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
716 d := newTestDaemon(t, nil, stubClock{})
717 sess := addControlSession(d, "work", "t_work", "p_work")
718 sess.tabs[0].name = "shell"
719
720 sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
721 require.True(t, sessions.Outcome == protocol.CommandSucceeded, sessions.Text)
722 require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
723 require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
724
725 tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
726 require.True(t, tabs.Outcome == protocol.CommandSucceeded, tabs.Text)
727 require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
728
729 panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
730 require.True(t, panes.Outcome == protocol.CommandSucceeded, panes.Text)
731 var decoded []map[string]any
732 require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
733 require.Len(t, decoded, 1)
734 require.Equal(t, "p_work", decoded[0]["id"])
735 require.Equal(t, "/tmp/work", decoded[0]["cwd"])
736 require.Equal(t, true, decoded[0]["focused"])
737}
738
739func TestHandleCommandEnvExportsTargetSessionEnvironment(t *testing.T) {
740 tests := []struct {
741 name string
742 request protocol.CommandRequest
743 want string
744 }{
745 {
746 name: "text by session name",
747 request: protocol.CommandRequest{Slug: "env", TargetSession: "work"},
748 want: "WAYLAND_DISPLAY=wayland-1\n",
749 },
750 {
751 name: "json from inside a pane",
752 request: protocol.CommandRequest{Slug: "env", TargetTab: "t_work", TargetPane: "p_work", JSON: true},
753 want: `{"DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
754 },
755 }
756 for _, tt := range tests {
757 t.Run(tt.name, func(t *testing.T) {
758 d := newTestDaemon(t, nil, stubClock{})
759 sess := addControlSession(d, "work", "t_work", "p_work")
760 sess.mu.Lock()
761 sess.env = []string{"SHELL=/bin/sh", "SECRET=hidden", "WAYLAND_DISPLAY=wayland-1"}
762 sess.mu.Unlock()
763
764 result := sendCommand(t, d, tt.request)
765 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
766 require.Equal(t, tt.want, result.Output)
767 })
768 }
769}
770
771func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
772 d := newTestDaemon(t, nil, stubClock{})
773 d.mu.Lock()
774 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
775 d.mu.Unlock()
776
777 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
778 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
779
780 // Observation is read-only: the stopped record stays stopped, is not
781 // resumed into a live session, and is not purged.
782 d.mu.Lock()
783 defer d.mu.Unlock()
784 entry, ok := d.inactive["old"]
785 require.True(t, ok, "stopped record must survive observation")
786 require.Equal(t, protocol.SessionDown, entry.state)
787 for _, sess := range d.sessions {
788 sess.mu.Lock()
789 name := sess.name
790 sess.mu.Unlock()
791 require.NotEqual(t, "old", name, "observation must not resume stopped sessions")
792 }
793}
794
795func TestRemoteCatalogJSONOutput(t *testing.T) {
796 d := newTestDaemon(t, nil, stubClock{})
797 work := addControlSession(d, "work", "t_work", "p_work")
798 work.incarnation[0] = 1
799 work.ephemeral = false
800 work.registerAttachment(&attachedClient{})
801 work.mruAt.Store(2)
802 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
803 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
804 work.mu.Lock()
805 work.tabs = append(work.tabs, tb)
806 work.mu.Unlock()
807
808 build := addControlSession(d, "build", "t_build", "p_build")
809 build.incarnation[0] = 2
810 build.ephemeral = true
811 build.mruAt.Store(1)
812
813 d.mu.Lock()
814 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
815 // Live runtime authority wins if both registries contain the same name.
816 d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: protocol.SessionDown}
817 d.mu.Unlock()
818
819 listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
820 require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text)
821
822 missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})
823 require.False(t, missingJSON.Outcome == protocol.CommandSucceeded)
824 require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)
825
826 withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
827 require.False(t, withArgs.Outcome == protocol.CommandSucceeded)
828 require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)
829
830 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
831 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
832 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
833
834 var catalog catalogue.RemoteCatalog
835 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
836 require.Equal(t, protocol.Version, catalog.ProtocolVersion)
837
838 raw := map[string]json.RawMessage{}
839 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
840 require.Len(t, raw, 3)
841 _, hasProtocol := raw["protocol_version"]
842 _, hasSchema := raw["schema_version"]
843 _, hasSessions := raw["sessions"]
844 require.True(t, hasProtocol && hasSchema && hasSessions)
845
846 require.Equal(t, []catalogue.RemoteCatalogSession{
847 {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
848 {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []catalogue.RemoteCatalogTab{}},
849 {LifecycleID: work.incarnation, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "1", Detail: " (sh)"}, {ID: "t_work_2", Index: 1, Name: "2", Detail: " (sh)"}}, Attached: true, ActiveTabID: "t_work", LastUsedSeq: 2},
850 }, catalog.Sessions)
851
852 require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
853
854 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
855 require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text)
856 require.Equal(t, listBefore.Output, listAfter.Output)
857 require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
858 require.NotContains(t, listAfter.Output, "old")
859}
860
861func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
862 d := newTestDaemon(t, nil, stubClock{})
863 sess := addControlSession(d, "work", "t_work", "p_work")
864 sess.incarnation[0] = 1
865 tb := sess.tabs[0]
866 tb.name = "shell"
867 p := tb.focusedPane()
868 pty := portsmocks.NewMockPTY(t)
869 pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
870 p.pty = pty
871 d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
872 require.Equal(t, 1234, pid)
873 return "fish", nil
874 }}
875
876 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
877 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
878 var catalog catalogue.RemoteCatalog
879 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
880 require.Equal(t, []catalogue.RemoteCatalogSession{{
881 LifecycleID: sess.incarnation,
882 Name: "work",
883 State: "up",
884 Ephemeral: true,
885 Tabs: []catalogue.RemoteCatalogTab{{
886 ID: "t_work",
887 Index: 0,
888 Name: "shell",
889 Detail: " (fish)",
890 }},
891 ActiveTabID: "t_work",
892 LastUsedSeq: 1,
893 }}, catalog.Sessions)
894}
895
896func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
897 d := newTestDaemon(t, nil, stubClock{})
898 sess := addControlSession(d, "work", "t_work", "p_work")
899 sess.mu.Lock()
900 sess.tabs = make([]*tab, catalogue.RemoteCatalogMaxTabsPerSess+1)
901 for i := range sess.tabs {
902 sess.tabs[i] = newTabWithStableID(
903 fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
904 )
905 }
906 sess.mu.Unlock()
907
908 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
909 require.False(t, result.Outcome == protocol.CommandSucceeded)
910 require.Contains(t, result.Text, "too many tabs")
911}
912
913func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
914 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
915 d := newTestDaemon(t, factory, stubClock{})
916 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
917 sess := addControlSession(d, "work", "t_active", "p_active")
918 target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
919 target.ctx, target.cancel = context.WithCancel(d.serveCtx)
920 sess.mu.Lock()
921 sess.tabs = append(sess.tabs, target)
922 selectTestAttachmentTabLocked(sess, 0)
923 sess.mu.Unlock()
924 original := target.tree.Focus
925
926 firstFrame := commandFrame(t, protocol.CommandRequest{
927 Slug: "split-right", Self: true, TargetSession: "work",
928 TargetTab: "t_invoking", TargetPane: "p_invoking",
929 })
930 firstTransport, firstSends, _ := newConn(t, firstFrame)
931 firstDone := make(chan struct{})
932 go func() {
933 if err := d.handleCommandFrame(firstTransport, firstFrame); err != nil {
934 t.Errorf("handle first command: %v", err)
935 }
936 close(firstDone)
937 }()
938 select {
939 case <-factory.entered:
940 case <-time.After(2 * time.Second):
941 t.Fatal("first command did not enter its blocked action")
942 }
943
944 secondFrame := commandFrame(t, protocol.CommandRequest{
945 Slug: "focus-pane-right", Self: true, TargetSession: "work",
946 TargetTab: "t_invoking", TargetPane: "p_invoking",
947 })
948 secondTransport, secondSends, _ := newConn(t, secondFrame)
949 secondDone := make(chan struct{})
950 go func() {
951 if err := d.handleCommandFrame(secondTransport, secondFrame); err != nil {
952 t.Errorf("handle second command: %v", err)
953 }
954 close(secondDone)
955 }()
956 select {
957 case <-secondDone:
958 t.Fatal("second command bypassed serialization")
959 case <-time.After(100 * time.Millisecond):
960 }
961 close(factory.release)
962 <-firstDone
963 <-secondDone
964 require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded)
965 require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded)
966
967 sess.mu.Lock()
968 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
969 sess.mu.Unlock()
970 target.mu.Lock()
971 defer target.mu.Unlock()
972 require.Len(t, target.panes, 2)
973 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
974}
975
976func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
977 d := newTestDaemon(t, nil, stubClock{})
978 source := addControlSession(d, "work", "t_work", "p_work")
979 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
980 tb := source.tabs[0]
981 tb.mu.Lock()
982 pane := tb.focusedPane()
983 tb.mu.Unlock()
984 ac := &attachedClient{tr: &closeTrackingTransport{}}
985 ac.setSession(source)
986
987 source.mu.Lock()
988 require.True(t, source.registerAttachmentLocked(ac))
989 target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
990 require.True(t, source.unregisterAttachmentLocked(ac))
991 source.mu.Unlock()
992
993 err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
994 require.ErrorIs(t, err, errMoveStaleTarget)
995}
996
997func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
998 d := newTestDaemon(t, nil, stubClock{})
999 source := addControlSession(d, "work", "t_active", "p_active")
1000 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1001 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1002 publishTiledPaneOwners(source, inactive)
1003 source.mu.Lock()
1004 source.tabs = append(source.tabs, inactive)
1005 selectTestAttachmentTabLocked(source, 0)
1006 source.mu.Unlock()
1007 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1008
1009 result := sendCommand(t, d, protocol.CommandRequest{
1010 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1011 })
1012 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1013
1014 source.mu.Lock()
1015 require.Len(t, source.tabs, 1)
1016 require.Equal(t, "t_inactive", source.tabs[0].stableID)
1017 source.mu.Unlock()
1018 destination.mu.Lock()
1019 destTab := destination.tabs[0]
1020 destination.mu.Unlock()
1021 destTab.mu.Lock()
1022 var moved *pane
1023 for _, candidate := range destTab.panes {
1024 if candidate.stableID == "p_active" {
1025 moved = candidate
1026 }
1027 }
1028 require.NotNil(t, moved)
1029 destTab.mu.Unlock()
1030}
1031
1032// TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError proves a move
1033// command rejected because a transient KillAll purge owns the exact lifecycle
1034// set surfaces the same retryable internal outcome as the create path, instead
1035// of misreporting the destination as gone.
1036func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) {
1037 d := newTestDaemon(t, nil, stubClock{})
1038 addControlSession(d, "work", "t_work", "p_work")
1039 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1040
1041 beginTestPurgeAdmission(t, d)
1042 result := sendCommand(t, d, protocol.CommandRequest{
1043 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1044 })
1045 require.False(t, result.Outcome == protocol.CommandSucceeded)
1046 require.Equal(t, protocol.ErrInternal, result.Code)
1047 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
1048 d.endPurgeAdmission()
1049}
1050
1051func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
1052 d := newTestDaemon(t, nil, stubClock{})
1053 source := addControlSession(d, "work", "t_active", "p_active")
1054 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1055 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1056 publishTiledPaneOwners(source, inactive)
1057 source.mu.Lock()
1058 source.tabs = append(source.tabs, inactive)
1059 selectTestAttachmentTabLocked(source, 0)
1060 source.mu.Unlock()
1061 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1062
1063 result := sendCommand(t, d, protocol.CommandRequest{
1064 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1065 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
1066 })
1067 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1068
1069 active := source.tabs[0]
1070 active.mu.Lock()
1071 require.Len(t, active.panes, 1)
1072 require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
1073 active.mu.Unlock()
1074 destination.mu.Lock()
1075 destTab := destination.tabs[0]
1076 destination.mu.Unlock()
1077 destTab.mu.Lock()
1078 var moved *pane
1079 for _, candidate := range destTab.panes {
1080 if candidate.stableID == "p_inactive" {
1081 moved = candidate
1082 }
1083 }
1084 require.NotNil(t, moved)
1085 destTab.mu.Unlock()
1086}
1087
1088func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
1089 d := newTestDaemon(t, nil, stubClock{})
1090 source := addControlSession(d, "work", "t_first", "p_first")
1091 publishTiledPaneOwners(source, source.tabs[0])
1092 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1093 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1094 publishTiledPaneOwners(source, second)
1095 source.mu.Lock()
1096 source.tabs = append(source.tabs, second)
1097 selectTestAttachmentTabLocked(source, 0)
1098 source.mu.Unlock()
1099 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1100
1101 result := sendCommand(t, d, protocol.CommandRequest{
1102 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1103 TargetTab: "t_second", TargetPane: "p_second",
1104 })
1105 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1106
1107 source.mu.Lock()
1108 require.Len(t, source.tabs, 1)
1109 require.Equal(t, "t_second", source.tabs[0].stableID)
1110 source.mu.Unlock()
1111 destination.mu.Lock()
1112 require.Len(t, destination.tabs, 2)
1113 require.Equal(t, "t_first", destination.tabs[1].stableID)
1114 destination.mu.Unlock()
1115}
1116
1117func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1118 d := newTestDaemon(t, nil, stubClock{})
1119 source := addControlSession(d, "work", "t_first", "p_first")
1120 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1121 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1122 publishTiledPaneOwners(source, second)
1123 source.mu.Lock()
1124 source.tabs = append(source.tabs, second)
1125 selectTestAttachmentTabLocked(source, 0)
1126 source.mu.Unlock()
1127 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1128
1129 result := sendCommand(t, d, protocol.CommandRequest{
1130 Slug: "move-tab", Args: []string{"dest"}, Self: true,
1131 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1132 })
1133 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1134
1135 source.mu.Lock()
1136 require.Len(t, source.tabs, 1)
1137 require.Equal(t, "t_first", source.tabs[0].stableID)
1138 source.mu.Unlock()
1139 destination.mu.Lock()
1140 require.Len(t, destination.tabs, 2)
1141 require.Equal(t, "t_second", destination.tabs[1].stableID)
1142 destination.mu.Unlock()
1143}
1144
1145func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1146 tests := []struct {
1147 name string
1148 arrange func(*Daemon) protocol.CommandRequest
1149 code uint16
1150 text string
1151 }{
1152 {
1153 name: "move-pane missing destination session",
1154 arrange: func(d *Daemon) protocol.CommandRequest {
1155 addControlSession(d, "work", "t_work", "p_work")
1156 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1157 },
1158 code: protocol.ErrNoSuchTarget,
1159 text: "move request is invalid",
1160 },
1161 {
1162 name: "move-pane unknown destination session",
1163 arrange: func(d *Daemon) protocol.CommandRequest {
1164 addControlSession(d, "work", "t_work", "p_work")
1165 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1166 },
1167 code: protocol.ErrNoSuchTarget,
1168 text: "move target is no longer available",
1169 },
1170 {
1171 name: "move-pane destination tab not in session",
1172 arrange: func(d *Daemon) protocol.CommandRequest {
1173 addControlSession(d, "work", "t_work", "p_work")
1174 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1175 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1176 },
1177 code: protocol.ErrNoSuchTarget,
1178 text: "move target is no longer available",
1179 },
1180 {
1181 name: "move-pane same source tab",
1182 arrange: func(d *Daemon) protocol.CommandRequest {
1183 addControlSession(d, "work", "t_work", "p_work")
1184 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1185 },
1186 code: protocol.ErrNoSuchTarget,
1187 text: "move request is invalid",
1188 },
1189 {
1190 name: "move-tab same session",
1191 arrange: func(d *Daemon) protocol.CommandRequest {
1192 addControlSession(d, "work", "t_work", "p_work")
1193 return protocol.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1194 },
1195 code: protocol.ErrNoSuchTarget,
1196 text: "move request is invalid",
1197 },
1198 {
1199 name: "move-tab stopping destination",
1200 arrange: func(d *Daemon) protocol.CommandRequest {
1201 addControlSession(d, "work", "t_work", "p_work")
1202 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1203 destination.teardownMu.Lock()
1204 destination.teardownActive = true
1205 destination.teardownMu.Unlock()
1206 return protocol.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1207 },
1208 code: protocol.ErrNoSuchTarget,
1209 text: "move target is no longer available",
1210 },
1211 {
1212 name: "move-pane too few args",
1213 arrange: func(d *Daemon) protocol.CommandRequest {
1214 addControlSession(d, "work", "t_work", "p_work")
1215 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1216 },
1217 code: protocol.ErrInvalidCommandArgs,
1218 },
1219 {
1220 name: "move-pane too many args",
1221 arrange: func(d *Daemon) protocol.CommandRequest {
1222 addControlSession(d, "work", "t_work", "p_work")
1223 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1224 },
1225 code: protocol.ErrInvalidCommandArgs,
1226 },
1227 {
1228 name: "move-tab missing destination",
1229 arrange: func(d *Daemon) protocol.CommandRequest {
1230 addControlSession(d, "work", "t_work", "p_work")
1231 return protocol.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1232 },
1233 code: protocol.ErrInvalidCommandArgs,
1234 },
1235 {
1236 name: "explicit session name without IDs remains authoritative",
1237 arrange: func(d *Daemon) protocol.CommandRequest {
1238 addControlSession(d, "renamed", "t_work", "p_work")
1239 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1240 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1241 },
1242 code: protocol.ErrNoSuchTarget,
1243 text: "no such session: old-name",
1244 },
1245 }
1246
1247 for _, tt := range tests {
1248 t.Run(tt.name, func(t *testing.T) {
1249 d := newTestDaemon(t, nil, stubClock{})
1250 request := tt.arrange(d)
1251 result := sendCommand(t, d, request)
1252 require.False(t, result.Outcome == protocol.CommandSucceeded)
1253 require.Equal(t, tt.code, result.Code)
1254 if tt.text != "" {
1255 require.Equal(t, tt.text, result.Text)
1256 }
1257 })
1258 }
1259}
1260
1261func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1262 d := newTestDaemon(t, nil, stubClock{})
1263 advisory := addControlSession(d, "old-name", "t_other", "p_other")
1264 source := addControlSession(d, "renamed", "t_work", "p_work")
1265 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1266
1267 result := sendCommand(t, d, protocol.CommandRequest{
1268 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1269 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1270 })
1271 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1272
1273 source.mu.Lock()
1274 require.Len(t, source.tabs, 0)
1275 source.mu.Unlock()
1276 advisory.mu.Lock()
1277 require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1278 advisory.mu.Unlock()
1279 destination.mu.Lock()
1280 destTab := destination.tabs[0]
1281 destination.mu.Unlock()
1282 destTab.mu.Lock()
1283 require.Len(t, destTab.panes, 2)
1284 destTab.mu.Unlock()
1285}
1286
1287func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1288 d := newTestDaemon(t, nil, stubClock{})
1289 source := addControlSession(d, "renamed", "t_active", "p_active")
1290 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1291 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1292 publishTiledPaneOwners(source, inactive)
1293 source.mu.Lock()
1294 source.tabs = append(source.tabs, inactive)
1295 selectTestAttachmentTabLocked(source, 1)
1296 source.mu.Unlock()
1297 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1298
1299 result := sendCommand(t, d, protocol.CommandRequest{
1300 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1301 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1302 })
1303 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1304
1305 source.mu.Lock()
1306 require.Len(t, source.tabs, 1)
1307 require.Equal(t, "t_active", source.tabs[0].stableID)
1308 source.mu.Unlock()
1309 destination.mu.Lock()
1310 destTab := destination.tabs[0]
1311 destination.mu.Unlock()
1312 destTab.mu.Lock()
1313 var moved *pane
1314 for _, candidate := range destTab.panes {
1315 if candidate.stableID == "p_inactive" {
1316 moved = candidate
1317 }
1318 }
1319 require.NotNil(t, moved)
1320 destTab.mu.Unlock()
1321}
1322
1323func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1324 d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1325 left.mu.Lock()
1326 left.name = "left"
1327 left.tabs[0].stableID = "left-moved"
1328 selectTestAttachmentTabLocked(left, 0)
1329 left.mu.Unlock()
1330
1331 right := addMoveTabTestSession(d, "right", "right-stays")
1332 rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1333 rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1334 publishTiledPaneOwners(right, rightMoved)
1335 right.mu.Lock()
1336 right.tabs = append(right.tabs, rightMoved)
1337 selectTestAttachmentTabLocked(right, 1)
1338 right.mu.Unlock()
1339
1340 leftFrame := commandFrame(t, protocol.CommandRequest{
1341 Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1342 })
1343 rightFrame := commandFrame(t, protocol.CommandRequest{
1344 Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1345 })
1346 leftTransport, leftSends, _ := newConn(t, leftFrame)
1347 rightTransport, rightSends, _ := newConn(t, rightFrame)
1348 start := make(chan struct{})
1349 done := make(chan struct{}, 2)
1350 go func() {
1351 <-start
1352 if err := d.handleCommandFrame(leftTransport, leftFrame); err != nil {
1353 t.Errorf("handle left command: %v", err)
1354 }
1355 done <- struct{}{}
1356 }()
1357 go func() {
1358 <-start
1359 if err := d.handleCommandFrame(rightTransport, rightFrame); err != nil {
1360 t.Errorf("handle right command: %v", err)
1361 }
1362 done <- struct{}{}
1363 }()
1364 close(start)
1365 for _, sends := range []chan wire.Envelope{leftSends, rightSends} {
1366 select {
1367 case <-done:
1368 result := awaitCommandResult(t, sends)
1369 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1370 case <-time.After(5 * time.Second):
1371 t.Fatal("opposite-direction move commands deadlocked")
1372 }
1373 }
1374}
1375
1376func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1377 ctx, cancel := context.WithCancel(d.serveCtx)
1378 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1379 tb.ctx, tb.cancel = context.WithCancel(ctx)
1380 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1381 ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1382 }
1383 publishTiledPaneOwners(sess, tb)
1384 d.mu.Lock()
1385 d.sessions[sess.id] = sess
1386 d.mu.Unlock()
1387 return sess
1388}
1389
1390func commandFrame(t *testing.T, request protocol.CommandRequest) wire.Envelope {
1391 t.Helper()
1392 if request.Version == 0 {
1393 request.Version = protocol.Version
1394 }
1395 return mustClientEnvelope(request)
1396}
1397
1398func sendCommand(t *testing.T, d *Daemon, request protocol.CommandRequest) protocol.CommandResult {
1399 t.Helper()
1400 frame := commandFrame(t, request)
1401 tr, sends, _ := newConn(t, frame)
1402 require.NoError(t, d.handleCommandFrame(tr, frame))
1403 return awaitCommandResult(t, sends)
1404}
1405
1406func awaitCommandResult(t *testing.T, sends chan wire.Envelope) protocol.CommandResult {
1407 t.Helper()
1408 reply := awaitFrame(t, sends, "CommandResult")
1409 return decodeServerMessage(t, reply).(protocol.CommandResult)
1410}
1411
1412type controlPTYFactory struct {
1413 mu sync.Mutex
1414 ptys []*quietPTY
1415 entered chan struct{}
1416 release chan struct{}
1417 once sync.Once
1418}
1419
1420func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (appports.PTY, error) {
1421 f.once.Do(func() {
1422 if f.entered != nil {
1423 close(f.entered)
1424 <-f.release
1425 }
1426 })
1427 pty := newQuietPTY()
1428 f.mu.Lock()
1429 f.ptys = append(f.ptys, pty)
1430 f.mu.Unlock()
1431 return pty, nil
1432}
1433
1434func (f *controlPTYFactory) close() {
1435 if f.release != nil {
1436 select {
1437 case <-f.release:
1438 default:
1439 close(f.release)
1440 }
1441 }
1442 f.mu.Lock()
1443 defer f.mu.Unlock()
1444 for _, pty := range f.ptys {
1445 _ = pty.Close()
1446 }
1447}
1448
1449type blockingControlSendTransport struct {
1450 started chan struct{}
1451 closed chan struct{}
1452 once sync.Once
1453}
1454
1455func newBlockingControlSendTransport() *blockingControlSendTransport {
1456 return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1457}
1458
1459func (tr *blockingControlSendTransport) Send(wire.Envelope) error {
1460 tr.once.Do(func() { close(tr.started) })
1461 <-tr.closed
1462 return io.ErrClosedPipe
1463}
1464
1465func (*blockingControlSendTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1466func (tr *blockingControlSendTransport) Close() error {
1467 tr.once.Do(func() { close(tr.started) })
1468 select {
1469 case <-tr.closed:
1470 default:
1471 close(tr.closed)
1472 }
1473 return nil
1474}
1475
1476type commandSendErrorTransport struct {
1477 err error
1478 closed bool
1479}
1480
1481func (tr *commandSendErrorTransport) Send(wire.Envelope) error { return tr.err }
1482func (*commandSendErrorTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1483func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil }
1484
1485func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1486 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1487 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1488 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1489 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1490 }
1491 sess.mruAt.Store(1)
1492 d.mu.Lock()
1493 d.sessions[sess.id] = sess
1494 d.mu.Unlock()
1495 return sess
1496}
1497
1498// TestHandleKillSessionParticipantsChangedReportsRetryable proves a direct
1499// session kill whose teardown snapshot a concurrent token resume invalidated
1500// reports a clear retryable outcome instead of a permanent delete failure, and
1501// leaves the session live.
1502func TestHandleKillSessionParticipantsChangedReportsRetryable(t *testing.T) {
1503 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1504
1505 d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1506 if action != "" {
1507 return
1508 }
1509 d.afterAttachmentEffectParticipantsSnapshotted = nil
1510 // A token resume replaces the exact transport after the participant
1511 // snapshot was taken, so the kill aborts before removing anything.
1512 fresh := &closeTrackingTransport{}
1513 ac.replaceTransport(fresh)
1514 ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1515 }
1516 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1517
1518 tr, sends := newCapturingTransport(t)
1519 d.handleKill(tr, protocol.Kill{RequestID: 1, Scope: protocol.KillSession, Name: "work"})
1520
1521 frame := awaitFrame(t, sends, "KillResult")
1522 result, ok := decodeServerMessage(t, frame).(protocol.KillResult)
1523 require.True(t, ok)
1524 require.Equal(t, uint64(1), result.RequestID)
1525 require.Equal(t, protocol.KillFailed, result.Outcome)
1526 require.Equal(t, protocol.ErrInternal, result.Code)
1527 require.Equal(t, sessionKillRetryMessage, result.Text)
1528 require.Equal(t, 1, sessionCount(d), "an aborted direct kill must leave the session live")
1529 require.Same(t, sess, firstSession(d))
1530}
1531
1532// TestCloseTabParticipantsChangedReportsRetryable proves a last-tab close whose
1533// teardown snapshot a concurrent token resume invalidated reports a clear
1534// retryable outcome to both the command result and the attached client, and
1535// leaves the session and its tab live.
1536func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
1537 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1538
1539 d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1540 if action != "" {
1541 return
1542 }
1543 d.afterAttachmentEffectParticipantsSnapshotted = nil
1544 fresh := &closeTrackingTransport{}
1545 ac.replaceTransport(fresh)
1546 ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1547 }
1548 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1549
1550 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
1551 require.False(t, result.Outcome == protocol.CommandSucceeded)
1552 require.Equal(t, protocol.ErrInternal, result.Code)
1553 require.Equal(t, sessionKillRetryMessage, result.Text)
1554
1555 toasts := awaitToastCount(t, ac, 1)
1556 require.Contains(t, toasts[0].Message, "couldn't close tab")
1557 require.Contains(t, toasts[0].Message, "try again")
1558
1559 sess.mu.Lock()
1560 tabs := len(sess.tabs)
1561 sess.mu.Unlock()
1562 require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")
1563 require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")
1564}
1565
1566func TestDispatchActionLifecycle(t *testing.T) {
1567 boom := errors.New("boom")
1568 tests := []struct {
1569 name string
1570 runErr error
1571 serialize bool
1572 // callerHolds enters dispatchAction already inside the dispatch
1573 // boundary, as the palette and control paths do.
1574 callerHolds bool
1575 wantChanged bool
1576 wantErr error
1577 wantRender bool
1578 }{
1579 {name: "change renders the attachment", wantChanged: true, wantRender: true},
1580 {name: "no change is success without render", runErr: errDaemonActionNoChange},
1581 {name: "failure skips render", runErr: boom, wantErr: boom},
1582 {name: "serialized change holds dispatch boundary", serialize: true, wantChanged: true, wantRender: true},
1583 {name: "caller-held boundary is reused and still renders", callerHolds: true, wantChanged: true, wantRender: true},
1584 }
1585 for _, tt := range tests {
1586 t.Run(tt.name, func(t *testing.T) {
1587 d := newTestDaemon(t, nil, stubClock{})
1588 sess := addControlSession(d, "work", "t_work", "p_work")
1589 ac := &attachedClient{}
1590 ac.setSession(sess)
1591 sess.mu.Lock()
1592 sess.registerAttachmentLocked(ac)
1593 sess.mu.Unlock()
1594 invalidations := make(chan renderInvalidation, 2)
1595 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
1596 rc.attach(ac)
1597 sess.installRenderCoordinator(rc)
1598
1599 runner := newMockdaemonActionRunner(t)
1600 runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
1601 require.Same(t, sess, request.target.session, "unset target resolves from the attachment")
1602 require.Same(t, ac, request.target.attachment)
1603 held := !sess.dispatchMu.TryLock()
1604 if !held {
1605 sess.dispatchMu.Unlock()
1606 }
1607 require.Equal(t, tt.serialize || tt.callerHolds, held, "dispatch boundary ownership")
1608 return tt.runErr
1609 }).Once()
1610 x := daemonActionDispatch{runner: runner, request: daemonActionRequest{kind: daemonActionEqualizePanes}, resolveFrom: sess, attachment: ac, producer: "test"}
1611 if tt.serialize {
1612 x.serialize = sess
1613 }
1614
1615 if tt.callerHolds {
1616 sess.dispatchMu.Lock()
1617 }
1618 changed, err := d.dispatchAction(x)
1619 if tt.callerHolds {
1620 sess.dispatchMu.Unlock()
1621 }
1622
1623 require.ErrorIs(t, err, tt.wantErr)
1624 require.NotErrorIs(t, err, errDaemonActionNoChange)
1625 require.Equal(t, tt.wantChanged, changed)
1626 require.True(t, sess.dispatchMu.TryLock(), "dispatchAction must not leave the boundary held")
1627 sess.dispatchMu.Unlock()
1628 if tt.wantRender {
1629 require.Equal(t, "test", awaitInvalidation(t, invalidations).producer)
1630 } else {
1631 requireNoInvalidation(t, invalidations)
1632 }
1633 })
1634 }
1635}