1package daemon
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "log/slog"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/usecase/command"
19 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/stretchr/testify/require"
21)
22
23func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
24 tests := []struct {
25 name string
26 palette func(paletteExec) error
27 control func(controlExec) error
28 kind daemonActionKind
29 direction layout.Direction
30 }{
31 {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
32 {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},
33 {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},
34 }
35
36 for _, tt := range tests {
37 t.Run(tt.name, func(t *testing.T) {
38 d := newTestDaemon(t, nil, stubClock{})
39 sess := addControlSession(d, "work", "t_work", "p_work")
40 tb := testAttachmentTab(sess)
41 tb.mu.Lock()
42 pane := tb.focusedPane()
43 tb.mu.Unlock()
44 spy := &actionRunnerSpy{}
45 target := daemonActionTarget{session: sess, tab: tb, pane: pane}
46
47 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
48 require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
49
50 require.Len(t, spy.requests, 2)
51 for _, request := range spy.requests {
52 require.Equal(t, tt.kind, request.kind)
53 require.Same(t, sess, request.target.session)
54 require.Same(t, tb, request.target.tab)
55 require.Same(t, pane, request.target.pane)
56 require.Equal(t, tt.direction, request.direction)
57 }
58 })
59 }
60}
61
62func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
63 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
64 h.tab.mu.Lock()
65 h.tab.tree.Focus = "pane-1"
66 beforeGeneration := h.tab.layoutGeneration
67 tabStableID := h.tab.stableID
68 targetStableID := h.panes["pane-3"].stableID
69 h.tab.mu.Unlock()
70
71 result := sendCommand(t, h.daemon, ports.CommandRequest{
72 Slug: "consume-or-expel-pane-left",
73 Self: true,
74 TargetSession: "work",
75 TargetTab: tabStableID,
76 TargetPane: targetStableID,
77 })
78
79 require.True(t, result.OK, result.Text)
80 require.Empty(t, result.Output)
81 require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
82 h.tab.mu.Lock()
83 require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
84 require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
85 require.Len(t, h.tab.tree.Root.Children, 2)
86 h.tab.mu.Unlock()
87}
88
89func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
90 h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
91 ac := &attachedClient{}
92 ac.setSession(h.session)
93 h.session.mu.Lock()
94 h.session.registerAttachmentLocked(ac)
95 h.session.mu.Unlock()
96 invalidations := make(chan renderInvalidation, 1)
97 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
98 rc.attach(ac)
99 h.session.installRenderCoordinator(rc)
100 before := h.snapshot()
101
102 result := sendCommand(t, h.daemon, ports.CommandRequest{
103 Slug: "consume-or-expel-pane-left",
104 Self: true,
105 TargetSession: "work",
106 TargetTab: h.tab.stableID,
107 TargetPane: h.panes["pane-1"].stableID,
108 })
109
110 require.True(t, result.OK, result.Text)
111 require.Empty(t, result.Output)
112 require.Equal(t, before, h.snapshot())
113 require.Empty(t, h.daemon.notices.history())
114 requireNoInvalidation(t, invalidations)
115}
116
117type actionRunnerSpy struct {
118 requests []daemonActionRequest
119 err error
120}
121
122func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
123 s.requests = append(s.requests, request)
124 return s.err
125}
126
127func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
128 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
129 clock := &signalClock{timers: make(chan *signalTimer, 1)}
130 d := newTestDaemon(t, factory, clock)
131 addControlSession(d, "work", "t_work", "p_work")
132 frame := commandFrame(t, ports.CommandRequest{
133 RequestID: 17, Slug: "new-tab", TargetSession: "work",
134 })
135 tr, sends, releaseConn := newConn(t, frame)
136 defer releaseConn()
137 done := make(chan error, 1)
138 go func() {
139 done <- d.handleCommand(tr, frame)
140 }()
141 <-factory.entered
142 timer := <-clock.timers
143 require.Equal(t, CommandRequestTimeout, timer.duration)
144 timer.ch <- time.Time{}
145 result := awaitCommandResult(t, sends)
146 require.Equal(t, uint64(17), result.RequestID)
147 require.Equal(t, ports.ErrInternal, result.Code)
148 require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
149 require.NoError(t, <-done)
150 close(factory.release)
151}
152
153func TestHandleCommandResponseSendTimeoutClosesTransport(t *testing.T) {
154 clock := &signalClock{timers: make(chan *signalTimer, 1)}
155 d := newTestDaemon(t, nil, clock)
156 tr := newBlockingControlSendTransport()
157 frame := commandFrame(t, ports.CommandRequest{RequestID: 41, Attached: true})
158 done := make(chan error, 1)
159 go func() { done <- d.handleCommand(tr, frame) }()
160
161 <-tr.started
162 timer := <-clock.timers
163 timer.ch <- time.Time{}
164 select {
165 case err := <-done:
166 require.ErrorIs(t, err, errSendTimedOut)
167 case <-time.After(testWaitTimeout):
168 t.Fatal("bounded control response did not return")
169 }
170 select {
171 case <-tr.closed:
172 default:
173 t.Fatal("timed-out control response did not close its transport")
174 }
175}
176
177func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
178 d := newTestDaemon(t, nil, stubClock{})
179 var logs bytes.Buffer
180 d.log = slog.New(slog.NewTextHandler(&logs, nil))
181 sendErr := errors.New("command result send failed")
182 tr := &commandSendErrorTransport{err: sendErr}
183
184 err := d.handleCommand(tr, commandFrame(t, ports.CommandRequest{
185 RequestID: 41,
186 Attached: true,
187 }))
188
189 require.ErrorIs(t, err, sendErr)
190 require.Contains(t, logs.String(), "command response send failed")
191 require.True(t, tr.closed, "the failed one-shot transport must still close")
192}
193
194func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
195 // The version prefix is valid and mismatched, while the remainder is not a
196 // decodable request. The daemon must still return ErrVersionMismatch.
197 frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}}
198 tr, sends, _ := newConn(t, frame)
199
200 d := newTestDaemon(t, nil, stubClock{})
201 require.NoError(t, d.handleCommand(tr, frame))
202
203 result := awaitCommandResult(t, sends)
204 require.False(t, result.OK)
205 require.Equal(t, ports.ErrVersionMismatch, result.Code)
206}
207
208func TestHandleConnRoutesCommand(t *testing.T) {
209 d := newTestDaemon(t, nil, stubClock{})
210 request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"}
211 payload, err := ports.MarshalCommandRequest(request)
212 require.NoError(t, err)
213 frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
214 tr, sends, _ := newConn(t, frame)
215
216 d.handleConn(tr)
217
218 result := awaitCommandResult(t, sends)
219 require.True(t, result.OK, result.Text)
220}
221
222func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
223 tests := []struct {
224 name string
225 arrange func(*Daemon)
226 request ports.CommandRequest
227 code uint16
228 }{
229 {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand},
230 {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable},
231 {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget},
232 {name: "ambiguous sessions", arrange: func(d *Daemon) {
233 addControlSession(d, "one", "t_one", "p_one")
234 addControlSession(d, "two", "t_two", "p_two")
235 }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget},
236 {name: "missing explicit session", arrange: func(d *Daemon) {
237 addControlSession(d, "work", "t_work", "p_work")
238 }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget},
239 {name: "missing stable IDs", arrange: func(d *Daemon) {
240 addControlSession(d, "work", "t_work", "p_work")
241 }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
242 {name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) {
243 addControlSession(d, "one", "t_shared", "p_shared")
244 addControlSession(d, "two", "t_shared", "p_shared")
245 }, request: ports.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: ports.ErrAmbiguousTarget},
246 {name: "self requires both IDs", arrange: func(d *Daemon) {
247 addControlSession(d, "work", "t_work", "p_work")
248 }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget},
249 {name: "self rejects pane from another tab", arrange: func(d *Daemon) {
250 sess := addControlSession(d, "work", "t_work", "p_work")
251 other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
252 other.ctx, other.cancel = context.WithCancel(d.serveCtx)
253 sess.tabs = append(sess.tabs, other)
254 }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget},
255 }
256
257 for _, tt := range tests {
258 t.Run(tt.name, func(t *testing.T) {
259 d := newTestDaemon(t, nil, stubClock{})
260 if tt.arrange != nil {
261 tt.arrange(d)
262 }
263 result := sendCommand(t, d, tt.request)
264 require.False(t, result.OK)
265 require.Equal(t, tt.code, result.Code)
266 })
267 }
268}
269
270func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
271 d := newTestDaemon(t, nil, stubClock{})
272 sess := addControlSession(d, "renamed", "t_work", "p_work")
273
274 result := sendCommand(t, d, ports.CommandRequest{
275 Slug: "toast", Args: []string{"hello"},
276 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
277 })
278
279 require.True(t, result.OK, result.Text)
280 require.Equal(t, uint64(1), sess.mruAt.Load())
281}
282
283func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
284 d := newTestDaemon(t, nil, stubClock{})
285 sess := addControlSession(d, "work", "t_first", "p_first")
286 first := sess.tabs[0]
287 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
288 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
289 sess.mu.Lock()
290 sess.tabs = append(sess.tabs, second)
291 selectTestAttachmentTabLocked(sess, 0)
292 sess.mu.Unlock()
293
294 result := sendCommand(t, d, ports.CommandRequest{
295 Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
296 TargetTab: "t_second", TargetPane: "p_second",
297 })
298
299 require.True(t, result.OK, result.Text)
300 first.mu.Lock()
301 require.Equal(t, "targeted", first.name)
302 first.mu.Unlock()
303 second.mu.Lock()
304 require.Empty(t, second.name)
305 second.mu.Unlock()
306 sess.mu.Lock()
307 require.Zero(t, testAttachmentTabIndexLocked(sess))
308 sess.mu.Unlock()
309}
310
311func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
312 factory := &controlPTYFactory{}
313 d := newTestDaemon(t, factory, stubClock{})
314 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
315 sess := addControlSession(d, "work", "t_active", "p_active")
316 active := sess.tabs[0]
317 originFocus := active.tree.Focus
318 origin := active.focusedPane()
319 second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
320 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
321 sess.mu.Lock()
322 sess.tabs = append(sess.tabs, second)
323 selectTestAttachmentTabLocked(sess, 0)
324 sess.mu.Unlock()
325
326 result := sendCommand(t, d, ports.CommandRequest{
327 Slug: "split-right", TargetSession: "work",
328 TargetTab: "t_origin", TargetPane: "p_origin",
329 })
330
331 require.True(t, result.OK, result.Text)
332 sess.mu.Lock()
333 activeIndex := testAttachmentTabIndexLocked(sess)
334 sess.mu.Unlock()
335 require.Zero(t, activeIndex)
336 active.mu.Lock()
337 activePaneCount := len(active.panes)
338 activeFocus := active.tree.Focus
339 originRetained := active.panes[origin.id] == origin
340 active.mu.Unlock()
341 require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
342 require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
343 require.True(t, originRetained)
344 second.mu.Lock()
345 secondPaneCount := len(second.panes)
346 second.mu.Unlock()
347 require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
348}
349
350func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
351 factory := &controlPTYFactory{}
352 d := newTestDaemon(t, factory, stubClock{})
353 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
354 sess := addControlSession(d, "work", "t_active", "p_active")
355 active := sess.tabs[0]
356 invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
357 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
358 sess.mu.Lock()
359 sess.tabs = append(sess.tabs, invoking)
360 selectTestAttachmentTabLocked(sess, 0)
361 sess.mu.Unlock()
362
363 rename := sendCommand(t, d, ports.CommandRequest{
364 Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
365 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
366 })
367 require.True(t, rename.OK, rename.Text)
368 active.mu.Lock()
369 require.Empty(t, active.name)
370 active.mu.Unlock()
371 invoking.mu.Lock()
372 require.Equal(t, "invoking", invoking.name)
373 invoking.mu.Unlock()
374
375 split := sendCommand(t, d, ports.CommandRequest{
376 Slug: "split-right", Self: true,
377 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
378 })
379 require.True(t, split.OK, split.Text)
380 active.mu.Lock()
381 require.Len(t, active.panes, 1)
382 active.mu.Unlock()
383 invoking.mu.Lock()
384 require.Len(t, invoking.panes, 2)
385 beforeFocus := invoking.tree.Focus
386 beforeGeneration := invoking.layoutGeneration
387 invoking.mu.Unlock()
388
389 grow := sendCommand(t, d, ports.CommandRequest{
390 Slug: "grow-pane-width", Self: true,
391 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
392 })
393 require.True(t, grow.OK, grow.Text)
394 invoking.mu.Lock()
395 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
396 require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
397 require.NotZero(t, invoking.tree.Root.Children[0].Weight, "resize must update the invoking tab's shares")
398 invoking.mu.Unlock()
399 active.mu.Lock()
400 require.Len(t, active.panes, 1, "--self resize must not mutate the active tab")
401 active.mu.Unlock()
402
403 equalize := sendCommand(t, d, ports.CommandRequest{
404 Slug: "equalize-panes", Self: true,
405 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
406 })
407 require.True(t, equalize.OK, equalize.Text)
408 invoking.mu.Lock()
409 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
410 for _, child := range invoking.tree.Root.Children {
411 require.Zero(t, child.Weight, "equalize must clear each target-tab share")
412 }
413 invoking.mu.Unlock()
414}
415
416func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
417 targets := []struct {
418 name string
419 request func() ports.CommandRequest
420 }{
421 {"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }},
422 {"unique", func() ports.CommandRequest { return ports.CommandRequest{} }},
423 }
424 for _, target := range targets {
425 t.Run(target.name, func(t *testing.T) {
426 for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
427 t.Run(slug, func(t *testing.T) {
428 factory := &controlPTYFactory{}
429 d := newTestDaemon(t, factory, stubClock{})
430 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
431 sess := addControlSession(d, "work", "t_work", "p_work")
432 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
433 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
434 tb := testAttachmentTab(sess)
435 tb.mu.Lock()
436 generation := tb.layoutGeneration
437 tb.mu.Unlock()
438 sess.snapEligible.Store(true)
439 sess.snapshotMu.Lock()
440 snapshotGeneration := sess.snapshotGeneration
441 sess.snapshotMu.Unlock()
442 request := target.request()
443 request.Slug = slug
444 result := sendCommand(t, d, request)
445 require.True(t, result.OK, result.Text)
446 require.Empty(t, result.Output)
447 tb.mu.Lock()
448 require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
449 tb.mu.Unlock()
450 sess.snapshotMu.Lock()
451 require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
452 sess.snapshotMu.Unlock()
453 sess.mu.Lock()
454 require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless")
455 sess.mu.Unlock()
456 })
457 }
458 })
459 }
460}
461
462func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
463 factory := &controlPTYFactory{}
464 d := newTestDaemon(t, factory, stubClock{})
465 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
466 sess := addControlSession(d, "work", "t_active", "p_active")
467 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
468 active := sess.tabs[0]
469 locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
470 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
471 sess.mu.Lock()
472 sess.tabs = append(sess.tabs, locator)
473 selectTestAttachmentTabLocked(sess, 0)
474 sess.mu.Unlock()
475 active.mu.Lock()
476 before := active.layoutGeneration
477 active.mu.Unlock()
478
479 result := sendCommand(t, d, ports.CommandRequest{
480 Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
481 })
482 require.True(t, result.OK, result.Text)
483 active.mu.Lock()
484 require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
485 active.mu.Unlock()
486 locator.mu.Lock()
487 require.Zero(t, locator.layoutGeneration)
488 locator.mu.Unlock()
489 sess.mu.Lock()
490 require.Zero(t, testAttachmentTabIndexLocked(sess))
491 sess.mu.Unlock()
492}
493
494func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
495 factory := &controlPTYFactory{}
496 d := newTestDaemon(t, factory, stubClock{})
497 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
498 sess := addControlSession(d, "work", "t_work", "p_work")
499 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
500 tb := testAttachmentTab(sess)
501 tb.mu.Lock()
502 tb.size.Cols = 41
503 tb.bumpLayoutGenerationLocked()
504 tb.mu.Unlock()
505
506 result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
507 require.False(t, result.OK)
508 require.Equal(t, ports.ErrNoSuchTarget, result.Code)
509 require.Equal(t, "pane cannot be resized further", result.Text)
510}
511
512func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
513 d := newTestDaemon(t, nil, stubClock{})
514 sess := addControlSession(d, "work", "t_active", "p_active")
515 invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
516 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
517 sess.mu.Lock()
518 sess.tabs = append(sess.tabs, invoking)
519 selectTestAttachmentTabLocked(sess, 0)
520 sess.mu.Unlock()
521
522 result := sendCommand(t, d, ports.CommandRequest{
523 Slug: "list-panes", Self: true, JSON: true,
524 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
525 })
526 require.True(t, result.OK, result.Text)
527 require.Contains(t, result.Output, "p_invoking")
528 require.NotContains(t, result.Output, "p_active")
529}
530
531func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
532 d := newTestDaemon(t, nil, stubClock{})
533 addControlSession(d, "work", "t_work", "p_work")
534
535 result := sendCommand(t, d, ports.CommandRequest{
536 Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
537 })
538
539 require.False(t, result.OK)
540 require.Equal(t, ports.ErrInvalidCommandArgs, result.Code)
541}
542
543func TestCloseCommandsReportMutationOutcome(t *testing.T) {
544 t.Run("stale close tab target fails", func(t *testing.T) {
545 d := newTestDaemon(t, nil, stubClock{})
546 sess := addControlSession(d, "work", "t_work", "p_work")
547 stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
548 cmd, ok := command.BySlug("close-tab")
549 require.True(t, ok)
550
551 result := d.runControl(cmd, controlExec{
552 d: d, sess: sess, tab: stale,
553 target: daemonActionTarget{session: sess, tab: stale},
554 }, ports.CommandRequest{Slug: "close-tab"})
555
556 require.False(t, result.OK)
557 require.Equal(t, ports.ErrInternal, result.Code)
558 require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
559 })
560
561 t.Run("failed final pane close fails and retains session", func(t *testing.T) {
562 d := newTestDaemon(t, &controlPTYFactory{}, stubClock{})
563 d.snapsEnabled = true
564 d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t)
565 sess := addControlSession(d, "work", "t_work", "p_work")
566 sess.mu.Lock()
567 sess.ephemeral = false
568 sess.mu.Unlock()
569 sess.snapEligible.Store(true)
570
571 result := sendCommand(t, d, ports.CommandRequest{Slug: "close-pane", TargetSession: "work"})
572
573 require.False(t, result.OK)
574 require.Equal(t, ports.ErrInternal, result.Code)
575 d.mu.Lock()
576 require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
577 d.mu.Unlock()
578 sess.mu.Lock()
579 require.Len(t, sess.tabs, 1)
580 sess.mu.Unlock()
581 })
582
583 t.Run("successful close tab remains successful", func(t *testing.T) {
584 d := newTestDaemon(t, nil, stubClock{})
585 sess := addControlSession(d, "work", "t_first", "p_first")
586 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
587 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
588 sess.mu.Lock()
589 sess.tabs = append(sess.tabs, second)
590 selectTestAttachmentTabLocked(sess, 1)
591 sess.mu.Unlock()
592
593 result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})
594
595 require.True(t, result.OK, result.Text)
596 sess.mu.Lock()
597 require.Len(t, sess.tabs, 1)
598 require.Equal(t, "t_second", sess.tabs[0].stableID)
599 sess.mu.Unlock()
600 })
601}
602
603func TestHandleCommandHeadlessMutations(t *testing.T) {
604 tests := []struct {
605 name string
606 slug string
607 args []string
608 verify func(*testing.T, *Daemon, *session)
609 }{
610 {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
611 tb := testAttachmentTab(sess)
612 tb.mu.Lock()
613 defer tb.mu.Unlock()
614 require.Len(t, tb.panes, 2)
615 }},
616 {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
617 sess.mu.Lock()
618 defer sess.mu.Unlock()
619 require.Len(t, sess.tabs, 2)
620 require.Equal(t, 0, testAttachmentTabIndexLocked(sess))
621 }},
622 {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
623 tb := testAttachmentTab(sess)
624 tb.mu.Lock()
625 defer tb.mu.Unlock()
626 require.Equal(t, "logs", tb.name)
627 }},
628 }
629 for _, tt := range tests {
630 t.Run(tt.name, func(t *testing.T) {
631 factory := &controlPTYFactory{}
632 d := newTestDaemon(t, factory, stubClock{})
633 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
634 sess := addControlSession(d, "work", "t_work", "p_work")
635
636 result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
637
638 require.True(t, result.OK, result.Text)
639 tt.verify(t, d, sess)
640 })
641 }
642}
643
644func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
645 factory := &controlPTYFactory{}
646 d := newTestDaemon(t, factory, stubClock{})
647 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
648 source := addControlSession(d, "work", "t_work", "p_work")
649 source.mu.Lock()
650 source.cwd = "/tmp/work"
651 source.env = []string{"INHERITED=yes"}
652 source.terminal = terminalEnv{TrueColor: true}
653 source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
654 source.mu.Unlock()
655
656 result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
657
658 require.True(t, result.OK, result.Text)
659 d.mu.Lock()
660 created := d.findByNameLocked("scripted")
661 d.mu.Unlock()
662 require.NotNil(t, created)
663 created.mu.Lock()
664 require.Empty(t, created.snapshotAttachmentsLocked())
665 require.Equal(t, "/tmp/work", created.cwd)
666 require.Equal(t, []string{"INHERITED=yes"}, created.env)
667 require.True(t, created.terminal.TrueColor)
668 tb := created.tabs[0]
669 created.mu.Unlock()
670 tb.mu.Lock()
671 require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
672 tb.mu.Unlock()
673 require.Empty(t, source.snapshotAttachments())
674
675 taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
676 require.False(t, taken.OK)
677 require.Equal(t, ports.ErrNameTaken, taken.Code)
678}
679
680func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
681 d := newTestDaemon(t, nil, stubClock{})
682 sess := addControlSession(d, "work", "t_work", "p_work")
683
684 bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
685 require.False(t, bad.OK)
686 require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code)
687
688 good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
689 require.True(t, good.OK, good.Text)
690 d.notices.mu.Lock()
691 require.Len(t, d.notices.pending, 1)
692 require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
693 require.Equal(t, sess.id, d.notices.pending[0].SessionID)
694 d.notices.mu.Unlock()
695}
696
697func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
698 d := newTestDaemon(t, nil, stubClock{})
699 sess := addControlSession(d, "work", "t_work", "p_work")
700 sess.tabs[0].name = "shell"
701
702 sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
703 require.True(t, sessions.OK, sessions.Text)
704 require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
705 require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
706
707 tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
708 require.True(t, tabs.OK, tabs.Text)
709 require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
710
711 panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
712 require.True(t, panes.OK, panes.Text)
713 var decoded []map[string]any
714 require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
715 require.Len(t, decoded, 1)
716 require.Equal(t, "p_work", decoded[0]["id"])
717 require.Equal(t, "/tmp/work", decoded[0]["cwd"])
718 require.Equal(t, true, decoded[0]["focused"])
719}
720
721func TestRemoteCatalogJSONOutput(t *testing.T) {
722 d := newTestDaemon(t, nil, stubClock{})
723 work := addControlSession(d, "work", "t_work", "p_work")
724 work.ephemeral = false
725 work.registerAttachment(&attachedClient{})
726 work.mruAt.Store(2)
727 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
728 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
729 work.mu.Lock()
730 work.tabs = append(work.tabs, tb)
731 work.mu.Unlock()
732
733 build := addControlSession(d, "build", "t_build", "p_build")
734 build.ephemeral = true
735 build.mruAt.Store(1)
736
737 d.mu.Lock()
738 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown}
739 d.mu.Unlock()
740
741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
742 require.True(t, listBefore.OK, listBefore.Text)
743
744 missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"})
745 require.False(t, missingJSON.OK)
746 require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code)
747
748 withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
749 require.False(t, withArgs.OK)
750 require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code)
751
752 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
753 require.True(t, result.OK, result.Text)
754 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
755
756 var catalog ports.RemoteCatalog
757 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
758 require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion)
759
760 raw := map[string]json.RawMessage{}
761 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
762 require.Len(t, raw, 2)
763 _, hasProtocol := raw["protocol_version"]
764 _, hasSessions := raw["sessions"]
765 require.True(t, hasProtocol && hasSessions)
766
767 require.Equal(t, []ports.RemoteCatalogSession{
768 {Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false},
769 {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
770 }, catalog.Sessions)
771
772 for _, session := range catalog.Sessions {
773 require.Equal(t, "up", session.State)
774 require.NotEqual(t, "old", session.Name)
775 }
776
777 listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
778 require.True(t, listAfter.OK, listAfter.Text)
779 require.Equal(t, listBefore.Output, listAfter.Output)
780 require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
781 require.NotContains(t, listAfter.Output, "old")
782}
783
784func TestRemoteCatalogTabCountSaturates(t *testing.T) {
785 d := newTestDaemon(t, nil, stubClock{})
786 sess := addControlSession(d, "work", "t_work", "p_work")
787 sess.mu.Lock()
788 base := sess.tabs[0]
789 sess.tabs = make([]*tab, int(^uint16(0))+1)
790 for i := range sess.tabs {
791 sess.tabs[i] = base
792 }
793 sess.mu.Unlock()
794
795 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
796 require.True(t, result.OK, result.Text)
797 var catalog ports.RemoteCatalog
798 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
799 require.Equal(t, []ports.RemoteCatalogSession{{
800 Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
801 }}, catalog.Sessions)
802}
803
804func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
805 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
806 d := newTestDaemon(t, factory, stubClock{})
807 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
808 sess := addControlSession(d, "work", "t_active", "p_active")
809 target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
810 target.ctx, target.cancel = context.WithCancel(d.serveCtx)
811 sess.mu.Lock()
812 sess.tabs = append(sess.tabs, target)
813 selectTestAttachmentTabLocked(sess, 0)
814 sess.mu.Unlock()
815 original := target.tree.Focus
816
817 firstFrame := commandFrame(t, ports.CommandRequest{
818 Slug: "split-right", Self: true, TargetSession: "work",
819 TargetTab: "t_invoking", TargetPane: "p_invoking",
820 })
821 firstTransport, firstSends, _ := newConn(t, firstFrame)
822 firstDone := make(chan struct{})
823 go func() {
824 if err := d.handleCommand(firstTransport, firstFrame); err != nil {
825 t.Errorf("handle first command: %v", err)
826 }
827 close(firstDone)
828 }()
829 select {
830 case <-factory.entered:
831 case <-time.After(2 * time.Second):
832 t.Fatal("first command did not enter its blocked action")
833 }
834
835 secondFrame := commandFrame(t, ports.CommandRequest{
836 Slug: "focus-pane-right", Self: true, TargetSession: "work",
837 TargetTab: "t_invoking", TargetPane: "p_invoking",
838 })
839 secondTransport, secondSends, _ := newConn(t, secondFrame)
840 secondDone := make(chan struct{})
841 go func() {
842 if err := d.handleCommand(secondTransport, secondFrame); err != nil {
843 t.Errorf("handle second command: %v", err)
844 }
845 close(secondDone)
846 }()
847 select {
848 case <-secondDone:
849 t.Fatal("second command bypassed serialization")
850 case <-time.After(100 * time.Millisecond):
851 }
852 close(factory.release)
853 <-firstDone
854 <-secondDone
855 require.True(t, awaitCommandResult(t, firstSends).OK)
856 require.True(t, awaitCommandResult(t, secondSends).OK)
857
858 sess.mu.Lock()
859 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
860 sess.mu.Unlock()
861 target.mu.Lock()
862 defer target.mu.Unlock()
863 require.Len(t, target.panes, 2)
864 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
865}
866
867func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
868 d := newTestDaemon(t, nil, stubClock{})
869 source := addControlSession(d, "work", "t_work", "p_work")
870 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
871 tb := source.tabs[0]
872 tb.mu.Lock()
873 pane := tb.focusedPane()
874 tb.mu.Unlock()
875 ac := &attachedClient{tr: &closeTrackingTransport{}}
876 ac.setSession(source)
877
878 source.mu.Lock()
879 require.True(t, source.registerAttachmentLocked(ac))
880 target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
881 require.True(t, source.unregisterAttachmentLocked(ac))
882 source.mu.Unlock()
883
884 err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
885 require.ErrorIs(t, err, errMoveStaleTarget)
886}
887
888func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
889 d := newTestDaemon(t, nil, stubClock{})
890 source := addControlSession(d, "work", "t_active", "p_active")
891 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
892 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
893 publishTiledPaneOwners(source, inactive)
894 source.mu.Lock()
895 source.tabs = append(source.tabs, inactive)
896 selectTestAttachmentTabLocked(source, 0)
897 source.mu.Unlock()
898 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
899
900 result := sendCommand(t, d, ports.CommandRequest{
901 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
902 })
903 require.True(t, result.OK, result.Text)
904
905 source.mu.Lock()
906 require.Len(t, source.tabs, 1)
907 require.Equal(t, "t_inactive", source.tabs[0].stableID)
908 source.mu.Unlock()
909 destination.mu.Lock()
910 destTab := destination.tabs[0]
911 destination.mu.Unlock()
912 destTab.mu.Lock()
913 var moved *pane
914 for _, candidate := range destTab.panes {
915 if candidate.stableID == "p_active" {
916 moved = candidate
917 }
918 }
919 require.NotNil(t, moved)
920 destTab.mu.Unlock()
921}
922
923func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
924 d := newTestDaemon(t, nil, stubClock{})
925 source := addControlSession(d, "work", "t_active", "p_active")
926 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
927 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
928 publishTiledPaneOwners(source, inactive)
929 source.mu.Lock()
930 source.tabs = append(source.tabs, inactive)
931 selectTestAttachmentTabLocked(source, 0)
932 source.mu.Unlock()
933 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
934
935 result := sendCommand(t, d, ports.CommandRequest{
936 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
937 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
938 })
939 require.True(t, result.OK, result.Text)
940
941 active := source.tabs[0]
942 active.mu.Lock()
943 require.Len(t, active.panes, 1)
944 require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
945 active.mu.Unlock()
946 destination.mu.Lock()
947 destTab := destination.tabs[0]
948 destination.mu.Unlock()
949 destTab.mu.Lock()
950 var moved *pane
951 for _, candidate := range destTab.panes {
952 if candidate.stableID == "p_inactive" {
953 moved = candidate
954 }
955 }
956 require.NotNil(t, moved)
957 destTab.mu.Unlock()
958}
959
960func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
961 d := newTestDaemon(t, nil, stubClock{})
962 source := addControlSession(d, "work", "t_first", "p_first")
963 publishTiledPaneOwners(source, source.tabs[0])
964 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
965 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
966 publishTiledPaneOwners(source, second)
967 source.mu.Lock()
968 source.tabs = append(source.tabs, second)
969 selectTestAttachmentTabLocked(source, 0)
970 source.mu.Unlock()
971 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
972
973 result := sendCommand(t, d, ports.CommandRequest{
974 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
975 TargetTab: "t_second", TargetPane: "p_second",
976 })
977 require.True(t, result.OK, result.Text)
978
979 source.mu.Lock()
980 require.Len(t, source.tabs, 1)
981 require.Equal(t, "t_second", source.tabs[0].stableID)
982 source.mu.Unlock()
983 destination.mu.Lock()
984 require.Len(t, destination.tabs, 2)
985 require.Equal(t, "t_first", destination.tabs[1].stableID)
986 destination.mu.Unlock()
987}
988
989func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
990 d := newTestDaemon(t, nil, stubClock{})
991 source := addControlSession(d, "work", "t_first", "p_first")
992 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
993 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
994 publishTiledPaneOwners(source, second)
995 source.mu.Lock()
996 source.tabs = append(source.tabs, second)
997 selectTestAttachmentTabLocked(source, 0)
998 source.mu.Unlock()
999 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1000
1001 result := sendCommand(t, d, ports.CommandRequest{
1002 Slug: "move-tab", Args: []string{"dest"}, Self: true,
1003 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1004 })
1005 require.True(t, result.OK, result.Text)
1006
1007 source.mu.Lock()
1008 require.Len(t, source.tabs, 1)
1009 require.Equal(t, "t_first", source.tabs[0].stableID)
1010 source.mu.Unlock()
1011 destination.mu.Lock()
1012 require.Len(t, destination.tabs, 2)
1013 require.Equal(t, "t_second", destination.tabs[1].stableID)
1014 destination.mu.Unlock()
1015}
1016
1017func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1018 tests := []struct {
1019 name string
1020 arrange func(*Daemon) ports.CommandRequest
1021 code uint16
1022 text string
1023 }{
1024 {
1025 name: "move-pane missing destination session",
1026 arrange: func(d *Daemon) ports.CommandRequest {
1027 addControlSession(d, "work", "t_work", "p_work")
1028 return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1029 },
1030 code: ports.ErrNoSuchTarget,
1031 text: "move request is invalid",
1032 },
1033 {
1034 name: "move-pane unknown destination session",
1035 arrange: func(d *Daemon) ports.CommandRequest {
1036 addControlSession(d, "work", "t_work", "p_work")
1037 return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1038 },
1039 code: ports.ErrNoSuchTarget,
1040 text: "move target is no longer available",
1041 },
1042 {
1043 name: "move-pane destination tab not in session",
1044 arrange: func(d *Daemon) ports.CommandRequest {
1045 addControlSession(d, "work", "t_work", "p_work")
1046 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1047 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1048 },
1049 code: ports.ErrNoSuchTarget,
1050 text: "move target is no longer available",
1051 },
1052 {
1053 name: "move-pane same source tab",
1054 arrange: func(d *Daemon) ports.CommandRequest {
1055 addControlSession(d, "work", "t_work", "p_work")
1056 return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1057 },
1058 code: ports.ErrNoSuchTarget,
1059 text: "move request is invalid",
1060 },
1061 {
1062 name: "move-tab same session",
1063 arrange: func(d *Daemon) ports.CommandRequest {
1064 addControlSession(d, "work", "t_work", "p_work")
1065 return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1066 },
1067 code: ports.ErrNoSuchTarget,
1068 text: "move request is invalid",
1069 },
1070 {
1071 name: "move-tab stopping destination",
1072 arrange: func(d *Daemon) ports.CommandRequest {
1073 addControlSession(d, "work", "t_work", "p_work")
1074 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1075 destination.teardownMu.Lock()
1076 destination.teardownActive = true
1077 destination.teardownMu.Unlock()
1078 return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1079 },
1080 code: ports.ErrNoSuchTarget,
1081 text: "move target is no longer available",
1082 },
1083 {
1084 name: "move-pane too few args",
1085 arrange: func(d *Daemon) ports.CommandRequest {
1086 addControlSession(d, "work", "t_work", "p_work")
1087 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1088 },
1089 code: ports.ErrInvalidCommandArgs,
1090 },
1091 {
1092 name: "move-pane too many args",
1093 arrange: func(d *Daemon) ports.CommandRequest {
1094 addControlSession(d, "work", "t_work", "p_work")
1095 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1096 },
1097 code: ports.ErrInvalidCommandArgs,
1098 },
1099 {
1100 name: "move-tab missing destination",
1101 arrange: func(d *Daemon) ports.CommandRequest {
1102 addControlSession(d, "work", "t_work", "p_work")
1103 return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1104 },
1105 code: ports.ErrInvalidCommandArgs,
1106 },
1107 {
1108 name: "explicit session name without IDs remains authoritative",
1109 arrange: func(d *Daemon) ports.CommandRequest {
1110 addControlSession(d, "renamed", "t_work", "p_work")
1111 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1112 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1113 },
1114 code: ports.ErrNoSuchTarget,
1115 text: "no such session: old-name",
1116 },
1117 }
1118
1119 for _, tt := range tests {
1120 t.Run(tt.name, func(t *testing.T) {
1121 d := newTestDaemon(t, nil, stubClock{})
1122 request := tt.arrange(d)
1123 result := sendCommand(t, d, request)
1124 require.False(t, result.OK)
1125 require.Equal(t, tt.code, result.Code)
1126 if tt.text != "" {
1127 require.Equal(t, tt.text, result.Text)
1128 }
1129 })
1130 }
1131}
1132
1133func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1134 d := newTestDaemon(t, nil, stubClock{})
1135 advisory := addControlSession(d, "old-name", "t_other", "p_other")
1136 source := addControlSession(d, "renamed", "t_work", "p_work")
1137 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1138
1139 result := sendCommand(t, d, ports.CommandRequest{
1140 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1141 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1142 })
1143 require.True(t, result.OK, result.Text)
1144
1145 source.mu.Lock()
1146 require.Len(t, source.tabs, 0)
1147 source.mu.Unlock()
1148 advisory.mu.Lock()
1149 require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1150 advisory.mu.Unlock()
1151 destination.mu.Lock()
1152 destTab := destination.tabs[0]
1153 destination.mu.Unlock()
1154 destTab.mu.Lock()
1155 require.Len(t, destTab.panes, 2)
1156 destTab.mu.Unlock()
1157}
1158
1159func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1160 d := newTestDaemon(t, nil, stubClock{})
1161 source := addControlSession(d, "renamed", "t_active", "p_active")
1162 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1163 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1164 publishTiledPaneOwners(source, inactive)
1165 source.mu.Lock()
1166 source.tabs = append(source.tabs, inactive)
1167 selectTestAttachmentTabLocked(source, 1)
1168 source.mu.Unlock()
1169 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1170
1171 result := sendCommand(t, d, ports.CommandRequest{
1172 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1173 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1174 })
1175 require.True(t, result.OK, result.Text)
1176
1177 source.mu.Lock()
1178 require.Len(t, source.tabs, 1)
1179 require.Equal(t, "t_active", source.tabs[0].stableID)
1180 source.mu.Unlock()
1181 destination.mu.Lock()
1182 destTab := destination.tabs[0]
1183 destination.mu.Unlock()
1184 destTab.mu.Lock()
1185 var moved *pane
1186 for _, candidate := range destTab.panes {
1187 if candidate.stableID == "p_inactive" {
1188 moved = candidate
1189 }
1190 }
1191 require.NotNil(t, moved)
1192 destTab.mu.Unlock()
1193}
1194
1195func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1196 d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1197 left.mu.Lock()
1198 left.name = "left"
1199 left.tabs[0].stableID = "left-moved"
1200 selectTestAttachmentTabLocked(left, 0)
1201 left.mu.Unlock()
1202
1203 right := addMoveTabTestSession(d, "right", "right-stays")
1204 rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1205 rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1206 publishTiledPaneOwners(right, rightMoved)
1207 right.mu.Lock()
1208 right.tabs = append(right.tabs, rightMoved)
1209 selectTestAttachmentTabLocked(right, 1)
1210 right.mu.Unlock()
1211
1212 leftFrame := commandFrame(t, ports.CommandRequest{
1213 Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1214 })
1215 rightFrame := commandFrame(t, ports.CommandRequest{
1216 Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1217 })
1218 leftTransport, leftSends, _ := newConn(t, leftFrame)
1219 rightTransport, rightSends, _ := newConn(t, rightFrame)
1220 start := make(chan struct{})
1221 done := make(chan struct{}, 2)
1222 go func() {
1223 <-start
1224 if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1225 t.Errorf("handle left command: %v", err)
1226 }
1227 done <- struct{}{}
1228 }()
1229 go func() {
1230 <-start
1231 if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1232 t.Errorf("handle right command: %v", err)
1233 }
1234 done <- struct{}{}
1235 }()
1236 close(start)
1237 for _, sends := range []chan ports.Frame{leftSends, rightSends} {
1238 select {
1239 case <-done:
1240 result := awaitCommandResult(t, sends)
1241 require.True(t, result.OK, result.Text)
1242 case <-time.After(5 * time.Second):
1243 t.Fatal("opposite-direction move commands deadlocked")
1244 }
1245 }
1246}
1247
1248func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1249 ctx, cancel := context.WithCancel(d.serveCtx)
1250 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1251 tb.ctx, tb.cancel = context.WithCancel(ctx)
1252 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1253 ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1254 }
1255 publishTiledPaneOwners(sess, tb)
1256 d.mu.Lock()
1257 d.sessions[sess.id] = sess
1258 d.mu.Unlock()
1259 return sess
1260}
1261
1262func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
1263 t.Helper()
1264 if request.Version == 0 {
1265 request.Version = ports.ProtocolVersion
1266 }
1267 payload, err := ports.MarshalCommandRequest(request)
1268 require.NoError(t, err)
1269 return ports.Frame{Type: ports.MsgCommand, Payload: payload}
1270}
1271
1272func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
1273 t.Helper()
1274 frame := commandFrame(t, request)
1275 tr, sends, _ := newConn(t, frame)
1276 require.NoError(t, d.handleCommand(tr, frame))
1277 return awaitCommandResult(t, sends)
1278}
1279
1280func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
1281 t.Helper()
1282 reply := awaitFrame(t, sends, ports.MsgCommandResult)
1283 result, err := ports.UnmarshalCommandResult(reply.Payload)
1284 require.NoError(t, err)
1285 return result
1286}
1287
1288type controlPTYFactory struct {
1289 mu sync.Mutex
1290 ptys []*quietPTY
1291 entered chan struct{}
1292 release chan struct{}
1293 once sync.Once
1294}
1295
1296func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
1297 f.once.Do(func() {
1298 if f.entered != nil {
1299 close(f.entered)
1300 <-f.release
1301 }
1302 })
1303 pty := newQuietPTY()
1304 f.mu.Lock()
1305 f.ptys = append(f.ptys, pty)
1306 f.mu.Unlock()
1307 return pty, nil
1308}
1309
1310func (f *controlPTYFactory) close() {
1311 if f.release != nil {
1312 select {
1313 case <-f.release:
1314 default:
1315 close(f.release)
1316 }
1317 }
1318 f.mu.Lock()
1319 defer f.mu.Unlock()
1320 for _, pty := range f.ptys {
1321 _ = pty.Close()
1322 }
1323}
1324
1325type blockingControlSendTransport struct {
1326 started chan struct{}
1327 closed chan struct{}
1328 once sync.Once
1329}
1330
1331func newBlockingControlSendTransport() *blockingControlSendTransport {
1332 return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1333}
1334
1335func (tr *blockingControlSendTransport) Send(ports.Frame) error {
1336 tr.once.Do(func() { close(tr.started) })
1337 <-tr.closed
1338 return io.ErrClosedPipe
1339}
1340
1341func (*blockingControlSendTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1342func (tr *blockingControlSendTransport) Close() error {
1343 tr.once.Do(func() { close(tr.started) })
1344 select {
1345 case <-tr.closed:
1346 default:
1347 close(tr.closed)
1348 }
1349 return nil
1350}
1351
1352type commandSendErrorTransport struct {
1353 err error
1354 closed bool
1355}
1356
1357func (tr *commandSendErrorTransport) Send(ports.Frame) error { return tr.err }
1358func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1359func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil }
1360
1361func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1362 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1363 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1364 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1365 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1366 }
1367 sess.mruAt.Store(1)
1368 d.mu.Lock()
1369 d.sessions[sess.id] = sess
1370 d.mu.Unlock()
1371 return sess
1372}