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 appports "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol"
20 "github.com/bnema/vev/internal/protocol/catalogue"
21 "github.com/bnema/vev/internal/protocol/wire"
22 "github.com/bnema/vev/internal/usecase/command"
23 "github.com/bnema/vev/internal/usecase/layout"
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 TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
740 d := newTestDaemon(t, nil, stubClock{})
741 d.mu.Lock()
742 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
743 d.mu.Unlock()
744
745 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
746 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
747
748 // Observation is read-only: the stopped record stays stopped, is not
749 // resumed into a live session, and is not purged.
750 d.mu.Lock()
751 defer d.mu.Unlock()
752 entry, ok := d.inactive["old"]
753 require.True(t, ok, "stopped record must survive observation")
754 require.Equal(t, protocol.SessionDown, entry.state)
755 for _, sess := range d.sessions {
756 sess.mu.Lock()
757 name := sess.name
758 sess.mu.Unlock()
759 require.NotEqual(t, "old", name, "observation must not resume stopped sessions")
760 }
761}
762
763func TestRemoteCatalogJSONOutput(t *testing.T) {
764 d := newTestDaemon(t, nil, stubClock{})
765 work := addControlSession(d, "work", "t_work", "p_work")
766 work.incarnation[0] = 1
767 work.ephemeral = false
768 work.registerAttachment(&attachedClient{})
769 work.mruAt.Store(2)
770 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
771 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
772 work.mu.Lock()
773 work.tabs = append(work.tabs, tb)
774 work.mu.Unlock()
775
776 build := addControlSession(d, "build", "t_build", "p_build")
777 build.incarnation[0] = 2
778 build.ephemeral = true
779 build.mruAt.Store(1)
780
781 d.mu.Lock()
782 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
783 // Live runtime authority wins if both registries contain the same name.
784 d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: protocol.SessionDown}
785 d.mu.Unlock()
786
787 listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
788 require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text)
789
790 missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})
791 require.False(t, missingJSON.Outcome == protocol.CommandSucceeded)
792 require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)
793
794 withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
795 require.False(t, withArgs.Outcome == protocol.CommandSucceeded)
796 require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)
797
798 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
799 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
800 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
801
802 var catalog catalogue.RemoteCatalog
803 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
804 require.Equal(t, protocol.Version, catalog.ProtocolVersion)
805
806 raw := map[string]json.RawMessage{}
807 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
808 require.Len(t, raw, 3)
809 _, hasProtocol := raw["protocol_version"]
810 _, hasSchema := raw["schema_version"]
811 _, hasSessions := raw["sessions"]
812 require.True(t, hasProtocol && hasSchema && hasSessions)
813
814 require.Equal(t, []catalogue.RemoteCatalogSession{
815 {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
816 {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []catalogue.RemoteCatalogTab{}},
817 {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},
818 }, catalog.Sessions)
819
820 require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
821
822 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
823 require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text)
824 require.Equal(t, listBefore.Output, listAfter.Output)
825 require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
826 require.NotContains(t, listAfter.Output, "old")
827}
828
829func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
830 d := newTestDaemon(t, nil, stubClock{})
831 sess := addControlSession(d, "work", "t_work", "p_work")
832 sess.incarnation[0] = 1
833 tb := sess.tabs[0]
834 tb.name = "shell"
835 p := tb.focusedPane()
836 pty := portsmocks.NewMockPTY(t)
837 pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
838 p.pty = pty
839 d.procComm = func(pid int) (string, error) {
840 require.Equal(t, 1234, pid)
841 return "fish", nil
842 }
843
844 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
845 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
846 var catalog catalogue.RemoteCatalog
847 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
848 require.Equal(t, []catalogue.RemoteCatalogSession{{
849 LifecycleID: sess.incarnation,
850 Name: "work",
851 State: "up",
852 Ephemeral: true,
853 Tabs: []catalogue.RemoteCatalogTab{{
854 ID: "t_work",
855 Index: 0,
856 Name: "shell",
857 Detail: " (fish)",
858 }},
859 ActiveTabID: "t_work",
860 LastUsedSeq: 1,
861 }}, catalog.Sessions)
862}
863
864func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
865 d := newTestDaemon(t, nil, stubClock{})
866 sess := addControlSession(d, "work", "t_work", "p_work")
867 sess.mu.Lock()
868 sess.tabs = make([]*tab, catalogue.RemoteCatalogMaxTabsPerSess+1)
869 for i := range sess.tabs {
870 sess.tabs[i] = newTabWithStableID(
871 fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
872 )
873 }
874 sess.mu.Unlock()
875
876 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
877 require.False(t, result.Outcome == protocol.CommandSucceeded)
878 require.Contains(t, result.Text, "too many tabs")
879}
880
881func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
882 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
883 d := newTestDaemon(t, factory, stubClock{})
884 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
885 sess := addControlSession(d, "work", "t_active", "p_active")
886 target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
887 target.ctx, target.cancel = context.WithCancel(d.serveCtx)
888 sess.mu.Lock()
889 sess.tabs = append(sess.tabs, target)
890 selectTestAttachmentTabLocked(sess, 0)
891 sess.mu.Unlock()
892 original := target.tree.Focus
893
894 firstFrame := commandFrame(t, protocol.CommandRequest{
895 Slug: "split-right", Self: true, TargetSession: "work",
896 TargetTab: "t_invoking", TargetPane: "p_invoking",
897 })
898 firstTransport, firstSends, _ := newConn(t, firstFrame)
899 firstDone := make(chan struct{})
900 go func() {
901 if err := d.handleCommandFrame(firstTransport, firstFrame); err != nil {
902 t.Errorf("handle first command: %v", err)
903 }
904 close(firstDone)
905 }()
906 select {
907 case <-factory.entered:
908 case <-time.After(2 * time.Second):
909 t.Fatal("first command did not enter its blocked action")
910 }
911
912 secondFrame := commandFrame(t, protocol.CommandRequest{
913 Slug: "focus-pane-right", Self: true, TargetSession: "work",
914 TargetTab: "t_invoking", TargetPane: "p_invoking",
915 })
916 secondTransport, secondSends, _ := newConn(t, secondFrame)
917 secondDone := make(chan struct{})
918 go func() {
919 if err := d.handleCommandFrame(secondTransport, secondFrame); err != nil {
920 t.Errorf("handle second command: %v", err)
921 }
922 close(secondDone)
923 }()
924 select {
925 case <-secondDone:
926 t.Fatal("second command bypassed serialization")
927 case <-time.After(100 * time.Millisecond):
928 }
929 close(factory.release)
930 <-firstDone
931 <-secondDone
932 require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded)
933 require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded)
934
935 sess.mu.Lock()
936 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
937 sess.mu.Unlock()
938 target.mu.Lock()
939 defer target.mu.Unlock()
940 require.Len(t, target.panes, 2)
941 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
942}
943
944func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
945 d := newTestDaemon(t, nil, stubClock{})
946 source := addControlSession(d, "work", "t_work", "p_work")
947 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
948 tb := source.tabs[0]
949 tb.mu.Lock()
950 pane := tb.focusedPane()
951 tb.mu.Unlock()
952 ac := &attachedClient{tr: &closeTrackingTransport{}}
953 ac.setSession(source)
954
955 source.mu.Lock()
956 require.True(t, source.registerAttachmentLocked(ac))
957 target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
958 require.True(t, source.unregisterAttachmentLocked(ac))
959 source.mu.Unlock()
960
961 err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
962 require.ErrorIs(t, err, errMoveStaleTarget)
963}
964
965func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
966 d := newTestDaemon(t, nil, stubClock{})
967 source := addControlSession(d, "work", "t_active", "p_active")
968 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
969 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
970 publishTiledPaneOwners(source, inactive)
971 source.mu.Lock()
972 source.tabs = append(source.tabs, inactive)
973 selectTestAttachmentTabLocked(source, 0)
974 source.mu.Unlock()
975 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
976
977 result := sendCommand(t, d, protocol.CommandRequest{
978 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
979 })
980 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
981
982 source.mu.Lock()
983 require.Len(t, source.tabs, 1)
984 require.Equal(t, "t_inactive", source.tabs[0].stableID)
985 source.mu.Unlock()
986 destination.mu.Lock()
987 destTab := destination.tabs[0]
988 destination.mu.Unlock()
989 destTab.mu.Lock()
990 var moved *pane
991 for _, candidate := range destTab.panes {
992 if candidate.stableID == "p_active" {
993 moved = candidate
994 }
995 }
996 require.NotNil(t, moved)
997 destTab.mu.Unlock()
998}
999
1000// TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError proves a move
1001// command rejected because a transient KillAll purge owns the exact lifecycle
1002// set surfaces the same retryable internal outcome as the create path, instead
1003// of misreporting the destination as gone.
1004func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) {
1005 d := newTestDaemon(t, nil, stubClock{})
1006 addControlSession(d, "work", "t_work", "p_work")
1007 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1008
1009 beginTestPurgeAdmission(t, d)
1010 result := sendCommand(t, d, protocol.CommandRequest{
1011 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1012 })
1013 require.False(t, result.Outcome == protocol.CommandSucceeded)
1014 require.Equal(t, protocol.ErrInternal, result.Code)
1015 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
1016 d.endPurgeAdmission()
1017}
1018
1019func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
1020 d := newTestDaemon(t, nil, stubClock{})
1021 source := addControlSession(d, "work", "t_active", "p_active")
1022 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1023 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1024 publishTiledPaneOwners(source, inactive)
1025 source.mu.Lock()
1026 source.tabs = append(source.tabs, inactive)
1027 selectTestAttachmentTabLocked(source, 0)
1028 source.mu.Unlock()
1029 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1030
1031 result := sendCommand(t, d, protocol.CommandRequest{
1032 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1033 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
1034 })
1035 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1036
1037 active := source.tabs[0]
1038 active.mu.Lock()
1039 require.Len(t, active.panes, 1)
1040 require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
1041 active.mu.Unlock()
1042 destination.mu.Lock()
1043 destTab := destination.tabs[0]
1044 destination.mu.Unlock()
1045 destTab.mu.Lock()
1046 var moved *pane
1047 for _, candidate := range destTab.panes {
1048 if candidate.stableID == "p_inactive" {
1049 moved = candidate
1050 }
1051 }
1052 require.NotNil(t, moved)
1053 destTab.mu.Unlock()
1054}
1055
1056func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
1057 d := newTestDaemon(t, nil, stubClock{})
1058 source := addControlSession(d, "work", "t_first", "p_first")
1059 publishTiledPaneOwners(source, source.tabs[0])
1060 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1061 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1062 publishTiledPaneOwners(source, second)
1063 source.mu.Lock()
1064 source.tabs = append(source.tabs, second)
1065 selectTestAttachmentTabLocked(source, 0)
1066 source.mu.Unlock()
1067 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1068
1069 result := sendCommand(t, d, protocol.CommandRequest{
1070 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1071 TargetTab: "t_second", TargetPane: "p_second",
1072 })
1073 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1074
1075 source.mu.Lock()
1076 require.Len(t, source.tabs, 1)
1077 require.Equal(t, "t_second", source.tabs[0].stableID)
1078 source.mu.Unlock()
1079 destination.mu.Lock()
1080 require.Len(t, destination.tabs, 2)
1081 require.Equal(t, "t_first", destination.tabs[1].stableID)
1082 destination.mu.Unlock()
1083}
1084
1085func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1086 d := newTestDaemon(t, nil, stubClock{})
1087 source := addControlSession(d, "work", "t_first", "p_first")
1088 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1089 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1090 publishTiledPaneOwners(source, second)
1091 source.mu.Lock()
1092 source.tabs = append(source.tabs, second)
1093 selectTestAttachmentTabLocked(source, 0)
1094 source.mu.Unlock()
1095 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1096
1097 result := sendCommand(t, d, protocol.CommandRequest{
1098 Slug: "move-tab", Args: []string{"dest"}, Self: true,
1099 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1100 })
1101 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1102
1103 source.mu.Lock()
1104 require.Len(t, source.tabs, 1)
1105 require.Equal(t, "t_first", source.tabs[0].stableID)
1106 source.mu.Unlock()
1107 destination.mu.Lock()
1108 require.Len(t, destination.tabs, 2)
1109 require.Equal(t, "t_second", destination.tabs[1].stableID)
1110 destination.mu.Unlock()
1111}
1112
1113func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1114 tests := []struct {
1115 name string
1116 arrange func(*Daemon) protocol.CommandRequest
1117 code uint16
1118 text string
1119 }{
1120 {
1121 name: "move-pane missing destination session",
1122 arrange: func(d *Daemon) protocol.CommandRequest {
1123 addControlSession(d, "work", "t_work", "p_work")
1124 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1125 },
1126 code: protocol.ErrNoSuchTarget,
1127 text: "move request is invalid",
1128 },
1129 {
1130 name: "move-pane unknown destination session",
1131 arrange: func(d *Daemon) protocol.CommandRequest {
1132 addControlSession(d, "work", "t_work", "p_work")
1133 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1134 },
1135 code: protocol.ErrNoSuchTarget,
1136 text: "move target is no longer available",
1137 },
1138 {
1139 name: "move-pane destination tab not in session",
1140 arrange: func(d *Daemon) protocol.CommandRequest {
1141 addControlSession(d, "work", "t_work", "p_work")
1142 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1143 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1144 },
1145 code: protocol.ErrNoSuchTarget,
1146 text: "move target is no longer available",
1147 },
1148 {
1149 name: "move-pane same source tab",
1150 arrange: func(d *Daemon) protocol.CommandRequest {
1151 addControlSession(d, "work", "t_work", "p_work")
1152 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1153 },
1154 code: protocol.ErrNoSuchTarget,
1155 text: "move request is invalid",
1156 },
1157 {
1158 name: "move-tab same session",
1159 arrange: func(d *Daemon) protocol.CommandRequest {
1160 addControlSession(d, "work", "t_work", "p_work")
1161 return protocol.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1162 },
1163 code: protocol.ErrNoSuchTarget,
1164 text: "move request is invalid",
1165 },
1166 {
1167 name: "move-tab stopping destination",
1168 arrange: func(d *Daemon) protocol.CommandRequest {
1169 addControlSession(d, "work", "t_work", "p_work")
1170 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1171 destination.teardownMu.Lock()
1172 destination.teardownActive = true
1173 destination.teardownMu.Unlock()
1174 return protocol.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1175 },
1176 code: protocol.ErrNoSuchTarget,
1177 text: "move target is no longer available",
1178 },
1179 {
1180 name: "move-pane too few args",
1181 arrange: func(d *Daemon) protocol.CommandRequest {
1182 addControlSession(d, "work", "t_work", "p_work")
1183 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1184 },
1185 code: protocol.ErrInvalidCommandArgs,
1186 },
1187 {
1188 name: "move-pane too many args",
1189 arrange: func(d *Daemon) protocol.CommandRequest {
1190 addControlSession(d, "work", "t_work", "p_work")
1191 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1192 },
1193 code: protocol.ErrInvalidCommandArgs,
1194 },
1195 {
1196 name: "move-tab missing destination",
1197 arrange: func(d *Daemon) protocol.CommandRequest {
1198 addControlSession(d, "work", "t_work", "p_work")
1199 return protocol.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1200 },
1201 code: protocol.ErrInvalidCommandArgs,
1202 },
1203 {
1204 name: "explicit session name without IDs remains authoritative",
1205 arrange: func(d *Daemon) protocol.CommandRequest {
1206 addControlSession(d, "renamed", "t_work", "p_work")
1207 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1208 return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1209 },
1210 code: protocol.ErrNoSuchTarget,
1211 text: "no such session: old-name",
1212 },
1213 }
1214
1215 for _, tt := range tests {
1216 t.Run(tt.name, func(t *testing.T) {
1217 d := newTestDaemon(t, nil, stubClock{})
1218 request := tt.arrange(d)
1219 result := sendCommand(t, d, request)
1220 require.False(t, result.Outcome == protocol.CommandSucceeded)
1221 require.Equal(t, tt.code, result.Code)
1222 if tt.text != "" {
1223 require.Equal(t, tt.text, result.Text)
1224 }
1225 })
1226 }
1227}
1228
1229func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1230 d := newTestDaemon(t, nil, stubClock{})
1231 advisory := addControlSession(d, "old-name", "t_other", "p_other")
1232 source := addControlSession(d, "renamed", "t_work", "p_work")
1233 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1234
1235 result := sendCommand(t, d, protocol.CommandRequest{
1236 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1237 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1238 })
1239 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1240
1241 source.mu.Lock()
1242 require.Len(t, source.tabs, 0)
1243 source.mu.Unlock()
1244 advisory.mu.Lock()
1245 require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1246 advisory.mu.Unlock()
1247 destination.mu.Lock()
1248 destTab := destination.tabs[0]
1249 destination.mu.Unlock()
1250 destTab.mu.Lock()
1251 require.Len(t, destTab.panes, 2)
1252 destTab.mu.Unlock()
1253}
1254
1255func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1256 d := newTestDaemon(t, nil, stubClock{})
1257 source := addControlSession(d, "renamed", "t_active", "p_active")
1258 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1259 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1260 publishTiledPaneOwners(source, inactive)
1261 source.mu.Lock()
1262 source.tabs = append(source.tabs, inactive)
1263 selectTestAttachmentTabLocked(source, 1)
1264 source.mu.Unlock()
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_inactive", TargetPane: "p_inactive",
1270 })
1271 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1272
1273 source.mu.Lock()
1274 require.Len(t, source.tabs, 1)
1275 require.Equal(t, "t_active", source.tabs[0].stableID)
1276 source.mu.Unlock()
1277 destination.mu.Lock()
1278 destTab := destination.tabs[0]
1279 destination.mu.Unlock()
1280 destTab.mu.Lock()
1281 var moved *pane
1282 for _, candidate := range destTab.panes {
1283 if candidate.stableID == "p_inactive" {
1284 moved = candidate
1285 }
1286 }
1287 require.NotNil(t, moved)
1288 destTab.mu.Unlock()
1289}
1290
1291func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1292 d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1293 left.mu.Lock()
1294 left.name = "left"
1295 left.tabs[0].stableID = "left-moved"
1296 selectTestAttachmentTabLocked(left, 0)
1297 left.mu.Unlock()
1298
1299 right := addMoveTabTestSession(d, "right", "right-stays")
1300 rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1301 rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1302 publishTiledPaneOwners(right, rightMoved)
1303 right.mu.Lock()
1304 right.tabs = append(right.tabs, rightMoved)
1305 selectTestAttachmentTabLocked(right, 1)
1306 right.mu.Unlock()
1307
1308 leftFrame := commandFrame(t, protocol.CommandRequest{
1309 Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1310 })
1311 rightFrame := commandFrame(t, protocol.CommandRequest{
1312 Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1313 })
1314 leftTransport, leftSends, _ := newConn(t, leftFrame)
1315 rightTransport, rightSends, _ := newConn(t, rightFrame)
1316 start := make(chan struct{})
1317 done := make(chan struct{}, 2)
1318 go func() {
1319 <-start
1320 if err := d.handleCommandFrame(leftTransport, leftFrame); err != nil {
1321 t.Errorf("handle left command: %v", err)
1322 }
1323 done <- struct{}{}
1324 }()
1325 go func() {
1326 <-start
1327 if err := d.handleCommandFrame(rightTransport, rightFrame); err != nil {
1328 t.Errorf("handle right command: %v", err)
1329 }
1330 done <- struct{}{}
1331 }()
1332 close(start)
1333 for _, sends := range []chan wire.Envelope{leftSends, rightSends} {
1334 select {
1335 case <-done:
1336 result := awaitCommandResult(t, sends)
1337 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1338 case <-time.After(5 * time.Second):
1339 t.Fatal("opposite-direction move commands deadlocked")
1340 }
1341 }
1342}
1343
1344func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1345 ctx, cancel := context.WithCancel(d.serveCtx)
1346 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1347 tb.ctx, tb.cancel = context.WithCancel(ctx)
1348 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1349 ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1350 }
1351 publishTiledPaneOwners(sess, tb)
1352 d.mu.Lock()
1353 d.sessions[sess.id] = sess
1354 d.mu.Unlock()
1355 return sess
1356}
1357
1358func commandFrame(t *testing.T, request protocol.CommandRequest) wire.Envelope {
1359 t.Helper()
1360 if request.Version == 0 {
1361 request.Version = protocol.Version
1362 }
1363 return mustClientEnvelope(request)
1364}
1365
1366func sendCommand(t *testing.T, d *Daemon, request protocol.CommandRequest) protocol.CommandResult {
1367 t.Helper()
1368 frame := commandFrame(t, request)
1369 tr, sends, _ := newConn(t, frame)
1370 require.NoError(t, d.handleCommandFrame(tr, frame))
1371 return awaitCommandResult(t, sends)
1372}
1373
1374func awaitCommandResult(t *testing.T, sends chan wire.Envelope) protocol.CommandResult {
1375 t.Helper()
1376 reply := awaitFrame(t, sends, "CommandResult")
1377 return decodeServerMessage(t, reply).(protocol.CommandResult)
1378}
1379
1380type controlPTYFactory struct {
1381 mu sync.Mutex
1382 ptys []*quietPTY
1383 entered chan struct{}
1384 release chan struct{}
1385 once sync.Once
1386}
1387
1388func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (appports.PTY, error) {
1389 f.once.Do(func() {
1390 if f.entered != nil {
1391 close(f.entered)
1392 <-f.release
1393 }
1394 })
1395 pty := newQuietPTY()
1396 f.mu.Lock()
1397 f.ptys = append(f.ptys, pty)
1398 f.mu.Unlock()
1399 return pty, nil
1400}
1401
1402func (f *controlPTYFactory) close() {
1403 if f.release != nil {
1404 select {
1405 case <-f.release:
1406 default:
1407 close(f.release)
1408 }
1409 }
1410 f.mu.Lock()
1411 defer f.mu.Unlock()
1412 for _, pty := range f.ptys {
1413 _ = pty.Close()
1414 }
1415}
1416
1417type blockingControlSendTransport struct {
1418 started chan struct{}
1419 closed chan struct{}
1420 once sync.Once
1421}
1422
1423func newBlockingControlSendTransport() *blockingControlSendTransport {
1424 return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1425}
1426
1427func (tr *blockingControlSendTransport) Send(wire.Envelope) error {
1428 tr.once.Do(func() { close(tr.started) })
1429 <-tr.closed
1430 return io.ErrClosedPipe
1431}
1432
1433func (*blockingControlSendTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1434func (tr *blockingControlSendTransport) Close() error {
1435 tr.once.Do(func() { close(tr.started) })
1436 select {
1437 case <-tr.closed:
1438 default:
1439 close(tr.closed)
1440 }
1441 return nil
1442}
1443
1444type commandSendErrorTransport struct {
1445 err error
1446 closed bool
1447}
1448
1449func (tr *commandSendErrorTransport) Send(wire.Envelope) error { return tr.err }
1450func (*commandSendErrorTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1451func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil }
1452
1453func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1454 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1455 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1456 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1457 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1458 }
1459 sess.mruAt.Store(1)
1460 d.mu.Lock()
1461 d.sessions[sess.id] = sess
1462 d.mu.Unlock()
1463 return sess
1464}
1465
1466// TestHandleKillSessionParticipantsChangedReportsRetryable proves a direct
1467// session kill whose teardown snapshot a concurrent token resume invalidated
1468// reports a clear retryable outcome instead of a permanent delete failure, and
1469// leaves the session live.
1470func TestHandleKillSessionParticipantsChangedReportsRetryable(t *testing.T) {
1471 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1472
1473 d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1474 if action != "" {
1475 return
1476 }
1477 d.afterAttachmentEffectParticipantsSnapshotted = nil
1478 // A token resume replaces the exact transport after the participant
1479 // snapshot was taken, so the kill aborts before removing anything.
1480 fresh := &closeTrackingTransport{}
1481 ac.replaceTransport(fresh)
1482 ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1483 }
1484 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1485
1486 tr, sends := newCapturingTransport(t)
1487 d.handleKill(tr, protocol.Kill{RequestID: 1, Scope: protocol.KillSession, Name: "work"})
1488
1489 frame := awaitFrame(t, sends, "KillResult")
1490 result, ok := decodeServerMessage(t, frame).(protocol.KillResult)
1491 require.True(t, ok)
1492 require.Equal(t, uint64(1), result.RequestID)
1493 require.Equal(t, protocol.KillFailed, result.Outcome)
1494 require.Equal(t, protocol.ErrInternal, result.Code)
1495 require.Equal(t, sessionKillRetryMessage, result.Text)
1496 require.Equal(t, 1, sessionCount(d), "an aborted direct kill must leave the session live")
1497 require.Same(t, sess, firstSession(d))
1498}
1499
1500// TestCloseTabParticipantsChangedReportsRetryable proves a last-tab close whose
1501// teardown snapshot a concurrent token resume invalidated reports a clear
1502// retryable outcome to both the command result and the attached client, and
1503// leaves the session and its tab live.
1504func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
1505 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1506
1507 d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1508 if action != "" {
1509 return
1510 }
1511 d.afterAttachmentEffectParticipantsSnapshotted = nil
1512 fresh := &closeTrackingTransport{}
1513 ac.replaceTransport(fresh)
1514 ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1515 }
1516 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1517
1518 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
1519 require.False(t, result.Outcome == protocol.CommandSucceeded)
1520 require.Equal(t, protocol.ErrInternal, result.Code)
1521 require.Equal(t, sessionKillRetryMessage, result.Text)
1522
1523 toasts := awaitToastCount(t, ac, 1)
1524 require.Contains(t, toasts[0].Message, "couldn't close tab")
1525 require.Contains(t, toasts[0].Message, "try again")
1526
1527 sess.mu.Lock()
1528 tabs := len(sess.tabs)
1529 sess.mu.Unlock()
1530 require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")
1531 require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")
1532}
1533
1534func TestDispatchActionLifecycle(t *testing.T) {
1535 boom := errors.New("boom")
1536 tests := []struct {
1537 name string
1538 runErr error
1539 serialize bool
1540 // callerHolds enters dispatchAction already inside the dispatch
1541 // boundary, as the palette and control paths do.
1542 callerHolds bool
1543 wantChanged bool
1544 wantErr error
1545 wantRender bool
1546 }{
1547 {name: "change renders the attachment", wantChanged: true, wantRender: true},
1548 {name: "no change is success without render", runErr: errDaemonActionNoChange},
1549 {name: "failure skips render", runErr: boom, wantErr: boom},
1550 {name: "serialized change holds dispatch boundary", serialize: true, wantChanged: true, wantRender: true},
1551 {name: "caller-held boundary is reused and still renders", callerHolds: true, wantChanged: true, wantRender: true},
1552 }
1553 for _, tt := range tests {
1554 t.Run(tt.name, func(t *testing.T) {
1555 d := newTestDaemon(t, nil, stubClock{})
1556 sess := addControlSession(d, "work", "t_work", "p_work")
1557 ac := &attachedClient{}
1558 ac.setSession(sess)
1559 sess.mu.Lock()
1560 sess.registerAttachmentLocked(ac)
1561 sess.mu.Unlock()
1562 invalidations := make(chan renderInvalidation, 2)
1563 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
1564 rc.attach(ac)
1565 sess.installRenderCoordinator(rc)
1566
1567 runner := newMockdaemonActionRunner(t)
1568 runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
1569 require.Same(t, sess, request.target.session, "unset target resolves from the attachment")
1570 require.Same(t, ac, request.target.attachment)
1571 held := !sess.dispatchMu.TryLock()
1572 if !held {
1573 sess.dispatchMu.Unlock()
1574 }
1575 require.Equal(t, tt.serialize || tt.callerHolds, held, "dispatch boundary ownership")
1576 return tt.runErr
1577 }).Once()
1578 x := daemonActionDispatch{runner: runner, request: daemonActionRequest{kind: daemonActionEqualizePanes}, resolveFrom: sess, attachment: ac, producer: "test"}
1579 if tt.serialize {
1580 x.serialize = sess
1581 }
1582
1583 if tt.callerHolds {
1584 sess.dispatchMu.Lock()
1585 }
1586 changed, err := d.dispatchAction(x)
1587 if tt.callerHolds {
1588 sess.dispatchMu.Unlock()
1589 }
1590
1591 require.ErrorIs(t, err, tt.wantErr)
1592 require.NotErrorIs(t, err, errDaemonActionNoChange)
1593 require.Equal(t, tt.wantChanged, changed)
1594 require.True(t, sess.dispatchMu.TryLock(), "dispatchAction must not leave the boundary held")
1595 sess.dispatchMu.Unlock()
1596 if tt.wantRender {
1597 require.Equal(t, "test", awaitInvalidation(t, invalidations).producer)
1598 } else {
1599 requireNoInvalidation(t, invalidations)
1600 }
1601 })
1602 }
1603}