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 TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
785 d := newTestDaemon(t, nil, stubClock{})
786 sess := addControlSession(d, "work", "t_work", "p_work")
787 sess.incarnation[0] = 1
788 tb := sess.tabs[0]
789 tb.name = "shell"
790 p := tb.focusedPane()
791 pty := portsmocks.NewMockPTY(t)
792 pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
793 p.pty = pty
794 d.procComm = func(pid int) (string, error) {
795 require.Equal(t, 1234, pid)
796 return "fish", nil
797 }
798
799 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
800 require.True(t, result.OK, result.Text)
801 var catalog ports.RemoteCatalog
802 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
803 require.Equal(t, []ports.RemoteCatalogSession{{
804 LifecycleID: sess.incarnation,
805 Name: "work",
806 State: "up",
807 Ephemeral: true,
808 Tabs: []ports.RemoteCatalogTab{{
809 ID: "t_work",
810 Index: 0,
811 Name: "shell",
812 Detail: " (fish)",
813 }},
814 ActiveTabID: "t_work",
815 LastUsedSeq: 1,
816 }}, catalog.Sessions)
817}
818
819func TestRemoteCatalogTabCountSaturates(t *testing.T) {
820 d := newTestDaemon(t, nil, stubClock{})
821 sess := addControlSession(d, "work", "t_work", "p_work")
822 sess.mu.Lock()
823 base := sess.tabs[0]
824 sess.tabs = make([]*tab, int(^uint16(0))+1)
825 for i := range sess.tabs {
826 sess.tabs[i] = base
827 }
828 sess.mu.Unlock()
829
830 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
831 require.True(t, result.OK, result.Text)
832 var catalog ports.RemoteCatalog
833 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
834 require.Equal(t, []ports.RemoteCatalogSession{{
835 Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
836 }}, catalog.Sessions)
837}
838
839func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
840 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
841 d := newTestDaemon(t, factory, stubClock{})
842 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
843 sess := addControlSession(d, "work", "t_active", "p_active")
844 target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
845 target.ctx, target.cancel = context.WithCancel(d.serveCtx)
846 sess.mu.Lock()
847 sess.tabs = append(sess.tabs, target)
848 selectTestAttachmentTabLocked(sess, 0)
849 sess.mu.Unlock()
850 original := target.tree.Focus
851
852 firstFrame := commandFrame(t, ports.CommandRequest{
853 Slug: "split-right", Self: true, TargetSession: "work",
854 TargetTab: "t_invoking", TargetPane: "p_invoking",
855 })
856 firstTransport, firstSends, _ := newConn(t, firstFrame)
857 firstDone := make(chan struct{})
858 go func() {
859 if err := d.handleCommand(firstTransport, firstFrame); err != nil {
860 t.Errorf("handle first command: %v", err)
861 }
862 close(firstDone)
863 }()
864 select {
865 case <-factory.entered:
866 case <-time.After(2 * time.Second):
867 t.Fatal("first command did not enter its blocked action")
868 }
869
870 secondFrame := commandFrame(t, ports.CommandRequest{
871 Slug: "focus-pane-right", Self: true, TargetSession: "work",
872 TargetTab: "t_invoking", TargetPane: "p_invoking",
873 })
874 secondTransport, secondSends, _ := newConn(t, secondFrame)
875 secondDone := make(chan struct{})
876 go func() {
877 if err := d.handleCommand(secondTransport, secondFrame); err != nil {
878 t.Errorf("handle second command: %v", err)
879 }
880 close(secondDone)
881 }()
882 select {
883 case <-secondDone:
884 t.Fatal("second command bypassed serialization")
885 case <-time.After(100 * time.Millisecond):
886 }
887 close(factory.release)
888 <-firstDone
889 <-secondDone
890 require.True(t, awaitCommandResult(t, firstSends).OK)
891 require.True(t, awaitCommandResult(t, secondSends).OK)
892
893 sess.mu.Lock()
894 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
895 sess.mu.Unlock()
896 target.mu.Lock()
897 defer target.mu.Unlock()
898 require.Len(t, target.panes, 2)
899 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
900}
901
902func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
903 d := newTestDaemon(t, nil, stubClock{})
904 source := addControlSession(d, "work", "t_work", "p_work")
905 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
906 tb := source.tabs[0]
907 tb.mu.Lock()
908 pane := tb.focusedPane()
909 tb.mu.Unlock()
910 ac := &attachedClient{tr: &closeTrackingTransport{}}
911 ac.setSession(source)
912
913 source.mu.Lock()
914 require.True(t, source.registerAttachmentLocked(ac))
915 target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
916 require.True(t, source.unregisterAttachmentLocked(ac))
917 source.mu.Unlock()
918
919 err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
920 require.ErrorIs(t, err, errMoveStaleTarget)
921}
922
923func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(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"}, TargetSession: "work",
937 })
938 require.True(t, result.OK, result.Text)
939
940 source.mu.Lock()
941 require.Len(t, source.tabs, 1)
942 require.Equal(t, "t_inactive", source.tabs[0].stableID)
943 source.mu.Unlock()
944 destination.mu.Lock()
945 destTab := destination.tabs[0]
946 destination.mu.Unlock()
947 destTab.mu.Lock()
948 var moved *pane
949 for _, candidate := range destTab.panes {
950 if candidate.stableID == "p_active" {
951 moved = candidate
952 }
953 }
954 require.NotNil(t, moved)
955 destTab.mu.Unlock()
956}
957
958func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
959 d := newTestDaemon(t, nil, stubClock{})
960 source := addControlSession(d, "work", "t_active", "p_active")
961 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
962 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
963 publishTiledPaneOwners(source, inactive)
964 source.mu.Lock()
965 source.tabs = append(source.tabs, inactive)
966 selectTestAttachmentTabLocked(source, 0)
967 source.mu.Unlock()
968 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
969
970 result := sendCommand(t, d, ports.CommandRequest{
971 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
972 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
973 })
974 require.True(t, result.OK, result.Text)
975
976 active := source.tabs[0]
977 active.mu.Lock()
978 require.Len(t, active.panes, 1)
979 require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
980 active.mu.Unlock()
981 destination.mu.Lock()
982 destTab := destination.tabs[0]
983 destination.mu.Unlock()
984 destTab.mu.Lock()
985 var moved *pane
986 for _, candidate := range destTab.panes {
987 if candidate.stableID == "p_inactive" {
988 moved = candidate
989 }
990 }
991 require.NotNil(t, moved)
992 destTab.mu.Unlock()
993}
994
995func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
996 d := newTestDaemon(t, nil, stubClock{})
997 source := addControlSession(d, "work", "t_first", "p_first")
998 publishTiledPaneOwners(source, source.tabs[0])
999 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1000 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1001 publishTiledPaneOwners(source, second)
1002 source.mu.Lock()
1003 source.tabs = append(source.tabs, second)
1004 selectTestAttachmentTabLocked(source, 0)
1005 source.mu.Unlock()
1006 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1007
1008 result := sendCommand(t, d, ports.CommandRequest{
1009 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1010 TargetTab: "t_second", TargetPane: "p_second",
1011 })
1012 require.True(t, result.OK, result.Text)
1013
1014 source.mu.Lock()
1015 require.Len(t, source.tabs, 1)
1016 require.Equal(t, "t_second", source.tabs[0].stableID)
1017 source.mu.Unlock()
1018 destination.mu.Lock()
1019 require.Len(t, destination.tabs, 2)
1020 require.Equal(t, "t_first", destination.tabs[1].stableID)
1021 destination.mu.Unlock()
1022}
1023
1024func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1025 d := newTestDaemon(t, nil, stubClock{})
1026 source := addControlSession(d, "work", "t_first", "p_first")
1027 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1028 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1029 publishTiledPaneOwners(source, second)
1030 source.mu.Lock()
1031 source.tabs = append(source.tabs, second)
1032 selectTestAttachmentTabLocked(source, 0)
1033 source.mu.Unlock()
1034 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1035
1036 result := sendCommand(t, d, ports.CommandRequest{
1037 Slug: "move-tab", Args: []string{"dest"}, Self: true,
1038 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1039 })
1040 require.True(t, result.OK, result.Text)
1041
1042 source.mu.Lock()
1043 require.Len(t, source.tabs, 1)
1044 require.Equal(t, "t_first", source.tabs[0].stableID)
1045 source.mu.Unlock()
1046 destination.mu.Lock()
1047 require.Len(t, destination.tabs, 2)
1048 require.Equal(t, "t_second", destination.tabs[1].stableID)
1049 destination.mu.Unlock()
1050}
1051
1052func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1053 tests := []struct {
1054 name string
1055 arrange func(*Daemon) ports.CommandRequest
1056 code uint16
1057 text string
1058 }{
1059 {
1060 name: "move-pane missing destination session",
1061 arrange: func(d *Daemon) ports.CommandRequest {
1062 addControlSession(d, "work", "t_work", "p_work")
1063 return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1064 },
1065 code: ports.ErrNoSuchTarget,
1066 text: "move request is invalid",
1067 },
1068 {
1069 name: "move-pane unknown destination session",
1070 arrange: func(d *Daemon) ports.CommandRequest {
1071 addControlSession(d, "work", "t_work", "p_work")
1072 return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1073 },
1074 code: ports.ErrNoSuchTarget,
1075 text: "move target is no longer available",
1076 },
1077 {
1078 name: "move-pane destination tab not in session",
1079 arrange: func(d *Daemon) ports.CommandRequest {
1080 addControlSession(d, "work", "t_work", "p_work")
1081 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1082 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1083 },
1084 code: ports.ErrNoSuchTarget,
1085 text: "move target is no longer available",
1086 },
1087 {
1088 name: "move-pane same source tab",
1089 arrange: func(d *Daemon) ports.CommandRequest {
1090 addControlSession(d, "work", "t_work", "p_work")
1091 return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1092 },
1093 code: ports.ErrNoSuchTarget,
1094 text: "move request is invalid",
1095 },
1096 {
1097 name: "move-tab same session",
1098 arrange: func(d *Daemon) ports.CommandRequest {
1099 addControlSession(d, "work", "t_work", "p_work")
1100 return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1101 },
1102 code: ports.ErrNoSuchTarget,
1103 text: "move request is invalid",
1104 },
1105 {
1106 name: "move-tab stopping destination",
1107 arrange: func(d *Daemon) ports.CommandRequest {
1108 addControlSession(d, "work", "t_work", "p_work")
1109 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1110 destination.teardownMu.Lock()
1111 destination.teardownActive = true
1112 destination.teardownMu.Unlock()
1113 return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1114 },
1115 code: ports.ErrNoSuchTarget,
1116 text: "move target is no longer available",
1117 },
1118 {
1119 name: "move-pane too few args",
1120 arrange: func(d *Daemon) ports.CommandRequest {
1121 addControlSession(d, "work", "t_work", "p_work")
1122 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1123 },
1124 code: ports.ErrInvalidCommandArgs,
1125 },
1126 {
1127 name: "move-pane too many args",
1128 arrange: func(d *Daemon) ports.CommandRequest {
1129 addControlSession(d, "work", "t_work", "p_work")
1130 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1131 },
1132 code: ports.ErrInvalidCommandArgs,
1133 },
1134 {
1135 name: "move-tab missing destination",
1136 arrange: func(d *Daemon) ports.CommandRequest {
1137 addControlSession(d, "work", "t_work", "p_work")
1138 return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1139 },
1140 code: ports.ErrInvalidCommandArgs,
1141 },
1142 {
1143 name: "explicit session name without IDs remains authoritative",
1144 arrange: func(d *Daemon) ports.CommandRequest {
1145 addControlSession(d, "renamed", "t_work", "p_work")
1146 addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1147 return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1148 },
1149 code: ports.ErrNoSuchTarget,
1150 text: "no such session: old-name",
1151 },
1152 }
1153
1154 for _, tt := range tests {
1155 t.Run(tt.name, func(t *testing.T) {
1156 d := newTestDaemon(t, nil, stubClock{})
1157 request := tt.arrange(d)
1158 result := sendCommand(t, d, request)
1159 require.False(t, result.OK)
1160 require.Equal(t, tt.code, result.Code)
1161 if tt.text != "" {
1162 require.Equal(t, tt.text, result.Text)
1163 }
1164 })
1165 }
1166}
1167
1168func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1169 d := newTestDaemon(t, nil, stubClock{})
1170 advisory := addControlSession(d, "old-name", "t_other", "p_other")
1171 source := addControlSession(d, "renamed", "t_work", "p_work")
1172 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1173
1174 result := sendCommand(t, d, ports.CommandRequest{
1175 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1176 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1177 })
1178 require.True(t, result.OK, result.Text)
1179
1180 source.mu.Lock()
1181 require.Len(t, source.tabs, 0)
1182 source.mu.Unlock()
1183 advisory.mu.Lock()
1184 require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1185 advisory.mu.Unlock()
1186 destination.mu.Lock()
1187 destTab := destination.tabs[0]
1188 destination.mu.Unlock()
1189 destTab.mu.Lock()
1190 require.Len(t, destTab.panes, 2)
1191 destTab.mu.Unlock()
1192}
1193
1194func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1195 d := newTestDaemon(t, nil, stubClock{})
1196 source := addControlSession(d, "renamed", "t_active", "p_active")
1197 inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1198 inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1199 publishTiledPaneOwners(source, inactive)
1200 source.mu.Lock()
1201 source.tabs = append(source.tabs, inactive)
1202 selectTestAttachmentTabLocked(source, 1)
1203 source.mu.Unlock()
1204 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1205
1206 result := sendCommand(t, d, ports.CommandRequest{
1207 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1208 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1209 })
1210 require.True(t, result.OK, result.Text)
1211
1212 source.mu.Lock()
1213 require.Len(t, source.tabs, 1)
1214 require.Equal(t, "t_active", source.tabs[0].stableID)
1215 source.mu.Unlock()
1216 destination.mu.Lock()
1217 destTab := destination.tabs[0]
1218 destination.mu.Unlock()
1219 destTab.mu.Lock()
1220 var moved *pane
1221 for _, candidate := range destTab.panes {
1222 if candidate.stableID == "p_inactive" {
1223 moved = candidate
1224 }
1225 }
1226 require.NotNil(t, moved)
1227 destTab.mu.Unlock()
1228}
1229
1230func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1231 d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1232 left.mu.Lock()
1233 left.name = "left"
1234 left.tabs[0].stableID = "left-moved"
1235 selectTestAttachmentTabLocked(left, 0)
1236 left.mu.Unlock()
1237
1238 right := addMoveTabTestSession(d, "right", "right-stays")
1239 rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1240 rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1241 publishTiledPaneOwners(right, rightMoved)
1242 right.mu.Lock()
1243 right.tabs = append(right.tabs, rightMoved)
1244 selectTestAttachmentTabLocked(right, 1)
1245 right.mu.Unlock()
1246
1247 leftFrame := commandFrame(t, ports.CommandRequest{
1248 Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1249 })
1250 rightFrame := commandFrame(t, ports.CommandRequest{
1251 Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1252 })
1253 leftTransport, leftSends, _ := newConn(t, leftFrame)
1254 rightTransport, rightSends, _ := newConn(t, rightFrame)
1255 start := make(chan struct{})
1256 done := make(chan struct{}, 2)
1257 go func() {
1258 <-start
1259 if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1260 t.Errorf("handle left command: %v", err)
1261 }
1262 done <- struct{}{}
1263 }()
1264 go func() {
1265 <-start
1266 if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1267 t.Errorf("handle right command: %v", err)
1268 }
1269 done <- struct{}{}
1270 }()
1271 close(start)
1272 for _, sends := range []chan ports.Frame{leftSends, rightSends} {
1273 select {
1274 case <-done:
1275 result := awaitCommandResult(t, sends)
1276 require.True(t, result.OK, result.Text)
1277 case <-time.After(5 * time.Second):
1278 t.Fatal("opposite-direction move commands deadlocked")
1279 }
1280 }
1281}
1282
1283func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1284 ctx, cancel := context.WithCancel(d.serveCtx)
1285 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1286 tb.ctx, tb.cancel = context.WithCancel(ctx)
1287 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1288 ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1289 }
1290 publishTiledPaneOwners(sess, tb)
1291 d.mu.Lock()
1292 d.sessions[sess.id] = sess
1293 d.mu.Unlock()
1294 return sess
1295}
1296
1297func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
1298 t.Helper()
1299 if request.Version == 0 {
1300 request.Version = ports.ProtocolVersion
1301 }
1302 payload, err := ports.MarshalCommandRequest(request)
1303 require.NoError(t, err)
1304 return ports.Frame{Type: ports.MsgCommand, Payload: payload}
1305}
1306
1307func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
1308 t.Helper()
1309 frame := commandFrame(t, request)
1310 tr, sends, _ := newConn(t, frame)
1311 require.NoError(t, d.handleCommand(tr, frame))
1312 return awaitCommandResult(t, sends)
1313}
1314
1315func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
1316 t.Helper()
1317 reply := awaitFrame(t, sends, ports.MsgCommandResult)
1318 result, err := ports.UnmarshalCommandResult(reply.Payload)
1319 require.NoError(t, err)
1320 return result
1321}
1322
1323type controlPTYFactory struct {
1324 mu sync.Mutex
1325 ptys []*quietPTY
1326 entered chan struct{}
1327 release chan struct{}
1328 once sync.Once
1329}
1330
1331func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
1332 f.once.Do(func() {
1333 if f.entered != nil {
1334 close(f.entered)
1335 <-f.release
1336 }
1337 })
1338 pty := newQuietPTY()
1339 f.mu.Lock()
1340 f.ptys = append(f.ptys, pty)
1341 f.mu.Unlock()
1342 return pty, nil
1343}
1344
1345func (f *controlPTYFactory) close() {
1346 if f.release != nil {
1347 select {
1348 case <-f.release:
1349 default:
1350 close(f.release)
1351 }
1352 }
1353 f.mu.Lock()
1354 defer f.mu.Unlock()
1355 for _, pty := range f.ptys {
1356 _ = pty.Close()
1357 }
1358}
1359
1360type blockingControlSendTransport struct {
1361 started chan struct{}
1362 closed chan struct{}
1363 once sync.Once
1364}
1365
1366func newBlockingControlSendTransport() *blockingControlSendTransport {
1367 return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1368}
1369
1370func (tr *blockingControlSendTransport) Send(ports.Frame) error {
1371 tr.once.Do(func() { close(tr.started) })
1372 <-tr.closed
1373 return io.ErrClosedPipe
1374}
1375
1376func (*blockingControlSendTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1377func (tr *blockingControlSendTransport) Close() error {
1378 tr.once.Do(func() { close(tr.started) })
1379 select {
1380 case <-tr.closed:
1381 default:
1382 close(tr.closed)
1383 }
1384 return nil
1385}
1386
1387type commandSendErrorTransport struct {
1388 err error
1389 closed bool
1390}
1391
1392func (tr *commandSendErrorTransport) Send(ports.Frame) error { return tr.err }
1393func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1394func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil }
1395
1396func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1397 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1398 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1399 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1400 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1401 }
1402 sess.mruAt.Store(1)
1403 d.mu.Lock()
1404 d.sessions[sess.id] = sess
1405 d.mu.Unlock()
1406 return sess
1407}