detached 778bd38

Merge pull request #76 from bnema/perf/transactional-resize

perf(daemon): make resize transactions coordinator-owned
commit
778bd38f9e50ac7c5ea758ef5d931379225755e0
author
bnema · · 2026-07-12 14:26 UTC
committer
GitHub · · 2026-07-12 14:26 UTC
merge of
216dc2e + 4c50de8 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
34 files · +1686 -505
M internal/adapters/pty/pty_linux.go +6 -2
@@ -8,6 +8,7 @@ package pty
88 // x/sys/unix plus the standard library.
99
1010 import (
11+ "context"
1112 "errors"
1213 "fmt"
1314 "io"
@@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} }
3839 // verbatim (nil means inherit the current process environment); the caller
3940 // decides TERM and friends. sz sets the terminal window size before the child
4041 // starts, so the child observes the correct dimensions on its first query.
41-func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
43+ if err := ctx.Err(); err != nil {
44+ return nil, err
45+ }
4246 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
4347 if err != nil {
4448 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -79,7 +83,7 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
7983 }
8084 }
8185
82- cmd := exec.Command(command, args...)
86+ cmd := exec.CommandContext(ctx, command, args...)
8387 cmd.Env = env
8488 cmd.Dir = platform.DirOrHome(dir)
8589 cmd.Stdin = slave
@@ -8,6 +8,7 @@ package pty
8 // x/sys/unix plus the standard library.8 // x/sys/unix plus the standard library.
9 9
10 import (10 import (
11+ "context"
11 "errors"12 "errors"
12 "fmt"13 "fmt"
13 "io"14 "io"
@@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} }
38 // verbatim (nil means inherit the current process environment); the caller39 // verbatim (nil means inherit the current process environment); the caller
39 // decides TERM and friends. sz sets the terminal window size before the child40 // decides TERM and friends. sz sets the terminal window size before the child
40 // starts, so the child observes the correct dimensions on its first query.41 // starts, so the child observes the correct dimensions on its first query.
41-func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
43+ if err := ctx.Err(); err != nil {
44+ return nil, err
45+ }
42 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)46 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
43 if err != nil {47 if err != nil {
44 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)48 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -79,7 +83,7 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
79 }83 }
80 }84 }
81 85
82- cmd := exec.Command(command, args...)86+ cmd := exec.CommandContext(ctx, command, args...)
83 cmd.Env = env87 cmd.Env = env
84 cmd.Dir = platform.DirOrHome(dir)88 cmd.Dir = platform.DirOrHome(dir)
85 cmd.Stdin = slave89 cmd.Stdin = slave
M internal/adapters/pty/pty_linux_test.go +8 -7
@@ -4,6 +4,7 @@ package pty_test
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "io"
910 "os"
@@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
5354 t.Skip("skipping pty integration test in -short mode")
5455 }
5556 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
5758 require.NoError(t, err)
5859 t.Cleanup(func() { _ = p.Close() })
5960
@@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
7071 t.Skip("skipping pty integration test in -short mode")
7172 }
7273 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
7475 require.NoError(t, err)
7576 t.Cleanup(func() { _ = p.Close() })
7677
@@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
103104 t.Skip("skipping pty integration test in -short mode")
104105 }
105106 f := newFactory()
106- p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107108 require.NoError(t, err)
108109 t.Cleanup(func() { _ = p.Close() })
109110
@@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) {
141142 for _, tt := range tests {
142143 t.Run(tt.name, func(t *testing.T) {
143144 f := newFactory()
144- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145146 require.NoError(t, err)
146147 t.Cleanup(func() { _ = p.Close() })
147148
@@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
160161 t.Skip("skipping pty integration test in -short mode")
161162 }
162163 f := newFactory()
163- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164165 require.NoError(t, err)
165166
166167 pid := p.Pid()
@@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
191192 t.Skip("skipping pty integration test in -short mode")
192193 }
193194 f := newFactory()
194- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195196 require.NoError(t, err)
196197 pid := p.Pid()
197198
@@ -212,7 +213,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
212213 // deliver SIGWINCH to the foreground process group.
213214 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
214215 f := newFactory()
215- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216217 require.NoError(t, err)
217218 t.Cleanup(func() { _ = p.Close() })
218219
@@ -4,6 +4,7 @@ package pty_test
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "io"9 "io"
9 "os"10 "os"
@@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
53 t.Skip("skipping pty integration test in -short mode")54 t.Skip("skipping pty integration test in -short mode")
54 }55 }
55 f := newFactory()56 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57 require.NoError(t, err)58 require.NoError(t, err)
58 t.Cleanup(func() { _ = p.Close() })59 t.Cleanup(func() { _ = p.Close() })
59 60
@@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
70 t.Skip("skipping pty integration test in -short mode")71 t.Skip("skipping pty integration test in -short mode")
71 }72 }
72 f := newFactory()73 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74 require.NoError(t, err)75 require.NoError(t, err)
75 t.Cleanup(func() { _ = p.Close() })76 t.Cleanup(func() { _ = p.Close() })
76 77
@@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
103 t.Skip("skipping pty integration test in -short mode")104 t.Skip("skipping pty integration test in -short mode")
104 }105 }
105 f := newFactory()106 f := newFactory()
106- p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107 require.NoError(t, err)108 require.NoError(t, err)
108 t.Cleanup(func() { _ = p.Close() })109 t.Cleanup(func() { _ = p.Close() })
109 110
@@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) {
141 for _, tt := range tests {142 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {143 t.Run(tt.name, func(t *testing.T) {
143 f := newFactory()144 f := newFactory()
144- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145 require.NoError(t, err)146 require.NoError(t, err)
146 t.Cleanup(func() { _ = p.Close() })147 t.Cleanup(func() { _ = p.Close() })
147 148
@@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
160 t.Skip("skipping pty integration test in -short mode")161 t.Skip("skipping pty integration test in -short mode")
161 }162 }
162 f := newFactory()163 f := newFactory()
163- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164 require.NoError(t, err)165 require.NoError(t, err)
165 166
166 pid := p.Pid()167 pid := p.Pid()
@@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
191 t.Skip("skipping pty integration test in -short mode")192 t.Skip("skipping pty integration test in -short mode")
192 }193 }
193 f := newFactory()194 f := newFactory()
194- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195 require.NoError(t, err)196 require.NoError(t, err)
196 pid := p.Pid()197 pid := p.Pid()
197 198
@@ -212,7 +213,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
212 // deliver SIGWINCH to the foreground process group.213 // deliver SIGWINCH to the foreground process group.
213 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`214 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
214 f := newFactory()215 f := newFactory()
215- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})216+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216 require.NoError(t, err)217 require.NoError(t, err)
217 t.Cleanup(func() { _ = p.Close() })218 t.Cleanup(func() { _ = p.Close() })
218 219
M internal/ports/mocks/mocks.go +26 -20
@@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
499499 }
500500
501501 // Open provides a mock function for the type MockPTYFactory
502-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503- ret := _mock.Called(cmd, args, env, dir, sz)
502+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
504504
505505 if len(ret) == 0 {
506506 panic("no return value specified for Open")
@@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s
508508
509509 var r0 ports.PTY
510510 var r1 error
511- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512- return returnFunc(cmd, args, env, dir, sz)
511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512+ return returnFunc(ctx, cmd, args, env, dir, sz)
513513 }
514- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {
515- r0 = returnFunc(cmd, args, env, dir, sz)
514+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
515+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
516516 } else {
517517 if ret.Get(0) != nil {
518518 r0 = ret.Get(0).(ports.PTY)
519519 }
520520 }
521- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {
522- r1 = returnFunc(cmd, args, env, dir, sz)
521+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
522+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
523523 } else {
524524 r1 = ret.Error(1)
525525 }
@@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct {
532532 }
533533
534534 // Open is a helper method to define mock.On call
535+// - ctx context.Context
535536 // - cmd string
536537 // - args []string
537538 // - env []string
538539 // - dir string
539540 // - sz domain.Size
540-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
541- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}
541+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
542+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
542543 }
543544
544-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545546 _c.Call.Run(func(args mock.Arguments) {
546- var arg0 string
547+ var arg0 context.Context
547548 if args[0] != nil {
548- arg0 = args[0].(string)
549+ arg0 = args[0].(context.Context)
549550 }
550- var arg1 []string
551+ var arg1 string
551552 if args[1] != nil {
552- arg1 = args[1].([]string)
553+ arg1 = args[1].(string)
553554 }
554555 var arg2 []string
555556 if args[2] != nil {
556557 arg2 = args[2].([]string)
557558 }
558- var arg3 string
559+ var arg3 []string
559560 if args[3] != nil {
560- arg3 = args[3].(string)
561+ arg3 = args[3].([]string)
561562 }
562- var arg4 domain.Size
563+ var arg4 string
563564 if args[4] != nil {
564- arg4 = args[4].(domain.Size)
565+ arg4 = args[4].(string)
566+ }
567+ var arg5 domain.Size
568+ if args[5] != nil {
569+ arg5 = args[5].(domain.Size)
565570 }
566571 run(
567572 arg0,
@@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
569574 arg2,
570575 arg3,
571576 arg4,
577+ arg5,
572578 )
573579 })
574580 return _c
@@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
579585 return _c
580586 }
581587
582-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
588+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
583589 _c.Call.Return(run)
584590 return _c
585591 }
@@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
499 }499 }
500 500
501 // Open provides a mock function for the type MockPTYFactory501 // Open provides a mock function for the type MockPTYFactory
502-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {502+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503- ret := _mock.Called(cmd, args, env, dir, sz)503+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
504 504
505 if len(ret) == 0 {505 if len(ret) == 0 {
506 panic("no return value specified for Open")506 panic("no return value specified for Open")
@@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s
508 508
509 var r0 ports.PTY509 var r0 ports.PTY
510 var r1 error510 var r1 error
511- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512- return returnFunc(cmd, args, env, dir, sz)512+ return returnFunc(ctx, cmd, args, env, dir, sz)
513 }513 }
514- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {514+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
515- r0 = returnFunc(cmd, args, env, dir, sz)515+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
516 } else {516 } else {
517 if ret.Get(0) != nil {517 if ret.Get(0) != nil {
518 r0 = ret.Get(0).(ports.PTY)518 r0 = ret.Get(0).(ports.PTY)
519 }519 }
520 }520 }
521- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {521+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
522- r1 = returnFunc(cmd, args, env, dir, sz)522+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
523 } else {523 } else {
524 r1 = ret.Error(1)524 r1 = ret.Error(1)
525 }525 }
@@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct {
532 }532 }
533 533
534 // Open is a helper method to define mock.On call534 // Open is a helper method to define mock.On call
535+// - ctx context.Context
535 // - cmd string536 // - cmd string
536 // - args []string537 // - args []string
537 // - env []string538 // - env []string
538 // - dir string539 // - dir string
539 // - sz domain.Size540 // - sz domain.Size
540-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {541+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
541- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}542+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
542 }543 }
543 544
544-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {545+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545 _c.Call.Run(func(args mock.Arguments) {546 _c.Call.Run(func(args mock.Arguments) {
546- var arg0 string547+ var arg0 context.Context
547 if args[0] != nil {548 if args[0] != nil {
548- arg0 = args[0].(string)549+ arg0 = args[0].(context.Context)
549 }550 }
550- var arg1 []string551+ var arg1 string
551 if args[1] != nil {552 if args[1] != nil {
552- arg1 = args[1].([]string)553+ arg1 = args[1].(string)
553 }554 }
554 var arg2 []string555 var arg2 []string
555 if args[2] != nil {556 if args[2] != nil {
556 arg2 = args[2].([]string)557 arg2 = args[2].([]string)
557 }558 }
558- var arg3 string559+ var arg3 []string
559 if args[3] != nil {560 if args[3] != nil {
560- arg3 = args[3].(string)561+ arg3 = args[3].([]string)
561 }562 }
562- var arg4 domain.Size563+ var arg4 string
563 if args[4] != nil {564 if args[4] != nil {
564- arg4 = args[4].(domain.Size)565+ arg4 = args[4].(string)
566+ }
567+ var arg5 domain.Size
568+ if args[5] != nil {
569+ arg5 = args[5].(domain.Size)
565 }570 }
566 run(571 run(
567 arg0,572 arg0,
@@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
569 arg2,574 arg2,
570 arg3,575 arg3,
571 arg4,576 arg4,
577+ arg5,
572 )578 )
573 })579 })
574 return _c580 return _c
@@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
579 return _c585 return _c
580 }586 }
581 587
582-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {588+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
583 _c.Call.Return(run)589 _c.Call.Return(run)
584 return _c590 return _c
585 }591 }
M internal/ports/ports.go +3 -1
@@ -24,7 +24,9 @@ type PTY interface {
2424
2525 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
2626 type PTYFactory interface {
27- Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
27+ // Open creates a PTY while honoring ctx cancellation. Implementations must
28+ // return promptly once ctx is cancelled and must not leave a child running.
29+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
2830 }
2931
3032 // Terminal is the CLIENT-side controlling terminal.
@@ -24,7 +24,9 @@ type PTY interface {
24 24
25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
26 type PTYFactory interface {26 type PTYFactory interface {
27- Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)27+ // Open creates a PTY while honoring ctx cancellation. Implementations must
28+ // return promptly once ctx is cancelled and must not leave a child running.
29+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
28 }30 }
29 31
30 // Terminal is the CLIENT-side controlling terminal.32 // Terminal is the CLIENT-side controlling terminal.
M internal/usecase/daemon/client.go +23 -80
@@ -39,22 +39,19 @@ type attachedClient struct {
3939 // pipelineCache is the last successfully emitted composition. pipelineScratch
4040 // is its attachment-owned alternate buffer; both are only touched under
4141 // sendMu and must never share mutable backing storage.
42- pipelineCache composeCacheInput
43- pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46- resizePaint pendingByteTimer // guarded by sendMu
47- resizePaintGeneration uint64 // guarded by sendMu
48- resizePaintPending bool // guarded by sendMu
49- size domain.Size
50- keys *keys.Router
51- sess Guarded[*session]
52- mouseScan mouse.Scanner
53- themeMu sync.Mutex
54- theme themeui.Theme
55- clientTheme themeui.Theme
56- lastCursor cursorOut
57- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
42+ pipelineCache composeCacheInput
43+ pipelineScratch composeCacheInput
44+ renderScratch renderCaptureScratch // only touched while sendMu is held
45+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46+ size domain.Size
47+ keys *keys.Router
48+ sess Guarded[*session]
49+ mouseScan mouse.Scanner
50+ themeMu sync.Mutex
51+ theme themeui.Theme
52+ clientTheme themeui.Theme
53+ lastCursor cursorOut
54+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
5855 // previousSession is guarded independently. It is retained through temporary
5956 // setSession(nil) hand-offs and cleared only on terminal teardown.
6057 previousSession Guarded[*session]
@@ -71,6 +68,8 @@ type cursorOut struct {
7168 hasStyle bool
7269 }
7370
71+// pendingByteTimer is retained for overlay input timers. Resize timing belongs
72+// exclusively to renderCoordinator.
7473 type pendingByteTimer struct {
7574 timer ports.Timer
7675 done chan struct{}
@@ -100,16 +99,6 @@ func (p *pendingByteTimer) stop() {
10099 }
101100 }
102101
103-// cancelResizePaint invalidates callbacks before detaching or replacing an
104-// attachment. It deliberately takes sendMu before any session/daemon lock.
105-func (ac *attachedClient) cancelResizePaint() {
106- ac.sendMu.Lock()
107- ac.resizePaintGeneration++
108- ac.resizePaintPending = false
109- ac.resizePaint.stop()
110- ac.sendMu.Unlock()
111-}
112-
113102 func (ac *attachedClient) initOverlays() {
114103 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
115104 }
@@ -413,7 +402,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
413402 // paint occurs under sendMu, closing a park/resume race after this
414403 // unlocked coordinator validation.
415404 if w.attachment != nil && rc.wakeCurrent(w) {
416- d.paintCoordinatorWake(sess, w)
405+ d.paint(sess, w.attachment, w.reset, w.attachmentEpoch)
417406 }
418407 },
419408 ackReady: func() bool {
@@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
459448 if old == nil {
460449 return
461450 }
462- old.cancelResizePaint()
463-
464451 d.unregisterPreview(old)
465452 old.clearPreviousSession()
466453 old.setSession(nil)
@@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
487474 d.resizeForFirstPaint(sess, ac, clientSize)
488475 }
489476 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
490- d.invalidateRenderNow(sess, ac, true, "client.go")
477+ // Activation can synchronously resize a retained floating pane through the
478+ // same transaction. Fold its invalidation into this mandatory Welcome frame.
491479 d.activateTab(sess, tb)
480+ d.invalidateRenderNow(sess, ac, true, "client.go")
492481 }
493482
494483 // runConnLoop is the per-connection input router: it pumps client messages
@@ -563,7 +552,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
563552 if !sess.detachIfCurrent(ac) {
564553 return // already displaced by a newer client; nothing to do
565554 }
566- ac.cancelResizePaint()
567555 if rc := sess.renderCoordinator(); rc != nil {
568556 rc.noteDetach(ac)
569557 }
@@ -621,57 +609,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
621609 }
622610
623611 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
624- d.resizeWithPaint(sess, ac, sz, true)
612+ d.requestTransactionalResize(sess, ac, sz, false)
625613 }
626614
627-// resizeForFirstPaint applies attach geometry without installing an idle timer:
628-// firstPaint immediately emits the required full frame itself.
615+// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
616+// firstPaint caller emits its full frame immediately after this commit.
629617 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
630- d.resizeWithPaint(sess, ac, sz, false)
631-}
632-
633-func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
634- if !sz.Valid() {
635- return
636- }
637- d.exitCopyMode(ac)
638- if ac != nil {
639- // Suspend emission until every pane has adopted this geometry and the
640- // bounded resize paint is armed. PTY readers remain independent.
641- ac.sendMu.Lock()
642- defer ac.sendMu.Unlock()
643- if ac.currentSession() != sess {
644- return
645- }
646- ac.size = sz
647- if rc := sess.renderCoordinator(); rc != nil {
648- rc.attach(ac)
649- rc.recordResizeRequest(sz, ac)
650- }
651- }
652- tbSize := tabSize(sz)
653-
654- sess.mu.Lock()
655- tabs := append([]*tab(nil), sess.tabs...)
656- sess.mu.Unlock()
657- if len(tabs) == 0 {
658- return
659- }
660-
661- for _, tb := range tabs {
662- tb.mu.Lock()
663- tb.size = tbSize
664- d.applyLayoutLocked(tb)
665- tb.mu.Unlock()
666- }
667- // Only the shown tab's floating terminal tracks the client size. This is
668- // outside tab.mu so its PTY resize cannot block tab state.
669- d.resizeActiveFloating(sess.activeTab())
670- markSnapshotDirty(sess)
671- if ac != nil && schedulePaint {
672- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
673- d.scheduleResizePaintLocked(sess, ac)
674- }
618+ d.requestTransactionalResize(sess, ac, sz, true)
675619 }
676620
677621 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -681,7 +625,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
681625 return
682626 }
683627 if sess.detachIfCurrent(ac) {
684- ac.cancelResizePaint()
685628 if rc := sess.renderCoordinator(); rc != nil {
686629 rc.noteDetach(ac)
687630 }
@@ -39,22 +39,19 @@ type attachedClient struct {
39 // pipelineCache is the last successfully emitted composition. pipelineScratch39 // pipelineCache is the last successfully emitted composition. pipelineScratch
40 // is its attachment-owned alternate buffer; both are only touched under40 // is its attachment-owned alternate buffer; both are only touched under
41 // sendMu and must never share mutable backing storage.41 // sendMu and must never share mutable backing storage.
42- pipelineCache composeCacheInput42+ pipelineCache composeCacheInput
43- pipelineScratch composeCacheInput43+ pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held44+ renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held45+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46- resizePaint pendingByteTimer // guarded by sendMu46+ size domain.Size
47- resizePaintGeneration uint64 // guarded by sendMu47+ keys *keys.Router
48- resizePaintPending bool // guarded by sendMu48+ sess Guarded[*session]
49- size domain.Size49+ mouseScan mouse.Scanner
50- keys *keys.Router50+ themeMu sync.Mutex
51- sess Guarded[*session]51+ theme themeui.Theme
52- mouseScan mouse.Scanner52+ clientTheme themeui.Theme
53- themeMu sync.Mutex53+ lastCursor cursorOut
54- theme themeui.Theme54+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
55- clientTheme themeui.Theme
56- lastCursor cursorOut
57- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
58 // previousSession is guarded independently. It is retained through temporary55 // previousSession is guarded independently. It is retained through temporary
59 // setSession(nil) hand-offs and cleared only on terminal teardown.56 // setSession(nil) hand-offs and cleared only on terminal teardown.
60 previousSession Guarded[*session]57 previousSession Guarded[*session]
@@ -71,6 +68,8 @@ type cursorOut struct {
71 hasStyle bool68 hasStyle bool
72 }69 }
73 70
71+// pendingByteTimer is retained for overlay input timers. Resize timing belongs
72+// exclusively to renderCoordinator.
74 type pendingByteTimer struct {73 type pendingByteTimer struct {
75 timer ports.Timer74 timer ports.Timer
76 done chan struct{}75 done chan struct{}
@@ -100,16 +99,6 @@ func (p *pendingByteTimer) stop() {
100 }99 }
101 }100 }
102 101
103-// cancelResizePaint invalidates callbacks before detaching or replacing an
104-// attachment. It deliberately takes sendMu before any session/daemon lock.
105-func (ac *attachedClient) cancelResizePaint() {
106- ac.sendMu.Lock()
107- ac.resizePaintGeneration++
108- ac.resizePaintPending = false
109- ac.resizePaint.stop()
110- ac.sendMu.Unlock()
111-}
112-
113 func (ac *attachedClient) initOverlays() {102 func (ac *attachedClient) initOverlays() {
114 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })103 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
115 }104 }
@@ -413,7 +402,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
413 // paint occurs under sendMu, closing a park/resume race after this402 // paint occurs under sendMu, closing a park/resume race after this
414 // unlocked coordinator validation.403 // unlocked coordinator validation.
415 if w.attachment != nil && rc.wakeCurrent(w) {404 if w.attachment != nil && rc.wakeCurrent(w) {
416- d.paintCoordinatorWake(sess, w)405+ d.paint(sess, w.attachment, w.reset, w.attachmentEpoch)
417 }406 }
418 },407 },
419 ackReady: func() bool {408 ackReady: func() bool {
@@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
459 if old == nil {448 if old == nil {
460 return449 return
461 }450 }
462- old.cancelResizePaint()
463-
464 d.unregisterPreview(old)451 d.unregisterPreview(old)
465 old.clearPreviousSession()452 old.clearPreviousSession()
466 old.setSession(nil)453 old.setSession(nil)
@@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
487 d.resizeForFirstPaint(sess, ac, clientSize)474 d.resizeForFirstPaint(sess, ac, clientSize)
488 }475 }
489 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)476 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
490- d.invalidateRenderNow(sess, ac, true, "client.go")477+ // Activation can synchronously resize a retained floating pane through the
478+ // same transaction. Fold its invalidation into this mandatory Welcome frame.
491 d.activateTab(sess, tb)479 d.activateTab(sess, tb)
480+ d.invalidateRenderNow(sess, ac, true, "client.go")
492 }481 }
493 482
494 // runConnLoop is the per-connection input router: it pumps client messages483 // runConnLoop is the per-connection input router: it pumps client messages
@@ -563,7 +552,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
563 if !sess.detachIfCurrent(ac) {552 if !sess.detachIfCurrent(ac) {
564 return // already displaced by a newer client; nothing to do553 return // already displaced by a newer client; nothing to do
565 }554 }
566- ac.cancelResizePaint()
567 if rc := sess.renderCoordinator(); rc != nil {555 if rc := sess.renderCoordinator(); rc != nil {
568 rc.noteDetach(ac)556 rc.noteDetach(ac)
569 }557 }
@@ -621,57 +609,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
621 }609 }
622 610
623 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {611 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
624- d.resizeWithPaint(sess, ac, sz, true)612+ d.requestTransactionalResize(sess, ac, sz, false)
625 }613 }
626 614
627-// resizeForFirstPaint applies attach geometry without installing an idle timer:615+// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
628-// firstPaint immediately emits the required full frame itself.616+// firstPaint caller emits its full frame immediately after this commit.
629 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {617 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
630- d.resizeWithPaint(sess, ac, sz, false)618+ d.requestTransactionalResize(sess, ac, sz, true)
631-}
632-
633-func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
634- if !sz.Valid() {
635- return
636- }
637- d.exitCopyMode(ac)
638- if ac != nil {
639- // Suspend emission until every pane has adopted this geometry and the
640- // bounded resize paint is armed. PTY readers remain independent.
641- ac.sendMu.Lock()
642- defer ac.sendMu.Unlock()
643- if ac.currentSession() != sess {
644- return
645- }
646- ac.size = sz
647- if rc := sess.renderCoordinator(); rc != nil {
648- rc.attach(ac)
649- rc.recordResizeRequest(sz, ac)
650- }
651- }
652- tbSize := tabSize(sz)
653-
654- sess.mu.Lock()
655- tabs := append([]*tab(nil), sess.tabs...)
656- sess.mu.Unlock()
657- if len(tabs) == 0 {
658- return
659- }
660-
661- for _, tb := range tabs {
662- tb.mu.Lock()
663- tb.size = tbSize
664- d.applyLayoutLocked(tb)
665- tb.mu.Unlock()
666- }
667- // Only the shown tab's floating terminal tracks the client size. This is
668- // outside tab.mu so its PTY resize cannot block tab state.
669- d.resizeActiveFloating(sess.activeTab())
670- markSnapshotDirty(sess)
671- if ac != nil && schedulePaint {
672- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
673- d.scheduleResizePaintLocked(sess, ac)
674- }
675 }619 }
676 620
677 // detachOnSendError drops a client whose transport failed, leaving the session621 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -681,7 +625,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
681 return625 return
682 }626 }
683 if sess.detachIfCurrent(ac) {627 if sess.detachIfCurrent(ac) {
684- ac.cancelResizePaint()
685 if rc := sess.renderCoordinator(); rc != nil {628 if rc := sess.renderCoordinator(); rc != nil {
686 rc.noteDetach(ac)629 rc.noteDetach(ac)
687 }630 }
M internal/usecase/daemon/daemon.go +0 -1
@@ -22,7 +22,6 @@ import (
2222 // sustained floods step toward the maximum to reduce frame/syscall pressure.
2323 const (
2424 minDebounceInterval = 2 * time.Millisecond
25- maxDebounceInterval = 16 * time.Millisecond
2625 maxSyncUpdateDuration = 500 * time.Millisecond
2726 )
2827
@@ -22,7 +22,6 @@ import (
22 // sustained floods step toward the maximum to reduce frame/syscall pressure.22 // sustained floods step toward the maximum to reduce frame/syscall pressure.
23 const (23 const (
24 minDebounceInterval = 2 * time.Millisecond24 minDebounceInterval = 2 * time.Millisecond
25- maxDebounceInterval = 16 * time.Millisecond
26 maxSyncUpdateDuration = 500 * time.Millisecond25 maxSyncUpdateDuration = 500 * time.Millisecond
27 )26 )
28 27
M internal/usecase/daemon/daemon_test.go +5 -5
@@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
142142 return f
143143 }
144144
145-func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) {
145+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
146146 <-f.release
147147 return nil, io.ErrClosedPipe
148148 }
@@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
154154 t.Helper()
155155 f := portsmocks.NewMockPTYFactory(t)
156156 var normal domain.Size
157- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159159 if !normal.Valid() {
160160 normal = sz
161161 }
@@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
174174 var mu sync.Mutex
175175 var normal domain.Size
176176 next := 0
177- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
177+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
179179 mu.Lock()
180180 defer mu.Unlock()
181181 if !normal.Valid() {
@@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
142 return f142 return f
143 }143 }
144 144
145-func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) {145+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
146 <-f.release146 <-f.release
147 return nil, io.ErrClosedPipe147 return nil, io.ErrClosedPipe
148 }148 }
@@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
154 t.Helper()154 t.Helper()
155 f := portsmocks.NewMockPTYFactory(t)155 f := portsmocks.NewMockPTYFactory(t)
156 var normal domain.Size156 var normal domain.Size
157- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {158+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159 if !normal.Valid() {159 if !normal.Valid() {
160 normal = sz160 normal = sz
161 }161 }
@@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
174 var mu sync.Mutex174 var mu sync.Mutex
175 var normal domain.Size175 var normal domain.Size
176 next := 0176 next := 0
177- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(177+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {178+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
179 mu.Lock()179 mu.Lock()
180 defer mu.Unlock()180 defer mu.Unlock()
181 if !normal.Valid() {181 if !normal.Valid() {
M internal/usecase/daemon/floating.go +72 -12
@@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
169169 return
170170 }
171171 d.ensureFloatingWarm(sess, tb)
172- d.resizeInstalledFloating(tb)
172+ tb.mu.Lock()
173+ hasFloating := tb.floating.pane != nil
174+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175+ tb.mu.Unlock()
176+ if hasFloating {
177+ d.requestTransactionalResize(sess, ac, size, true)
178+ }
173179 }
174180
175181 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -197,10 +203,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
197203 return nil
198204 }
199205 if visible {
200- d.resizeActiveFloating(tb)
201- }
202- if ac != nil {
203- d.invalidateRender(sess, ac, true, "floating.go")
206+ tb.mu.Lock()
207+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
208+ tb.mu.Unlock()
209+ d.requestTransactionalResize(sess, ac, size, true)
204210 }
205211 return nil
206212 }
@@ -218,6 +224,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
218224
219225 // floatingLaunchSpec is an immutable snapshot of one launch. It is built
220226 // before the worker starts so config reloads cannot affect that launch.
227+// floatingLaunch owns one worker from queueing through installation or stale
228+// result cleanup. Its done channel lets session teardown reap an in-flight
229+// Open before returning.
230+type floatingLaunch struct {
231+ done chan struct{}
232+}
233+
234+func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
235+ launch := &floatingLaunch{done: make(chan struct{})}
236+ sess.floatingLaunchMu.Lock()
237+ defer sess.floatingLaunchMu.Unlock()
238+ if sess.floatingLaunchStopping {
239+ return nil, false
240+ }
241+ if sess.floatingLaunches == nil {
242+ sess.floatingLaunches = make(map[*floatingLaunch]struct{})
243+ }
244+ sess.floatingLaunches[launch] = struct{}{}
245+ return launch, true
246+}
247+
248+func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
249+ sess.floatingLaunchMu.Lock()
250+ delete(sess.floatingLaunches, launch)
251+ close(launch.done)
252+ sess.floatingLaunchMu.Unlock()
253+}
254+
255+func (sess *session) stopFloatingLaunches() {
256+ sess.floatingLaunchMu.Lock()
257+ sess.floatingLaunchStopping = true
258+ sess.floatingLaunchMu.Unlock()
259+}
260+
221261 type floatingLaunchSpec struct {
222262 sessionName string
223263 cwd string
@@ -240,10 +280,18 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
240280 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
241281 return
242282 }
283+ launch, ok := sess.registerFloatingLaunch()
284+ if !ok {
285+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
286+ return
287+ }
243288 // Count the launch before its goroutine starts. It remains counted through
244289 // install, including the reader/coordinator Adds, so shutdown cannot return
245290 // before a late Open completion has either been rejected or fully joined.
246- d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })
291+ d.sessWg.Go(func() {
292+ defer sess.finishFloatingLaunch(launch)
293+ d.openAndInstallFloating(sess, tb, spec, generation, launch)
294+ })
247295 }
248296
249297 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
@@ -299,8 +347,20 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
299347
300348 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
301349 // occurs under tb.mu; the pane is fully initialized before publication.
302-func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
303- pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
350+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
351+ // Open is context-aware and is intentionally called without any daemon,
352+ // session, tab, or launch-ownership lock. Cancellation makes teardown
353+ // bounded even if the adapter is waiting to create the child.
354+ if err := spec.parentCtx.Err(); err != nil {
355+ return
356+ }
357+ openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
358+ stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
359+ defer func() {
360+ stopSessionCancel()
361+ cancelOpen()
362+ }()
363+ pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
304364 if err != nil {
305365 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
306366 return
@@ -343,13 +403,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
343403 d.reapplyThemeSession(sess)
344404 d.startPaneGoroutines(sess, tb, p)
345405 if visible {
346- d.resizeActiveFloating(tb)
347406 sess.mu.Lock()
348407 ac := sess.client
349408 sess.mu.Unlock()
350- if ac != nil {
351- d.invalidateRender(sess, ac, true, "floating.go")
352- }
409+ tb.mu.Lock()
410+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
411+ tb.mu.Unlock()
412+ d.requestTransactionalResize(sess, ac, size, true)
353413 }
354414 }
355415
@@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
169 return169 return
170 }170 }
171 d.ensureFloatingWarm(sess, tb)171 d.ensureFloatingWarm(sess, tb)
172- d.resizeInstalledFloating(tb)172+ tb.mu.Lock()
173+ hasFloating := tb.floating.pane != nil
174+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175+ tb.mu.Unlock()
176+ if hasFloating {
177+ d.requestTransactionalResize(sess, ac, size, true)
178+ }
173 }179 }
174 180
175 // toggleFloating changes only this tab's slot. Opening an uninitialized slot181 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -197,10 +203,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
197 return nil203 return nil
198 }204 }
199 if visible {205 if visible {
200- d.resizeActiveFloating(tb)206+ tb.mu.Lock()
201- }207+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
202- if ac != nil {208+ tb.mu.Unlock()
203- d.invalidateRender(sess, ac, true, "floating.go")209+ d.requestTransactionalResize(sess, ac, size, true)
204 }210 }
205 return nil211 return nil
206 }212 }
@@ -218,6 +224,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
218 224
219 // floatingLaunchSpec is an immutable snapshot of one launch. It is built225 // floatingLaunchSpec is an immutable snapshot of one launch. It is built
220 // before the worker starts so config reloads cannot affect that launch.226 // before the worker starts so config reloads cannot affect that launch.
227+// floatingLaunch owns one worker from queueing through installation or stale
228+// result cleanup. Its done channel lets session teardown reap an in-flight
229+// Open before returning.
230+type floatingLaunch struct {
231+ done chan struct{}
232+}
233+
234+func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
235+ launch := &floatingLaunch{done: make(chan struct{})}
236+ sess.floatingLaunchMu.Lock()
237+ defer sess.floatingLaunchMu.Unlock()
238+ if sess.floatingLaunchStopping {
239+ return nil, false
240+ }
241+ if sess.floatingLaunches == nil {
242+ sess.floatingLaunches = make(map[*floatingLaunch]struct{})
243+ }
244+ sess.floatingLaunches[launch] = struct{}{}
245+ return launch, true
246+}
247+
248+func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
249+ sess.floatingLaunchMu.Lock()
250+ delete(sess.floatingLaunches, launch)
251+ close(launch.done)
252+ sess.floatingLaunchMu.Unlock()
253+}
254+
255+func (sess *session) stopFloatingLaunches() {
256+ sess.floatingLaunchMu.Lock()
257+ sess.floatingLaunchStopping = true
258+ sess.floatingLaunchMu.Unlock()
259+}
260+
221 type floatingLaunchSpec struct {261 type floatingLaunchSpec struct {
222 sessionName string262 sessionName string
223 cwd string263 cwd string
@@ -240,10 +280,18 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
240 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)280 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
241 return281 return
242 }282 }
283+ launch, ok := sess.registerFloatingLaunch()
284+ if !ok {
285+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
286+ return
287+ }
243 // Count the launch before its goroutine starts. It remains counted through288 // Count the launch before its goroutine starts. It remains counted through
244 // install, including the reader/coordinator Adds, so shutdown cannot return289 // install, including the reader/coordinator Adds, so shutdown cannot return
245 // before a late Open completion has either been rejected or fully joined.290 // before a late Open completion has either been rejected or fully joined.
246- d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })291+ d.sessWg.Go(func() {
292+ defer sess.finishFloatingLaunch(launch)
293+ d.openAndInstallFloating(sess, tb, spec, generation, launch)
294+ })
247 }295 }
248 296
249 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {297 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
@@ -299,8 +347,20 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
299 347
300 // openAndInstallFloating runs entirely in the launch worker. No PTY operation348 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
301 // occurs under tb.mu; the pane is fully initialized before publication.349 // occurs under tb.mu; the pane is fully initialized before publication.
302-func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {350+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
303- pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)351+ // Open is context-aware and is intentionally called without any daemon,
352+ // session, tab, or launch-ownership lock. Cancellation makes teardown
353+ // bounded even if the adapter is waiting to create the child.
354+ if err := spec.parentCtx.Err(); err != nil {
355+ return
356+ }
357+ openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
358+ stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
359+ defer func() {
360+ stopSessionCancel()
361+ cancelOpen()
362+ }()
363+ pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
304 if err != nil {364 if err != nil {
305 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)365 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
306 return366 return
@@ -343,13 +403,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
343 d.reapplyThemeSession(sess)403 d.reapplyThemeSession(sess)
344 d.startPaneGoroutines(sess, tb, p)404 d.startPaneGoroutines(sess, tb, p)
345 if visible {405 if visible {
346- d.resizeActiveFloating(tb)
347 sess.mu.Lock()406 sess.mu.Lock()
348 ac := sess.client407 ac := sess.client
349 sess.mu.Unlock()408 sess.mu.Unlock()
350- if ac != nil {409+ tb.mu.Lock()
351- d.invalidateRender(sess, ac, true, "floating.go")410+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
352- }411+ tb.mu.Unlock()
412+ d.requestTransactionalResize(sess, ac, size, true)
353 }413 }
354 }414 }
355 415
A internal/usecase/daemon/floating_open_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+)
16+
17+func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
18+ factory := portsmocks.NewMockPTYFactory(t)
19+ opened := make(chan struct{})
20+ latePTY := portsmocks.NewMockPTY(t)
21+ closed := make(chan struct{})
22+ latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
23+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
24+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
25+ close(opened)
26+ <-ctx.Done()
27+ return latePTY, nil
28+ }).Once()
29+ d := newTestDaemon(t, factory, stubClock{})
30+ tb := newFloatingTestTab(t)
31+ ctx, cancel := context.WithCancel(t.Context())
32+ sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
33+ d.mu.Lock()
34+ d.sessions[sess.id] = sess
35+ d.mu.Unlock()
36+ d.startFloating(sess, tb, true)
37+ select {
38+ case <-opened:
39+ case <-time.After(time.Second):
40+ t.Fatal("floating Open did not begin")
41+ }
42+
43+ killed := make(chan error, 1)
44+ go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
45+ select {
46+ case err := <-killed:
47+ require.NoError(t, err)
48+ case <-time.After(time.Second):
49+ t.Fatal("session teardown waited for blocked Open")
50+ }
51+ select {
52+ case <-closed:
53+ case <-time.After(time.Second):
54+ t.Fatal("late Open PTY was not closed")
55+ }
56+ tb.mu.Lock()
57+ require.Nil(t, tb.floating.pane, "late PTY must not be installed")
58+ tb.mu.Unlock()
59+}
60+
61+func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
62+ factory := portsmocks.NewMockPTYFactory(t)
63+ entered := make(chan struct{}, 2)
64+ release := make(chan struct{})
65+ var once sync.Once
66+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
67+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
68+ entered <- struct{}{}
69+ <-release
70+ return newQuietPTY(), nil
71+ }).Twice()
72+ d := newTestDaemon(t, factory, stubClock{})
73+ tabs := make([]*tab, 0, 2)
74+ cancels := make([]context.CancelFunc, 0, 2)
75+ for _, id := range []domain.SessionID{"one", "two"} {
76+ tb := newFloatingTestTab(t)
77+ ctx, cancel := context.WithCancel(t.Context())
78+ sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
79+ tabs = append(tabs, tb)
80+ cancels = append(cancels, cancel)
81+ d.startFloating(sess, tb, false)
82+ }
83+ for range 2 {
84+ select {
85+ case <-entered:
86+ case <-time.After(time.Second):
87+ t.Fatal("unrelated floating launch was serialized")
88+ }
89+ }
90+ once.Do(func() { close(release) })
91+ for i, cancel := range cancels {
92+ cancel()
93+ d.teardownFloating(tabs[i], nil)
94+ }
95+ d.sessWg.Wait()
96+}
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+)
16+
17+func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
18+ factory := portsmocks.NewMockPTYFactory(t)
19+ opened := make(chan struct{})
20+ latePTY := portsmocks.NewMockPTY(t)
21+ closed := make(chan struct{})
22+ latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
23+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
24+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
25+ close(opened)
26+ <-ctx.Done()
27+ return latePTY, nil
28+ }).Once()
29+ d := newTestDaemon(t, factory, stubClock{})
30+ tb := newFloatingTestTab(t)
31+ ctx, cancel := context.WithCancel(t.Context())
32+ sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
33+ d.mu.Lock()
34+ d.sessions[sess.id] = sess
35+ d.mu.Unlock()
36+ d.startFloating(sess, tb, true)
37+ select {
38+ case <-opened:
39+ case <-time.After(time.Second):
40+ t.Fatal("floating Open did not begin")
41+ }
42+
43+ killed := make(chan error, 1)
44+ go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
45+ select {
46+ case err := <-killed:
47+ require.NoError(t, err)
48+ case <-time.After(time.Second):
49+ t.Fatal("session teardown waited for blocked Open")
50+ }
51+ select {
52+ case <-closed:
53+ case <-time.After(time.Second):
54+ t.Fatal("late Open PTY was not closed")
55+ }
56+ tb.mu.Lock()
57+ require.Nil(t, tb.floating.pane, "late PTY must not be installed")
58+ tb.mu.Unlock()
59+}
60+
61+func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
62+ factory := portsmocks.NewMockPTYFactory(t)
63+ entered := make(chan struct{}, 2)
64+ release := make(chan struct{})
65+ var once sync.Once
66+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
67+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
68+ entered <- struct{}{}
69+ <-release
70+ return newQuietPTY(), nil
71+ }).Twice()
72+ d := newTestDaemon(t, factory, stubClock{})
73+ tabs := make([]*tab, 0, 2)
74+ cancels := make([]context.CancelFunc, 0, 2)
75+ for _, id := range []domain.SessionID{"one", "two"} {
76+ tb := newFloatingTestTab(t)
77+ ctx, cancel := context.WithCancel(t.Context())
78+ sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
79+ tabs = append(tabs, tb)
80+ cancels = append(cancels, cancel)
81+ d.startFloating(sess, tb, false)
82+ }
83+ for range 2 {
84+ select {
85+ case <-entered:
86+ case <-time.After(time.Second):
87+ t.Fatal("unrelated floating launch was serialized")
88+ }
89+ }
90+ once.Do(func() { close(release) })
91+ for i, cancel := range cancels {
92+ cancel()
93+ d.teardownFloating(tabs[i], nil)
94+ }
95+ d.sessWg.Wait()
96+}
M internal/usecase/daemon/floating_render.go +0 -67
@@ -133,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
133133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
134134 }
135135 }
136-
137-// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
138-// is intentionally outside pane.mu; failed resizes do not publish any state.
139-func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
140- inner := geometry.Inner
141- if p == nil || !geometry.valid() {
142- return false
143- }
144- p.resizeMu.Lock()
145- defer p.resizeMu.Unlock()
146-
147- p.mu.Lock()
148- old := p.rect
149- pty := p.pty
150- p.mu.Unlock()
151- if old.Width == inner.Width && old.Height == inner.Height {
152- // Position and outer bounds can change while the terminal dimensions do
153- // not. Commit that geometry without needlessly resetting the PTY/screen.
154- p.mu.Lock()
155- p.rect = inner
156- p.popupGeometry = geometry
157- p.mu.Unlock()
158- return true
159- }
160- if pty != nil {
161- if err := pty.Resize(rectSize(inner)); err != nil {
162- d.log.Warn("floating pty resize failed", "err", err)
163- return false
164- }
165- }
166- p.mu.Lock()
167- p.screen.Resize(inner.Width, inner.Height)
168- p.rect = inner
169- p.popupGeometry = geometry
170- p.mu.Unlock()
171- return true
172-}
173-
174-// resizeInstalledFloating updates an installed floating pane, including a
175-// retained hidden pane when its tab becomes active.
176-func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
177- if d == nil || tb == nil {
178- return false
179- }
180- tb.mu.Lock()
181- p := tb.floating.pane
182- content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}
183- tb.mu.Unlock()
184- if p == nil {
185- return false
186- }
187- return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig()))
188-}
189-
190-// resizeActiveFloating updates a visible floating pane during a client resize.
191-func (d *Daemon) resizeActiveFloating(tb *tab) bool {
192- if d == nil || tb == nil {
193- return false
194- }
195- tb.mu.Lock()
196- visible := tb.floating.state == floatingVisible
197- tb.mu.Unlock()
198- if !visible {
199- return false
200- }
201- return d.resizeInstalledFloating(tb)
202-}
@@ -133,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
134 }134 }
135 }135 }
136-
137-// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
138-// is intentionally outside pane.mu; failed resizes do not publish any state.
139-func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
140- inner := geometry.Inner
141- if p == nil || !geometry.valid() {
142- return false
143- }
144- p.resizeMu.Lock()
145- defer p.resizeMu.Unlock()
146-
147- p.mu.Lock()
148- old := p.rect
149- pty := p.pty
150- p.mu.Unlock()
151- if old.Width == inner.Width && old.Height == inner.Height {
152- // Position and outer bounds can change while the terminal dimensions do
153- // not. Commit that geometry without needlessly resetting the PTY/screen.
154- p.mu.Lock()
155- p.rect = inner
156- p.popupGeometry = geometry
157- p.mu.Unlock()
158- return true
159- }
160- if pty != nil {
161- if err := pty.Resize(rectSize(inner)); err != nil {
162- d.log.Warn("floating pty resize failed", "err", err)
163- return false
164- }
165- }
166- p.mu.Lock()
167- p.screen.Resize(inner.Width, inner.Height)
168- p.rect = inner
169- p.popupGeometry = geometry
170- p.mu.Unlock()
171- return true
172-}
173-
174-// resizeInstalledFloating updates an installed floating pane, including a
175-// retained hidden pane when its tab becomes active.
176-func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
177- if d == nil || tb == nil {
178- return false
179- }
180- tb.mu.Lock()
181- p := tb.floating.pane
182- content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}
183- tb.mu.Unlock()
184- if p == nil {
185- return false
186- }
187- return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig()))
188-}
189-
190-// resizeActiveFloating updates a visible floating pane during a client resize.
191-func (d *Daemon) resizeActiveFloating(tb *tab) bool {
192- if d == nil || tb == nil {
193- return false
194- }
195- tb.mu.Lock()
196- visible := tb.floating.state == floatingVisible
197- tb.mu.Unlock()
198- if !visible {
199- return false
200- }
201- return d.resizeInstalledFloating(tb)
202-}
M internal/usecase/daemon/floating_render_test.go +28 -9
@@ -14,6 +14,25 @@ import (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
17+// applyFloatingResizePlanForTest exercises the transactional apply primitive.
18+func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool {
19+ if p == nil || !geometry.valid() {
20+ return false
21+ }
22+ plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
23+ d.applyResize(&plan)
24+ member := plan.members[0]
25+ if !member.ok {
26+ return false
27+ }
28+ p.mu.Lock()
29+ p.rect = geometry.Inner
30+ p.popupGeometry = geometry
31+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
32+ p.mu.Unlock()
33+ return true
34+}
35+
1736 func TestFloatingGeometryTranslate(t *testing.T) {
1837 geometry := floatingGeometry{
1938 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
@@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
200219 p.rect, p.popupGeometry = initial.Inner, initial
201220 d := newTestDaemon(t, nil, stubClock{})
202221
203- require.True(t, d.resizeFloatingPane(p, tt.next))
222+ require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
204223 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
205224 require.Equal(t, tt.next.Inner, p.rect)
206225 require.Equal(t, tt.next, p.popupGeometry)
@@ -329,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
329348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
330349 require.Empty(t, pty.sizes())
331350
332- // Showing attempts the current size before paint. A failed resize leaves the
333- // old screen and rect untouched, so a later hide/show can retry.
351+ // Showing attempts the current size before paint. A failed resize retains
352+ // the old screen, but commits the new popup rect so capture clips/pads it.
334353 require.NoError(t, d.toggleFloating(sess, nil))
335354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
336- require.Equal(t, initial, floating.rect)
355+ require.Equal(t, current, floating.rect)
337356 require.Equal(t, initial.Width, floating.screen.Frame.Width)
338357 require.Equal(t, initial.Height, floating.screen.Frame.Height)
339- require.Equal(t, initialGeometry, floating.popupGeometry)
358+ require.Equal(t, currentGeometry, floating.popupGeometry)
340359
341360 require.NoError(t, d.toggleFloating(sess, nil)) // hide
342361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
362381 d := newTestDaemon(t, nil, stubClock{})
363382 d.ApplyConfig(domain.Config{Floating: cfg})
364383
365- require.False(t, d.resizeFloatingPane(p, newGeometry))
384+ require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
366385 tb.mu.Lock()
367386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
368387 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
@@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
388407 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
389408 d := newTestDaemon(t, nil, stubClock{})
390409 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
391- require.False(t, d.resizeFloatingPane(p, requested))
410+ require.False(t, applyFloatingResizePlanForTest(d, p, requested))
392411 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
393412 require.Equal(t, floatingGeometry{}, p.popupGeometry)
394413 require.Equal(t, 5, p.screen.Frame.Width)
@@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
404423 done1 := make(chan bool, 1)
405424 done2 := make(chan bool, 1)
406425 secondStarted := make(chan struct{})
407- go func() { done1 <- d.resizeFloatingPane(p, first) }()
426+ go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
408427 <-pty.entered
409428 go func() {
410429 close(secondStarted)
411- done2 <- d.resizeFloatingPane(p, second)
430+ done2 <- applyFloatingResizePlanForTest(d, p, second)
412431 }()
413432 <-secondStarted
414433 close(pty.release)
@@ -14,6 +14,25 @@ import (
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15 )15 )
16 16
17+// applyFloatingResizePlanForTest exercises the transactional apply primitive.
18+func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool {
19+ if p == nil || !geometry.valid() {
20+ return false
21+ }
22+ plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
23+ d.applyResize(&plan)
24+ member := plan.members[0]
25+ if !member.ok {
26+ return false
27+ }
28+ p.mu.Lock()
29+ p.rect = geometry.Inner
30+ p.popupGeometry = geometry
31+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
32+ p.mu.Unlock()
33+ return true
34+}
35+
17 func TestFloatingGeometryTranslate(t *testing.T) {36 func TestFloatingGeometryTranslate(t *testing.T) {
18 geometry := floatingGeometry{37 geometry := floatingGeometry{
19 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},38 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
@@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
200 p.rect, p.popupGeometry = initial.Inner, initial219 p.rect, p.popupGeometry = initial.Inner, initial
201 d := newTestDaemon(t, nil, stubClock{})220 d := newTestDaemon(t, nil, stubClock{})
202 221
203- require.True(t, d.resizeFloatingPane(p, tt.next))222+ require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
204 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")223 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
205 require.Equal(t, tt.next.Inner, p.rect)224 require.Equal(t, tt.next.Inner, p.rect)
206 require.Equal(t, tt.next, p.popupGeometry)225 require.Equal(t, tt.next, p.popupGeometry)
@@ -329,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
329 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
330 require.Empty(t, pty.sizes())349 require.Empty(t, pty.sizes())
331 350
332- // Showing attempts the current size before paint. A failed resize leaves the351+ // Showing attempts the current size before paint. A failed resize retains
333- // old screen and rect untouched, so a later hide/show can retry.352+ // the old screen, but commits the new popup rect so capture clips/pads it.
334 require.NoError(t, d.toggleFloating(sess, nil))353 require.NoError(t, d.toggleFloating(sess, nil))
335 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
336- require.Equal(t, initial, floating.rect)355+ require.Equal(t, current, floating.rect)
337 require.Equal(t, initial.Width, floating.screen.Frame.Width)356 require.Equal(t, initial.Width, floating.screen.Frame.Width)
338 require.Equal(t, initial.Height, floating.screen.Frame.Height)357 require.Equal(t, initial.Height, floating.screen.Frame.Height)
339- require.Equal(t, initialGeometry, floating.popupGeometry)358+ require.Equal(t, currentGeometry, floating.popupGeometry)
340 359
341 require.NoError(t, d.toggleFloating(sess, nil)) // hide360 require.NoError(t, d.toggleFloating(sess, nil)) // hide
342 require.NoError(t, d.toggleFloating(sess, nil)) // retry show361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
362 d := newTestDaemon(t, nil, stubClock{})381 d := newTestDaemon(t, nil, stubClock{})
363 d.ApplyConfig(domain.Config{Floating: cfg})382 d.ApplyConfig(domain.Config{Floating: cfg})
364 383
365- require.False(t, d.resizeFloatingPane(p, newGeometry))384+ require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
366 tb.mu.Lock()385 tb.mu.Lock()
367 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
368 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))387 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
@@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
388 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}407 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
389 d := newTestDaemon(t, nil, stubClock{})408 d := newTestDaemon(t, nil, stubClock{})
390 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}409 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
391- require.False(t, d.resizeFloatingPane(p, requested))410+ require.False(t, applyFloatingResizePlanForTest(d, p, requested))
392 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)411 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
393 require.Equal(t, floatingGeometry{}, p.popupGeometry)412 require.Equal(t, floatingGeometry{}, p.popupGeometry)
394 require.Equal(t, 5, p.screen.Frame.Width)413 require.Equal(t, 5, p.screen.Frame.Width)
@@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
404 done1 := make(chan bool, 1)423 done1 := make(chan bool, 1)
405 done2 := make(chan bool, 1)424 done2 := make(chan bool, 1)
406 secondStarted := make(chan struct{})425 secondStarted := make(chan struct{})
407- go func() { done1 <- d.resizeFloatingPane(p, first) }()426+ go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
408 <-pty.entered427 <-pty.entered
409 go func() {428 go func() {
410 close(secondStarted)429 close(secondStarted)
411- done2 <- d.resizeFloatingPane(p, second)430+ done2 <- applyFloatingResizePlanForTest(d, p, second)
412 }()431 }()
413 <-secondStarted432 <-secondStarted
414 close(pty.release)433 close(pty.release)
M internal/usecase/daemon/floating_test.go +4 -4
@@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
3636 var releaseOnce sync.Once
3737 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
3838 t.Cleanup(releaseOpen)
39- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
39+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40+ func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
4141 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
4242 <-release
4343 return result, openErr
@@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
467467 t.Run(tt.name, func(t *testing.T) {
468468 factory := portsmocks.NewMockPTYFactory(t)
469469 opened := make(chan string, 1)
470- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471- func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472472 opened <- cwd
473473 return nil, io.ErrUnexpectedEOF
474474 }).Once()
@@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
36 var releaseOnce sync.Once36 var releaseOnce sync.Once
37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
38 t.Cleanup(releaseOpen)38 t.Cleanup(releaseOpen)
39- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(39+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {40+ func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
41 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}41 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
42 <-release42 <-release
43 return result, openErr43 return result, openErr
@@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
467 t.Run(tt.name, func(t *testing.T) {467 t.Run(tt.name, func(t *testing.T) {
468 factory := portsmocks.NewMockPTYFactory(t)468 factory := portsmocks.NewMockPTYFactory(t)
469 opened := make(chan string, 1)469 opened := make(chan string, 1)
470- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471- func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {471+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472 opened <- cwd472 opened <- cwd
473 return nil, io.ErrUnexpectedEOF473 return nil, io.ErrUnexpectedEOF
474 }).Once()474 }).Once()
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
2424 sess.tabs = []*tab{first, second}
2525
2626 require.NotPanics(t, func() {
27- d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
2828 })
2929 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
3030 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
@@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
24 sess.tabs = []*tab{first, second}24 sess.tabs = []*tab{first, second}
25 25
26 require.NotPanics(t, func() {26 require.NotPanics(t, func() {
27- d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})27+ d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
28 })28 })
29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
30 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)30 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
M internal/usecase/daemon/output_state_sync_test.go +28 -9
@@ -279,30 +279,49 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
279279 {name: "stream window", window: maxUnackedOutputStates},
280280 } {
281281 t.Run(tc.name, func(t *testing.T) {
282- p, releasePTY := newBlockingPTY(t)
283- defer releasePTY()
282+ // resizeReaderPTY delivers the child's SIGWINCH redraw through the
283+ // actual ptyReader. Its Resize callback does not return until Read has
284+ // accepted the bytes, at which point resizeApplying is still true.
285+ p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
284286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285287 ac.output.maxOutstanding = tc.window
286288 pane := sess.activeTab().focusedPane()
287-
289+ p.applying = func() bool {
290+ pane.mu.Lock()
291+ defer pane.mu.Unlock()
292+ return pane.resizeApplying
293+ }
288294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
289295 d.paint(sess, ac, true)
290296 client := vt.NewScreen(80, 24)
291297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292298 ac.ackOutputState(initial.NewStateNum)
293299
294- // The child can redraw the newly exposed columns while the resize
295- // output is being prepared. The first visible grown frame must contain
296- // that redraw, regardless of the negotiated output window.
300+ d.sessWg.Add(1)
301+ pane.onExit = func() {}
302+ go d.ptyReader(sess, sess.activeTab(), pane)
303+ defer func() { p.close(); d.sessWg.Wait() }()
304+
305+ // The resize deadline drives coordinator prepare/apply/commit. No test
306+ // writes the grown columns directly.
307+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
308+ d.clock = clock
297309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298- pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
299- d.paint(sess, ac, false)
310+ timer := <-clock.timers
311+ timer.ch <- time.Time{}
312+ awaitResizeCallback(t, sess.renderCoordinator())
313+ require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
314+ require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
300315
301316 client.Resize(120, 24)
302317 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303318 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304319 "first grown frame exposed stale pre-SIGWINCH pane content")
305- requireNoOutputFrame(t, sends)
320+ select {
321+ case extra := <-sends:
322+ t.Fatalf("resize emitted more than one frame: %#v", extra)
323+ default:
324+ }
306325 })
307326 }
308327 }
@@ -279,30 +279,49 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
279 {name: "stream window", window: maxUnackedOutputStates},279 {name: "stream window", window: maxUnackedOutputStates},
280 } {280 } {
281 t.Run(tc.name, func(t *testing.T) {281 t.Run(tc.name, func(t *testing.T) {
282- p, releasePTY := newBlockingPTY(t)282+ // resizeReaderPTY delivers the child's SIGWINCH redraw through the
283- defer releasePTY()283+ // actual ptyReader. Its Resize callback does not return until Read has
284+ // accepted the bytes, at which point resizeApplying is still true.
285+ p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
284 d, sess, ac, sends := newManualSessionWithPTYs(t, p)286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285 ac.output.maxOutstanding = tc.window287 ac.output.maxOutstanding = tc.window
286 pane := sess.activeTab().focusedPane()288 pane := sess.activeTab().focusedPane()
287-289+ p.applying = func() bool {
290+ pane.mu.Lock()
291+ defer pane.mu.Unlock()
292+ return pane.resizeApplying
293+ }
288 pane.screen.Write([]byte(strings.Repeat("A", 79)))294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
289 d.paint(sess, ac, true)295 d.paint(sess, ac, true)
290 client := vt.NewScreen(80, 24)296 client := vt.NewScreen(80, 24)
291 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292 ac.ackOutputState(initial.NewStateNum)298 ac.ackOutputState(initial.NewStateNum)
293 299
294- // The child can redraw the newly exposed columns while the resize300+ d.sessWg.Add(1)
295- // output is being prepared. The first visible grown frame must contain301+ pane.onExit = func() {}
296- // that redraw, regardless of the negotiated output window.302+ go d.ptyReader(sess, sess.activeTab(), pane)
303+ defer func() { p.close(); d.sessWg.Wait() }()
304+
305+ // The resize deadline drives coordinator prepare/apply/commit. No test
306+ // writes the grown columns directly.
307+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
308+ d.clock = clock
297 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298- pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))310+ timer := <-clock.timers
299- d.paint(sess, ac, false)311+ timer.ch <- time.Time{}
312+ awaitResizeCallback(t, sess.renderCoordinator())
313+ require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
314+ require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
300 315
301 client.Resize(120, 24)316 client.Resize(120, 24)
302 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))317 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,318 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304 "first grown frame exposed stale pre-SIGWINCH pane content")319 "first grown frame exposed stale pre-SIGWINCH pane content")
305- requireNoOutputFrame(t, sends)320+ select {
321+ case extra := <-sends:
322+ t.Fatalf("resize emitted more than one frame: %#v", extra)
323+ default:
324+ }
306 })325 })
307 }326 }
308 }327 }
M internal/usecase/daemon/palette_test.go +1 -1
@@ -371,7 +371,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
371371 d, sess, ac, sends, releases := newManualTabSession(t, 1)
372372 defer releases[0]()
373373 ptys := portsmocks.NewMockPTYFactory(t)
374- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
374+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
375375 d.ptys = ptys
376376
377377 d.handleInput(sess, ac, []byte("\x1b "))
@@ -371,7 +371,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
371 d, sess, ac, sends, releases := newManualTabSession(t, 1)371 d, sess, ac, sends, releases := newManualTabSession(t, 1)
372 defer releases[0]()372 defer releases[0]()
373 ptys := portsmocks.NewMockPTYFactory(t)373 ptys := portsmocks.NewMockPTYFactory(t)
374- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))374+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
375 d.ptys = ptys375 d.ptys = ptys
376 376
377 d.handleInput(sess, ac, []byte("\x1b "))377 d.handleInput(sess, ac, []byte("\x1b "))
M internal/usecase/daemon/pane.go +17 -9
@@ -16,15 +16,23 @@ import (
1616 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1717 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1818 type pane struct {
19- id layout.PaneID
20- stableID string
21- pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen
25- scrollback *scopy.Scrollback
26- syncGen uint64
27- rect domain.Rect
19+ id layout.PaneID
20+ stableID string
21+ pty ports.PTY
22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24+ screen *vt.Screen
25+ scrollback *scopy.Scrollback
26+ syncGen uint64
27+ rect domain.Rect
28+ // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29+ // draining into resizePending so output is replayed against the target (or
30+ // retained old) screen only after apply resolves.
31+ resizeApplying bool
32+ resizePending []byte
33+ ptyResponses []byte
34+ ptyClipboards []string
35+ ptyAttention bool
2836 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
2937 title paneTitleState
3038 ctx context.Context
@@ -16,15 +16,23 @@ import (
16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
17 // The PTY reader takes only pane.mu, so child output never waits on client IO.17 // The PTY reader takes only pane.mu, so child output never waits on client IO.
18 type pane struct {18 type pane struct {
19- id layout.PaneID19+ id layout.PaneID
20- stableID string20+ stableID string
21- pty ports.PTY21+ pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen24+ screen *vt.Screen
25- scrollback *scopy.Scrollback25+ scrollback *scopy.Scrollback
26- syncGen uint6426+ syncGen uint64
27- rect domain.Rect27+ rect domain.Rect
28+ // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29+ // draining into resizePending so output is replayed against the target (or
30+ // retained old) screen only after apply resolves.
31+ resizeApplying bool
32+ resizePending []byte
33+ ptyResponses []byte
34+ ptyClipboards []string
35+ ptyAttention bool
28 popupGeometry floatingGeometry // last geometry committed after a successful floating resize36 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
29 title paneTitleState37 title paneTitleState
30 ctx context.Context38 ctx context.Context
M internal/usecase/daemon/pane_actions.go +4 -4
@@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
6969 tb.mu.Unlock()
7070 return fmt.Errorf("daemon: generating pane identity: %w", err)
7171 }
72- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
72+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
7373 if err != nil {
7474 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
7575 tb.mu.Lock()
@@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
111111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
112112 if !ok {
113113 for _, p := range tb.panes {
114- d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
114+ d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
115115 }
116116 return
117117 }
@@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
120120 continue
121121 }
122122 if p := tb.panes[pl.ID]; p != nil {
123- d.resizePane(p, pl.Content)
123+ d.applyPaneResize(p, pl.Content)
124124 }
125125 }
126126 }
127127
128-func (d *Daemon) resizePane(p *pane, r domain.Rect) {
128+func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
129129 if p == nil {
130130 return
131131 }
@@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
69 tb.mu.Unlock()69 tb.mu.Unlock()
70 return fmt.Errorf("daemon: generating pane identity: %w", err)70 return fmt.Errorf("daemon: generating pane identity: %w", err)
71 }71 }
72- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))72+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
73 if err != nil {73 if err != nil {
74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
75 tb.mu.Lock()75 tb.mu.Lock()
@@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
112 if !ok {112 if !ok {
113 for _, p := range tb.panes {113 for _, p := range tb.panes {
114- d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})114+ d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
115 }115 }
116 return116 return
117 }117 }
@@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
120 continue120 continue
121 }121 }
122 if p := tb.panes[pl.ID]; p != nil {122 if p := tb.panes[pl.ID]; p != nil {
123- d.resizePane(p, pl.Content)123+ d.applyPaneResize(p, pl.Content)
124 }124 }
125 }125 }
126 }126 }
127 127
128-func (d *Daemon) resizePane(p *pane, r domain.Rect) {128+func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
129 if p == nil {129 if p == nil {
130 return130 return
131 }131 }
M internal/usecase/daemon/pane_actions_test.go +9 -9
@@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
3939 newPTY := portsmocks.NewMockPTY(t)
4040 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
4141 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
42- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
42+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
4343
4444 require.NoError(t, d.splitPane(sess, nil, tt.dir))
4545
@@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
6868 newPTY := portsmocks.NewMockPTY(t)
6969 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
7070 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
71- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
71+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
7272
7373 require.NoError(t, d.splitPane(sess, nil, layout.Right))
7474
@@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
8484
8585 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
8686 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
87- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
87+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
8888
8989 tb := sess.activeTab()
9090 tb.mu.Lock()
@@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
231231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
232232 newPTY := portsmocks.NewMockPTY(t)
233233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
234- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
234+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
235235
236236 require.NoError(t, d.stackPane(sess, nil))
237237 tb := sess.activeTab()
@@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
251251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
252252 newPTY := portsmocks.NewMockPTY(t)
253253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
254- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
254+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
255255 require.NoError(t, d.stackPane(sess, nil))
256256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
257257
@@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
261261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
262262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
263263 thirdPTY := portsmocks.NewMockPTY(t)
264- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
264+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
265265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
266266 require.Len(t, tb.panes, 2)
267267 }
@@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
291291 defer releaseNew()
292292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
293293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
294- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
294+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
295295
296296 require.NoError(t, d.splitPane(sess, nil, layout.Right))
297297 tb := sess.activeTab()
@@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
350350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352352 var gotEnv []string
353- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
353+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355355 gotEnv = append([]string(nil), env...)
356356 return newPTY, nil
357357 },
@@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
39 newPTY := portsmocks.NewMockPTY(t)39 newPTY := portsmocks.NewMockPTY(t)
40 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()40 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
41 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()41 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
42- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()42+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
43 43
44 require.NoError(t, d.splitPane(sess, nil, tt.dir))44 require.NoError(t, d.splitPane(sess, nil, tt.dir))
45 45
@@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
68 newPTY := portsmocks.NewMockPTY(t)68 newPTY := portsmocks.NewMockPTY(t)
69 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()69 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
70 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()70 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
71- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()71+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
72 72
73 require.NoError(t, d.splitPane(sess, nil, layout.Right))73 require.NoError(t, d.splitPane(sess, nil, layout.Right))
74 74
@@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
84 84
85 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {85 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
86 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})86 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
87- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()87+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
88 88
89 tb := sess.activeTab()89 tb := sess.activeTab()
90 tb.mu.Lock()90 tb.mu.Lock()
@@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
232 newPTY := portsmocks.NewMockPTY(t)232 newPTY := portsmocks.NewMockPTY(t)
233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
234- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()234+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
235 235
236 require.NoError(t, d.stackPane(sess, nil))236 require.NoError(t, d.stackPane(sess, nil))
237 tb := sess.activeTab()237 tb := sess.activeTab()
@@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
252 newPTY := portsmocks.NewMockPTY(t)252 newPTY := portsmocks.NewMockPTY(t)
253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
254- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()254+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
255 require.NoError(t, d.stackPane(sess, nil))255 require.NoError(t, d.stackPane(sess, nil))
256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
257 257
@@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
263 thirdPTY := portsmocks.NewMockPTY(t)263 thirdPTY := portsmocks.NewMockPTY(t)
264- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()264+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
266 require.Len(t, tb.panes, 2)266 require.Len(t, tb.panes, 2)
267 }267 }
@@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
291 defer releaseNew()291 defer releaseNew()
292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
294- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()294+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
295 295
296 require.NoError(t, d.splitPane(sess, nil, layout.Right))296 require.NoError(t, d.splitPane(sess, nil, layout.Right))
297 tb := sess.activeTab()297 tb := sess.activeTab()
@@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352 var gotEnv []string352 var gotEnv []string
353- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(353+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {354+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355 gotEnv = append([]string(nil), env...)355 gotEnv = append([]string(nil), env...)
356 return newPTY, nil356 return newPTY, nil
357 },357 },
M internal/usecase/daemon/performance_benchmark_test.go +211 -11
@@ -5,6 +5,7 @@ import (
55 "io"
66 "sync"
77 "testing"
8+ "time"
89
910 "github.com/stretchr/testify/require"
1011
@@ -92,14 +93,49 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
9293 require.Zero(t, emissions, "emit counts only prepare and transport success")
9394 }
9495
95-func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
96- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
96+func TestPerformanceFixtureResize(t *testing.T) {
97+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}})
98+ sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}}
9799
98- for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {
99- fixture.resize()
100+ for _, want := range sequence {
101+ fixture.resizeTo(want)
100102 require.Equal(t, want, fixture.ac.size)
101103 require.True(t, fixture.resized())
102104 }
105+ metrics := fixture.metrics()
106+ require.Equal(t, uint64(len(sequence)), metrics.resizeRequests)
107+ require.Equal(t, uint64(len(sequence)), metrics.resizeCommits)
108+ require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch")
109+ require.Zero(t, metrics.skippedEpochs)
110+ require.Zero(t, metrics.ptyFailures)
111+ require.Zero(t, metrics.frameGapEpochs)
112+ require.NotZero(t, metrics.resizeCommitNanos)
113+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested())
114+}
115+
116+func TestTransactionalResizeMetricDefinitionsAreZeroSafe(t *testing.T) {
117+ metrics := performanceMetrics{}
118+ require.Zero(t, coordinatorCoalescingRatio(metrics))
119+ require.Zero(t, metrics.skippedEpochs, "no accepted request means no skipped epoch")
120+ require.Zero(t, metrics.frameGapEpochs, "no committed epoch means no frame gap")
121+}
122+
123+func TestTransactionalResizeMetrics(t *testing.T) {
124+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}})
125+ fixture.resizeTo(domain.Size{Cols: 100, Rows: 30})
126+ // The fixture deliberately invokes the coordinator's committed retry path:
127+ // a retry never republishes geometry and its reset produces one extra frame.
128+ fixture.retryLatest()
129+
130+ metrics := fixture.metrics()
131+ require.Equal(t, uint64(1), metrics.resizeRequests)
132+ require.Equal(t, uint64(1), metrics.resizeCommits)
133+ require.Equal(t, uint64(1), metrics.ptyFailures)
134+ require.Equal(t, uint64(1), metrics.ptyRetries)
135+ require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
136+ require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
137+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
138+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
103139 }
104140
105141 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -155,8 +191,29 @@ func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
155191 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
156192 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
157193 }
158-func BenchmarkDaemonHistoryResize(b *testing.B) {
159- benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })
194+
195+// BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink
196+// geometry. Metrics are fixture counters; no benchmark result depends on wall
197+// time or scheduler delivery.
198+func BenchmarkDaemonHistoryResizeSweep(b *testing.B) {
199+ sequence := []domain.Size{{Cols: 80, Rows: 24}, {Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 120, Rows: 40}, {Cols: 100, Rows: 30}}
200+ benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) {
201+ f.resizeTo(sequence[f.resizes%len(sequence)])
202+ })
203+}
204+
205+// BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply
206+// followed by its successful full-reset retry for each operation.
207+func BenchmarkDaemonHistoryResizeRetry(b *testing.B) {
208+ benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) {
209+ f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil})
210+ size := domain.Size{Cols: 100, Rows: 30}
211+ if f.resizes%2 != 0 {
212+ size = domain.Size{Cols: 120, Rows: 40}
213+ }
214+ f.resizeTo(size)
215+ f.retryLatest()
216+ })
160217 }
161218
162219 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
@@ -237,6 +294,16 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
237294 b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
238295 b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
239296 b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
297+ // resizecommitns is the injected-clock elapsed time from accepted request
298+ // to committed epoch. The remaining values are counter deltas: skipped is
299+ // accepted minus committed (never negative), framegap is committed epochs
300+ // lacking their required output frame (never negative), and retries/failures
301+ // are PTY Resize outcomes. All divisions are guarded above.
302+ b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op")
303+ b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op")
304+ b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op")
305+ b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op")
306+ b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op")
240307 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
241308 b.ReportMetric(float64(historyRows), "historyrows/pane")
242309 }
@@ -286,6 +353,7 @@ type performanceConfig struct {
286353 tabs int
287354 panes int
288355 historyRows int
356+ ptyErrors []error
289357 }
290358
291359 type performanceMetrics struct {
@@ -300,6 +368,78 @@ type performanceMetrics struct {
300368 renderCaptures uint64
301369 renderCompositions uint64
302370 renderEmissions uint64
371+ resizeRequests uint64
372+ resizeCommits uint64
373+ resizeCommitNanos uint64
374+ skippedEpochs uint64
375+ ptyFailures uint64
376+ ptyRetries uint64
377+ frameGapEpochs uint64
378+}
379+
380+// performanceClock makes transition latency a reproducible counter rather
381+// than a wall-clock observation. A nil timer channel deliberately executes
382+// coordinator work synchronously.
383+type performanceClock struct {
384+ mu sync.Mutex
385+ now time.Time
386+}
387+
388+func (c *performanceClock) Now() time.Time {
389+ c.mu.Lock()
390+ defer c.mu.Unlock()
391+ c.now = c.now.Add(time.Microsecond)
392+ return c.now
393+}
394+func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} }
395+
396+type performanceTimer struct{}
397+
398+func (performanceTimer) C() <-chan time.Time { return nil }
399+func (performanceTimer) Reset(time.Duration) bool { return true }
400+func (performanceTimer) Stop() bool { return true }
401+
402+// scriptedPerformancePTY records every requested terminal size and returns a
403+// caller-provided error script. It has no reads or scheduling side effects.
404+type scriptedPerformancePTY struct {
405+ mu sync.Mutex
406+ sizes []domain.Size
407+ errs []error
408+ fails uint64
409+}
410+
411+func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
412+ p.mu.Lock()
413+ defer p.mu.Unlock()
414+ p.sizes = append(p.sizes, size)
415+ index := len(p.sizes) - 1
416+ if index < len(p.errs) && p.errs[index] != nil {
417+ p.fails++
418+ return p.errs[index]
419+ }
420+ return nil
421+}
422+func (*scriptedPerformancePTY) Read([]byte) (int, error) { return 0, io.EOF }
423+func (*scriptedPerformancePTY) Write(b []byte) (int, error) { return len(b), nil }
424+func (*scriptedPerformancePTY) Close() error { return nil }
425+func (*scriptedPerformancePTY) Pid() int { return 0 }
426+func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil }
427+func (p *scriptedPerformancePTY) requested() []domain.Size {
428+ p.mu.Lock()
429+ defer p.mu.Unlock()
430+ return append([]domain.Size(nil), p.sizes...)
431+}
432+func (p *scriptedPerformancePTY) metrics() (uint64, uint64) {
433+ p.mu.Lock()
434+ defer p.mu.Unlock()
435+ return p.fails, uint64(len(p.sizes))
436+}
437+func (p *scriptedPerformancePTY) setErrors(errs []error) {
438+ p.mu.Lock()
439+ defer p.mu.Unlock()
440+ p.errs = append([]error(nil), errs...)
441+ p.sizes = nil
442+ p.fails = 0
303443 }
304444
305445 type performanceFixture struct {
@@ -315,6 +455,13 @@ type performanceFixture struct {
315455 resizeSizes [2]domain.Size
316456 resizes int
317457 resizedSize domain.Size
458+ pty *scriptedPerformancePTY
459+ clock *performanceClock
460+ resizeRequests uint64
461+ resizeCommits uint64
462+ resizeCommitNanos uint64
463+ ptyFailures uint64
464+ ptyRetries uint64
318465 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
319466 renderCaptures uint64
320467 renderCompositions uint64
@@ -335,8 +482,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
335482 if config.historyRows == 0 {
336483 config.historyRows = 1
337484 }
485+ pty := &scriptedPerformancePTY{}
338486 ptys := make([]ports.PTY, config.tabs)
487+ ptys[0] = pty
339488 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
489+ clock := &performanceClock{}
490+ d.clock = clock
340491 output := &countingOutputTransport{}
341492 ac.tr = output
342493 ac.size = config.size
@@ -351,6 +502,8 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
351502 ac: ac,
352503 output: output,
353504 snaps: &countingSnapshotStore{},
505+ pty: pty,
506+ clock: clock,
354507 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
355508 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
356509 }
@@ -359,10 +512,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
359512 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
360513 }
361514 fixture.activePane = fixture.findActivePane()
515+ // Fixture construction may solve the initial layout; benchmark scripts begin
516+ // after that setup work and therefore have an empty call history.
517+ pty.setErrors(config.ptyErrors)
362518
363- // The fixture uses the production coordinator. stubClock's inert timer is
364- // completed synchronously by the coordinator, keeping benchmark operations
365- // deterministic while retaining their real invalidation path.
519+ // The fixture uses the production coordinator. performanceClock's nil timer
520+ // channel completes the coordinator path synchronously and deterministically.
366521 ac.renderStages = renderStageHooks{
367522 capture: func() { fixture.renderCaptures++ },
368523 compose: func() { fixture.renderCompositions++ },
@@ -457,9 +612,41 @@ func (f *performanceFixture) searchMatches() int {
457612 }
458613
459614 func (f *performanceFixture) resize() {
460- f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]
615+ f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
616+}
617+
618+func (f *performanceFixture) resizeTo(size domain.Size) {
619+ f.resizedSize = size
461620 f.resizes++
462- f.d.resize(f.sess, f.ac, f.resizedSize)
621+ before := f.sess.renderCoordinator().resizeSnapshot()
622+ start := f.clock.Now()
623+ failuresBefore, callsBefore := f.pty.metrics()
624+ f.d.resize(f.sess, f.ac, size)
625+ after := f.sess.renderCoordinator().resizeSnapshot()
626+ if after.epoch > before.epoch {
627+ f.resizeRequests += after.epoch - before.epoch
628+ }
629+ if after.committed > before.committed {
630+ f.resizeCommits += after.committed - before.committed
631+ f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds())
632+ }
633+ failuresAfter, callsAfter := f.pty.metrics()
634+ f.ptyFailures += failuresAfter - failuresBefore
635+ if callsAfter > callsBefore+1 {
636+ f.ptyRetries += callsAfter - callsBefore - 1
637+ }
638+}
639+
640+func (f *performanceFixture) retryLatest() {
641+ epoch := f.sess.renderCoordinator().resizeSnapshot().committed
642+ plan := f.d.prepareResize(f.sess, f.ac.size)
643+ failuresBefore, callsBefore := f.pty.metrics()
644+ f.d.retryResizeMembers(f.sess, f.ac, epoch, plan.members)
645+ failuresAfter, callsAfter := f.pty.metrics()
646+ f.ptyFailures += failuresAfter - failuresBefore
647+ if callsAfter > callsBefore {
648+ f.ptyRetries += callsAfter - callsBefore
649+ }
463650 }
464651
465652 func (f *performanceFixture) resized() bool {
@@ -483,6 +670,11 @@ func (f *performanceFixture) resetMetrics() {
483670 f.renderCaptures = 0
484671 f.renderCompositions = 0
485672 f.renderEmissions = 0
673+ f.resizeRequests = 0
674+ f.resizeCommits = 0
675+ f.resizeCommitNanos = 0
676+ f.ptyFailures = 0
677+ f.ptyRetries = 0
486678 if rc := f.sess.renderCoordinator(); rc != nil {
487679 f.coordinatorBaseline = rc.burstMetricsSnapshot()
488680 }
@@ -495,6 +687,14 @@ func (f *performanceFixture) metrics() performanceMetrics {
495687 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
496688 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
497689 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
690+ resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
691+ ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
692+ }
693+ if metrics.resizeRequests > metrics.resizeCommits {
694+ metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits
695+ }
696+ if metrics.resizeCommits > metrics.outputFrames {
697+ metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames
498698 }
499699 if rc := f.sess.renderCoordinator(); rc != nil {
500700 coordinator := rc.burstMetricsSnapshot()
@@ -5,6 +5,7 @@ import (
5 "io"5 "io"
6 "sync"6 "sync"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
@@ -92,14 +93,49 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
92 require.Zero(t, emissions, "emit counts only prepare and transport success")93 require.Zero(t, emissions, "emit counts only prepare and transport success")
93 }94 }
94 95
95-func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {96+func TestPerformanceFixtureResize(t *testing.T) {
96- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})97+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}})
98+ sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}}
97 99
98- for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {100+ for _, want := range sequence {
99- fixture.resize()101+ fixture.resizeTo(want)
100 require.Equal(t, want, fixture.ac.size)102 require.Equal(t, want, fixture.ac.size)
101 require.True(t, fixture.resized())103 require.True(t, fixture.resized())
102 }104 }
105+ metrics := fixture.metrics()
106+ require.Equal(t, uint64(len(sequence)), metrics.resizeRequests)
107+ require.Equal(t, uint64(len(sequence)), metrics.resizeCommits)
108+ require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch")
109+ require.Zero(t, metrics.skippedEpochs)
110+ require.Zero(t, metrics.ptyFailures)
111+ require.Zero(t, metrics.frameGapEpochs)
112+ require.NotZero(t, metrics.resizeCommitNanos)
113+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested())
114+}
115+
116+func TestTransactionalResizeMetricDefinitionsAreZeroSafe(t *testing.T) {
117+ metrics := performanceMetrics{}
118+ require.Zero(t, coordinatorCoalescingRatio(metrics))
119+ require.Zero(t, metrics.skippedEpochs, "no accepted request means no skipped epoch")
120+ require.Zero(t, metrics.frameGapEpochs, "no committed epoch means no frame gap")
121+}
122+
123+func TestTransactionalResizeMetrics(t *testing.T) {
124+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}})
125+ fixture.resizeTo(domain.Size{Cols: 100, Rows: 30})
126+ // The fixture deliberately invokes the coordinator's committed retry path:
127+ // a retry never republishes geometry and its reset produces one extra frame.
128+ fixture.retryLatest()
129+
130+ metrics := fixture.metrics()
131+ require.Equal(t, uint64(1), metrics.resizeRequests)
132+ require.Equal(t, uint64(1), metrics.resizeCommits)
133+ require.Equal(t, uint64(1), metrics.ptyFailures)
134+ require.Equal(t, uint64(1), metrics.ptyRetries)
135+ require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
136+ require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
137+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
138+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
103 }139 }
104 140
105 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {141 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -155,8 +191,29 @@ func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
155 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {191 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
156 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })192 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
157 }193 }
158-func BenchmarkDaemonHistoryResize(b *testing.B) {194+
159- benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })195+// BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink
196+// geometry. Metrics are fixture counters; no benchmark result depends on wall
197+// time or scheduler delivery.
198+func BenchmarkDaemonHistoryResizeSweep(b *testing.B) {
199+ sequence := []domain.Size{{Cols: 80, Rows: 24}, {Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 120, Rows: 40}, {Cols: 100, Rows: 30}}
200+ benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) {
201+ f.resizeTo(sequence[f.resizes%len(sequence)])
202+ })
203+}
204+
205+// BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply
206+// followed by its successful full-reset retry for each operation.
207+func BenchmarkDaemonHistoryResizeRetry(b *testing.B) {
208+ benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) {
209+ f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil})
210+ size := domain.Size{Cols: 100, Rows: 30}
211+ if f.resizes%2 != 0 {
212+ size = domain.Size{Cols: 120, Rows: 40}
213+ }
214+ f.resizeTo(size)
215+ f.retryLatest()
216+ })
160 }217 }
161 218
162 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {219 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
@@ -237,6 +294,16 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
237 b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")294 b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
238 b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")295 b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
239 b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")296 b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
297+ // resizecommitns is the injected-clock elapsed time from accepted request
298+ // to committed epoch. The remaining values are counter deltas: skipped is
299+ // accepted minus committed (never negative), framegap is committed epochs
300+ // lacking their required output frame (never negative), and retries/failures
301+ // are PTY Resize outcomes. All divisions are guarded above.
302+ b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op")
303+ b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op")
304+ b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op")
305+ b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op")
306+ b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op")
240 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")307 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
241 b.ReportMetric(float64(historyRows), "historyrows/pane")308 b.ReportMetric(float64(historyRows), "historyrows/pane")
242 }309 }
@@ -286,6 +353,7 @@ type performanceConfig struct {
286 tabs int353 tabs int
287 panes int354 panes int
288 historyRows int355 historyRows int
356+ ptyErrors []error
289 }357 }
290 358
291 type performanceMetrics struct {359 type performanceMetrics struct {
@@ -300,6 +368,78 @@ type performanceMetrics struct {
300 renderCaptures uint64368 renderCaptures uint64
301 renderCompositions uint64369 renderCompositions uint64
302 renderEmissions uint64370 renderEmissions uint64
371+ resizeRequests uint64
372+ resizeCommits uint64
373+ resizeCommitNanos uint64
374+ skippedEpochs uint64
375+ ptyFailures uint64
376+ ptyRetries uint64
377+ frameGapEpochs uint64
378+}
379+
380+// performanceClock makes transition latency a reproducible counter rather
381+// than a wall-clock observation. A nil timer channel deliberately executes
382+// coordinator work synchronously.
383+type performanceClock struct {
384+ mu sync.Mutex
385+ now time.Time
386+}
387+
388+func (c *performanceClock) Now() time.Time {
389+ c.mu.Lock()
390+ defer c.mu.Unlock()
391+ c.now = c.now.Add(time.Microsecond)
392+ return c.now
393+}
394+func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} }
395+
396+type performanceTimer struct{}
397+
398+func (performanceTimer) C() <-chan time.Time { return nil }
399+func (performanceTimer) Reset(time.Duration) bool { return true }
400+func (performanceTimer) Stop() bool { return true }
401+
402+// scriptedPerformancePTY records every requested terminal size and returns a
403+// caller-provided error script. It has no reads or scheduling side effects.
404+type scriptedPerformancePTY struct {
405+ mu sync.Mutex
406+ sizes []domain.Size
407+ errs []error
408+ fails uint64
409+}
410+
411+func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
412+ p.mu.Lock()
413+ defer p.mu.Unlock()
414+ p.sizes = append(p.sizes, size)
415+ index := len(p.sizes) - 1
416+ if index < len(p.errs) && p.errs[index] != nil {
417+ p.fails++
418+ return p.errs[index]
419+ }
420+ return nil
421+}
422+func (*scriptedPerformancePTY) Read([]byte) (int, error) { return 0, io.EOF }
423+func (*scriptedPerformancePTY) Write(b []byte) (int, error) { return len(b), nil }
424+func (*scriptedPerformancePTY) Close() error { return nil }
425+func (*scriptedPerformancePTY) Pid() int { return 0 }
426+func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil }
427+func (p *scriptedPerformancePTY) requested() []domain.Size {
428+ p.mu.Lock()
429+ defer p.mu.Unlock()
430+ return append([]domain.Size(nil), p.sizes...)
431+}
432+func (p *scriptedPerformancePTY) metrics() (uint64, uint64) {
433+ p.mu.Lock()
434+ defer p.mu.Unlock()
435+ return p.fails, uint64(len(p.sizes))
436+}
437+func (p *scriptedPerformancePTY) setErrors(errs []error) {
438+ p.mu.Lock()
439+ defer p.mu.Unlock()
440+ p.errs = append([]error(nil), errs...)
441+ p.sizes = nil
442+ p.fails = 0
303 }443 }
304 444
305 type performanceFixture struct {445 type performanceFixture struct {
@@ -315,6 +455,13 @@ type performanceFixture struct {
315 resizeSizes [2]domain.Size455 resizeSizes [2]domain.Size
316 resizes int456 resizes int
317 resizedSize domain.Size457 resizedSize domain.Size
458+ pty *scriptedPerformancePTY
459+ clock *performanceClock
460+ resizeRequests uint64
461+ resizeCommits uint64
462+ resizeCommitNanos uint64
463+ ptyFailures uint64
464+ ptyRetries uint64
318 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot465 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
319 renderCaptures uint64466 renderCaptures uint64
320 renderCompositions uint64467 renderCompositions uint64
@@ -335,8 +482,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
335 if config.historyRows == 0 {482 if config.historyRows == 0 {
336 config.historyRows = 1483 config.historyRows = 1
337 }484 }
485+ pty := &scriptedPerformancePTY{}
338 ptys := make([]ports.PTY, config.tabs)486 ptys := make([]ports.PTY, config.tabs)
487+ ptys[0] = pty
339 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)488 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
489+ clock := &performanceClock{}
490+ d.clock = clock
340 output := &countingOutputTransport{}491 output := &countingOutputTransport{}
341 ac.tr = output492 ac.tr = output
342 ac.size = config.size493 ac.size = config.size
@@ -351,6 +502,8 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
351 ac: ac,502 ac: ac,
352 output: output,503 output: output,
353 snaps: &countingSnapshotStore{},504 snaps: &countingSnapshotStore{},
505+ pty: pty,
506+ clock: clock,
354 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},507 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
355 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},508 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
356 }509 }
@@ -359,10 +512,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
359 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))512 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
360 }513 }
361 fixture.activePane = fixture.findActivePane()514 fixture.activePane = fixture.findActivePane()
515+ // Fixture construction may solve the initial layout; benchmark scripts begin
516+ // after that setup work and therefore have an empty call history.
517+ pty.setErrors(config.ptyErrors)
362 518
363- // The fixture uses the production coordinator. stubClock's inert timer is519+ // The fixture uses the production coordinator. performanceClock's nil timer
364- // completed synchronously by the coordinator, keeping benchmark operations520+ // channel completes the coordinator path synchronously and deterministically.
365- // deterministic while retaining their real invalidation path.
366 ac.renderStages = renderStageHooks{521 ac.renderStages = renderStageHooks{
367 capture: func() { fixture.renderCaptures++ },522 capture: func() { fixture.renderCaptures++ },
368 compose: func() { fixture.renderCompositions++ },523 compose: func() { fixture.renderCompositions++ },
@@ -457,9 +612,41 @@ func (f *performanceFixture) searchMatches() int {
457 }612 }
458 613
459 func (f *performanceFixture) resize() {614 func (f *performanceFixture) resize() {
460- f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]615+ f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
616+}
617+
618+func (f *performanceFixture) resizeTo(size domain.Size) {
619+ f.resizedSize = size
461 f.resizes++620 f.resizes++
462- f.d.resize(f.sess, f.ac, f.resizedSize)621+ before := f.sess.renderCoordinator().resizeSnapshot()
622+ start := f.clock.Now()
623+ failuresBefore, callsBefore := f.pty.metrics()
624+ f.d.resize(f.sess, f.ac, size)
625+ after := f.sess.renderCoordinator().resizeSnapshot()
626+ if after.epoch > before.epoch {
627+ f.resizeRequests += after.epoch - before.epoch
628+ }
629+ if after.committed > before.committed {
630+ f.resizeCommits += after.committed - before.committed
631+ f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds())
632+ }
633+ failuresAfter, callsAfter := f.pty.metrics()
634+ f.ptyFailures += failuresAfter - failuresBefore
635+ if callsAfter > callsBefore+1 {
636+ f.ptyRetries += callsAfter - callsBefore - 1
637+ }
638+}
639+
640+func (f *performanceFixture) retryLatest() {
641+ epoch := f.sess.renderCoordinator().resizeSnapshot().committed
642+ plan := f.d.prepareResize(f.sess, f.ac.size)
643+ failuresBefore, callsBefore := f.pty.metrics()
644+ f.d.retryResizeMembers(f.sess, f.ac, epoch, plan.members)
645+ failuresAfter, callsAfter := f.pty.metrics()
646+ f.ptyFailures += failuresAfter - failuresBefore
647+ if callsAfter > callsBefore {
648+ f.ptyRetries += callsAfter - callsBefore
649+ }
463 }650 }
464 651
465 func (f *performanceFixture) resized() bool {652 func (f *performanceFixture) resized() bool {
@@ -483,6 +670,11 @@ func (f *performanceFixture) resetMetrics() {
483 f.renderCaptures = 0670 f.renderCaptures = 0
484 f.renderCompositions = 0671 f.renderCompositions = 0
485 f.renderEmissions = 0672 f.renderEmissions = 0
673+ f.resizeRequests = 0
674+ f.resizeCommits = 0
675+ f.resizeCommitNanos = 0
676+ f.ptyFailures = 0
677+ f.ptyRetries = 0
486 if rc := f.sess.renderCoordinator(); rc != nil {678 if rc := f.sess.renderCoordinator(); rc != nil {
487 f.coordinatorBaseline = rc.burstMetricsSnapshot()679 f.coordinatorBaseline = rc.burstMetricsSnapshot()
488 }680 }
@@ -495,6 +687,14 @@ func (f *performanceFixture) metrics() performanceMetrics {
495 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,687 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
496 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,688 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
497 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,689 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
690+ resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
691+ ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
692+ }
693+ if metrics.resizeRequests > metrics.resizeCommits {
694+ metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits
695+ }
696+ if metrics.resizeCommits > metrics.outputFrames {
697+ metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames
498 }698 }
499 if rc := f.sess.renderCoordinator(); rc != nil {699 if rc := f.sess.renderCoordinator(); rc != nil {
500 coordinator := rc.burstMetricsSnapshot()700 coordinator := rc.burstMetricsSnapshot()
M internal/usecase/daemon/picker.go +0 -6
@@ -410,12 +410,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
410410 d.touchMRU(targetSess)
411411 ac.recordPreviousSession(from)
412412 d.mu.Unlock()
413- if old != nil && old != ac {
414- // Displacement is a detach lifecycle transition too. Cancel the
415- // attachment-owned PR #71 timer only after releasing daemon/session
416- // locks, preserving sendMu > daemon/session lock ordering.
417- old.cancelResizePaint()
418- }
419413 return old
420414 }
421415
@@ -410,12 +410,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
410 d.touchMRU(targetSess)410 d.touchMRU(targetSess)
411 ac.recordPreviousSession(from)411 ac.recordPreviousSession(from)
412 d.mu.Unlock()412 d.mu.Unlock()
413- if old != nil && old != ac {
414- // Displacement is a detach lifecycle transition too. Cancel the
415- // attachment-owned PR #71 timer only after releasing daemon/session
416- // locks, preserving sendMu > daemon/session lock ordering.
417- old.cancelResizePaint()
418- }
419 return old413 return old
420 }414 }
421 415
M internal/usecase/daemon/picker_test.go +10 -14
@@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
258258 awaitFrame(t, sends1, ports.MsgOutput)
259259 }
260260
261-func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
261+func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
262262 p1, releasePTY1 := newBlockingPTY(t)
263263 p2, releasePTY2 := newBlockingPTY(t)
264264 defer releasePTY1()
@@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
281281
282282 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283283 timer := <-clock.timers
284- ac2.sendMu.Lock()
285- before := ac2.resizePaintGeneration
286- require.True(t, ac2.resizePaintPending)
287- ac2.sendMu.Unlock()
284+ before := sess2.renderCoordinator().resizeSnapshot().epoch
288285
289286 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290- ac2.sendMu.Lock()
291- require.False(t, ac2.resizePaintPending)
292- require.Equal(t, before+1, ac2.resizePaintGeneration)
293- ac2.sendMu.Unlock()
287+ // The target coordinator invalidates its scheduled epoch during handoff;
288+ // no attachment-local timer survives the transfer.
289+ require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
294290 timer.ch <- time.Time{}
295291 }
296292
@@ -403,8 +399,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
403399 defer releasePTY3()
404400 var openedEnv []string
405401 f := portsmocks.NewMockPTYFactory(t)
406- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
407- func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
402+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
403+ func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
408404 if sz != (domain.Size{Cols: 80, Rows: 22}) {
409405 return newQuietPTY(), nil
410406 }
@@ -518,8 +514,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
518514 var opens [][]string
519515 f := portsmocks.NewMockPTYFactory(t)
520516 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
521- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
522- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
517+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
518+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
523519 opens = append(opens, append([]string(nil), env...))
524520 if len(opens) == 1 {
525521 return p1, nil
@@ -528,7 +524,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
528524 },
529525 ).Twice()
530526 floating := newQuietPTY()
531- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
527+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
532528 return got != normalSize && got.Valid()
533529 })).Return(floating, nil).Once()
534530 d := newTestDaemon(t, f, stubClock{})
@@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
258 awaitFrame(t, sends1, ports.MsgOutput)258 awaitFrame(t, sends1, ports.MsgOutput)
259 }259 }
260 260
261-func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {261+func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
262 p1, releasePTY1 := newBlockingPTY(t)262 p1, releasePTY1 := newBlockingPTY(t)
263 p2, releasePTY2 := newBlockingPTY(t)263 p2, releasePTY2 := newBlockingPTY(t)
264 defer releasePTY1()264 defer releasePTY1()
@@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
281 281
282 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})282 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283 timer := <-clock.timers283 timer := <-clock.timers
284- ac2.sendMu.Lock()284+ before := sess2.renderCoordinator().resizeSnapshot().epoch
285- before := ac2.resizePaintGeneration
286- require.True(t, ac2.resizePaintPending)
287- ac2.sendMu.Unlock()
288 285
289 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))286 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290- ac2.sendMu.Lock()287+ // The target coordinator invalidates its scheduled epoch during handoff;
291- require.False(t, ac2.resizePaintPending)288+ // no attachment-local timer survives the transfer.
292- require.Equal(t, before+1, ac2.resizePaintGeneration)289+ require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
293- ac2.sendMu.Unlock()
294 timer.ch <- time.Time{}290 timer.ch <- time.Time{}
295 }291 }
296 292
@@ -403,8 +399,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
403 defer releasePTY3()399 defer releasePTY3()
404 var openedEnv []string400 var openedEnv []string
405 f := portsmocks.NewMockPTYFactory(t)401 f := portsmocks.NewMockPTYFactory(t)
406- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(402+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
407- func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {403+ func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
408 if sz != (domain.Size{Cols: 80, Rows: 22}) {404 if sz != (domain.Size{Cols: 80, Rows: 22}) {
409 return newQuietPTY(), nil405 return newQuietPTY(), nil
410 }406 }
@@ -518,8 +514,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
518 var opens [][]string514 var opens [][]string
519 f := portsmocks.NewMockPTYFactory(t)515 f := portsmocks.NewMockPTYFactory(t)
520 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}516 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
521- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(517+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
522- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {518+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
523 opens = append(opens, append([]string(nil), env...))519 opens = append(opens, append([]string(nil), env...))
524 if len(opens) == 1 {520 if len(opens) == 1 {
525 return p1, nil521 return p1, nil
@@ -528,7 +524,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
528 },524 },
529 ).Twice()525 ).Twice()
530 floating := newQuietPTY()526 floating := newQuietPTY()
531- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {527+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
532 return got != normalSize && got.Valid()528 return got != normalSize && got.Valid()
533 })).Return(floating, nil).Once()529 })).Return(floating, nil).Once()
534 d := newTestDaemon(t, f, stubClock{})530 d := newTestDaemon(t, f, stubClock{})
M internal/usecase/daemon/render.go +91 -137
@@ -5,7 +5,6 @@ import (
55 "bytes"
66
77 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
98 "github.com/bnema/vev/internal/usecase/layout"
109 themeui "github.com/bnema/vev/internal/usecase/theme"
1110 "github.com/bnema/vev/pkg/renderer"
@@ -19,13 +18,6 @@ func completedSynchronizedUpdate(data []byte) bool {
1918 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
2019 }
2120
22-func signal(ch chan struct{}) {
23- select {
24- case ch <- struct{}{}:
25- default:
26- }
27-}
28-
2921 // paneRenderable reports dynamic composition visibility. Callers must not
3022 // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
3123 func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
@@ -42,7 +34,7 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
4234 if tb.floating.pane == p {
4335 return active && attached && tb.floating.state == floatingVisible
4436 }
45- if !((active && attached) || preview) {
37+ if (!active || !attached) && !preview {
4638 return false
4739 }
4840 placements, ok := solvedPlacementsLocked(tb)
@@ -93,97 +85,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
9385 return
9486 }
9587 buf := make([]byte, ptyReadBufSize)
96- var resp []byte
97- var clipboards []string
98- attentionCh := make(chan struct{}, 1)
9988 p.mu.Lock()
100- p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
101- p.screen.OnBell = func() { signal(attentionCh) }
102- p.screen.OnNotify = func(string, string) { signal(attentionCh) }
103- p.screen.OnProgress = func(bool) { signal(attentionCh) }
104- p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
89+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
90+ p.screen.OnBell = func() { p.ptyAttention = true }
91+ p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
92+ p.screen.OnProgress = func(bool) { p.ptyAttention = true }
93+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
10594 p.mu.Unlock()
10695 for {
10796 n, err := p.pty.Read(buf)
10897 if n > 0 {
109- data := buf[:n]
110- rc := sess.renderCoordinator()
111- p.mu.Lock()
112- wasSyncing := p.screen.SyncUpdateActive()
113- p.screen.Write(data)
114- p.refreshTerminalTitleLocked()
115- isSyncing := p.screen.SyncUpdateActive()
116- completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
117- var syncGen uint64
118- syncEnded := false
119- if wasSyncing != isSyncing {
120- if isSyncing {
121- syncGen = sess.syncGen.Add(1)
122- p.syncGen = syncGen
123- // Publish the gate while the authoritative screen mutation is
124- // still protected. A concurrent deadline therefore cannot
125- // compose this partial DEC 2026 payload.
126- if rc != nil {
127- rc.noteSyncBeginWithRenderability(p, syncGen, func() bool {
128- return d.paneRenderable(sess, tb, p)
129- }, func() {
130- p.mu.Lock()
131- if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
132- p.screen.ForceSyncEnd()
133- }
134- p.mu.Unlock()
135- })
136- }
137- } else {
138- syncGen = p.syncGen
139- syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
140- }
141- }
142- p.mu.Unlock()
143- renderable := d.paneRenderable(sess, tb, p)
144- markSnapshotDirty(sess)
145- select {
146- case <-attentionCh:
147- d.noteAttention(sess, tb)
148- default:
149- }
150- if len(clipboards) > 0 {
151- for _, b64 := range clipboards {
152- d.forwardClipboardAsync(sess, b64)
153- }
154- clipboards = clipboards[:0]
155- }
156- if len(resp) > 0 {
157- if _, writeErr := p.pty.Write(resp); writeErr != nil {
158- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
159- }
160- resp = resp[:0]
161- }
162- if rc != nil {
163- if wasSyncing != isSyncing {
164- if isSyncing {
165- // The accumulated synchronized batch is the pending render only
166- // while this pane can currently reach a composition target.
167- if renderable {
168- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
169- }
170- } else if syncEnded && renderable {
171- // State removal happened under pane.mu. Publish a new urgent
172- // completion after unlock: detach/replace may have cleared the
173- // pending batch work, so fireCurrent alone could not wake a
174- // headless picker preview or replacement attachment.
175- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
176- }
177- }
178- if completeSyncRead && renderable {
179- // The entire batch completed in this read. Its final screen state
180- // is ready now, so bypass the bulk debounce without arming a
181- // watchdog for a batch that no longer exists.
182- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
183- } else if renderable && !isSyncing && wasSyncing == isSyncing {
184- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
185- }
186- }
98+ d.processPTYData(sess, tb, p, buf[:n], true)
18799 }
188100 if err != nil {
189101 if p.onExit != nil {
@@ -196,45 +108,96 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
196108 }
197109 }
198110
199-// paint draws the composed client frame (active tab plus status bar) and
200-// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
201-// such as switch/create/close/rename/resize so the repaint is complete.
202-func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
203- ac.resizePaint.stop()
204- ac.resizePaintGeneration++
205- generation := ac.resizePaintGeneration
206- ac.resizePaintPending = true
207- ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
208- d.invalidateForResizeGeneration(sess, ac, generation)
209- })
210-}
211-
212-// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
213-// generation rejection, and cancellation while transferring only its eventual
214-// render request to the coordinator.
215-func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
216- ac.sendMu.Lock()
217- if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
218- ac.sendMu.Unlock()
111+// processPTYData is the sole VT parsing path. While a resize apply owns the
112+// pane, reader calls append exact bytes and returns immediately; it never waits
113+// for PTY.Resize or loses a short read.
114+func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
115+ rc := sess.renderCoordinator()
116+ p.mu.Lock()
117+ if bufferDuringApply && p.resizeApplying {
118+ p.resizePending = append(p.resizePending, data...)
119+ p.mu.Unlock()
219120 return
220121 }
221- ac.resizePaint.stop()
222- ac.resizePaintPending = false
223- ac.sendMu.Unlock()
224- d.invalidateRender(sess, ac, true, "render.go")
225-}
226-
227-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
228- d.paintForResizeGeneration(sess, ac, reset, 0, 0)
122+ wasSyncing := p.screen.SyncUpdateActive()
123+ p.screen.Write(data)
124+ p.refreshTerminalTitleLocked()
125+ isSyncing := p.screen.SyncUpdateActive()
126+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
127+ var syncGen uint64
128+ syncEnded := false
129+ if wasSyncing != isSyncing {
130+ if isSyncing {
131+ syncGen = sess.syncGen.Add(1)
132+ p.syncGen = syncGen
133+ if rc != nil {
134+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
135+ p.mu.Lock()
136+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
137+ p.screen.ForceSyncEnd()
138+ }
139+ p.mu.Unlock()
140+ })
141+ }
142+ } else {
143+ syncGen = p.syncGen
144+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
145+ }
146+ }
147+ p.mu.Unlock()
148+ renderable := d.paneRenderable(sess, tb, p)
149+ markSnapshotDirty(sess)
150+ d.flushPTYEffects(sess, tb, p)
151+ if rc != nil {
152+ if wasSyncing != isSyncing {
153+ if isSyncing {
154+ if renderable {
155+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
156+ }
157+ } else if syncEnded && renderable {
158+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
159+ }
160+ }
161+ if completeSyncRead && renderable {
162+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
163+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
164+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
165+ }
166+ }
229167 }
230168
231-// paintCoordinatorWake composes a coordinator wake only for its captured
232-// attachment incarnation. The epoch is checked after sendMu is acquired.
233-func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {
234- d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)
169+// flushPTYEffects preserves the normal response/title/attention/clipboard
170+// behavior for both direct reads and resize-buffer replay.
171+func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
172+ p.mu.Lock()
173+ attention := p.ptyAttention
174+ p.ptyAttention = false
175+ responses := append([]byte(nil), p.ptyResponses...)
176+ p.ptyResponses = p.ptyResponses[:0]
177+ clipboards := append([]string(nil), p.ptyClipboards...)
178+ p.ptyClipboards = p.ptyClipboards[:0]
179+ p.mu.Unlock()
180+ if attention {
181+ d.noteAttention(sess, tb)
182+ }
183+ for _, b64 := range clipboards {
184+ d.forwardClipboardAsync(sess, b64)
185+ }
186+ if len(responses) > 0 {
187+ if _, err := p.pty.Write(responses); err != nil {
188+ d.log.Warn("pty response write failed", "err", err, "session", sess.name)
189+ }
190+ }
235191 }
236192
237-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {
193+// paint draws the composed client frame (active tab plus status bar) and
194+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
195+// such as switch/create/close/rename/resize so the repaint is complete.
196+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) {
197+ attachmentEpoch := uint64(0)
198+ if len(epochs) != 0 {
199+ attachmentEpoch = epochs[0]
200+ }
238201 tb := sess.activeTab()
239202 if tb == nil {
240203 return
@@ -258,19 +221,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
258221 ac.sendMu.Unlock()
259222 return
260223 }
261- if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
262- ac.sendMu.Unlock()
263- return
264- }
265224 // Composition owns attachment sendMu; initialize its lazy overlay state
266225 // under that same ownership so concurrent fallback paints cannot observe a
267226 // partially published runtime.
268227 ac.initOverlays()
269- if ac.resizePaintPending {
270- ac.resizePaint.stop()
271- ac.resizePaintPending = false
272- reset = true
273- }
274228 overlays := ac.overlays.SnapshotForRender()
275229 repaintAttachedClients := false
276230 defer func() {
@@ -5,7 +5,6 @@ import (
5 "bytes"5 "bytes"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"9 themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
@@ -19,13 +18,6 @@ func completedSynchronizedUpdate(data []byte) bool {
19 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))18 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
20 }19 }
21 20
22-func signal(ch chan struct{}) {
23- select {
24- case ch <- struct{}{}:
25- default:
26- }
27-}
28-
29 // paneRenderable reports dynamic composition visibility. Callers must not21 // paneRenderable reports dynamic composition visibility. Callers must not
30 // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.22 // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
31 func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {23 func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
@@ -42,7 +34,7 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
42 if tb.floating.pane == p {34 if tb.floating.pane == p {
43 return active && attached && tb.floating.state == floatingVisible35 return active && attached && tb.floating.state == floatingVisible
44 }36 }
45- if !((active && attached) || preview) {37+ if (!active || !attached) && !preview {
46 return false38 return false
47 }39 }
48 placements, ok := solvedPlacementsLocked(tb)40 placements, ok := solvedPlacementsLocked(tb)
@@ -93,97 +85,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
93 return85 return
94 }86 }
95 buf := make([]byte, ptyReadBufSize)87 buf := make([]byte, ptyReadBufSize)
96- var resp []byte
97- var clipboards []string
98- attentionCh := make(chan struct{}, 1)
99 p.mu.Lock()88 p.mu.Lock()
100- p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }89+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
101- p.screen.OnBell = func() { signal(attentionCh) }90+ p.screen.OnBell = func() { p.ptyAttention = true }
102- p.screen.OnNotify = func(string, string) { signal(attentionCh) }91+ p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
103- p.screen.OnProgress = func(bool) { signal(attentionCh) }92+ p.screen.OnProgress = func(bool) { p.ptyAttention = true }
104- p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }93+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
105 p.mu.Unlock()94 p.mu.Unlock()
106 for {95 for {
107 n, err := p.pty.Read(buf)96 n, err := p.pty.Read(buf)
108 if n > 0 {97 if n > 0 {
109- data := buf[:n]98+ d.processPTYData(sess, tb, p, buf[:n], true)
110- rc := sess.renderCoordinator()
111- p.mu.Lock()
112- wasSyncing := p.screen.SyncUpdateActive()
113- p.screen.Write(data)
114- p.refreshTerminalTitleLocked()
115- isSyncing := p.screen.SyncUpdateActive()
116- completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
117- var syncGen uint64
118- syncEnded := false
119- if wasSyncing != isSyncing {
120- if isSyncing {
121- syncGen = sess.syncGen.Add(1)
122- p.syncGen = syncGen
123- // Publish the gate while the authoritative screen mutation is
124- // still protected. A concurrent deadline therefore cannot
125- // compose this partial DEC 2026 payload.
126- if rc != nil {
127- rc.noteSyncBeginWithRenderability(p, syncGen, func() bool {
128- return d.paneRenderable(sess, tb, p)
129- }, func() {
130- p.mu.Lock()
131- if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
132- p.screen.ForceSyncEnd()
133- }
134- p.mu.Unlock()
135- })
136- }
137- } else {
138- syncGen = p.syncGen
139- syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
140- }
141- }
142- p.mu.Unlock()
143- renderable := d.paneRenderable(sess, tb, p)
144- markSnapshotDirty(sess)
145- select {
146- case <-attentionCh:
147- d.noteAttention(sess, tb)
148- default:
149- }
150- if len(clipboards) > 0 {
151- for _, b64 := range clipboards {
152- d.forwardClipboardAsync(sess, b64)
153- }
154- clipboards = clipboards[:0]
155- }
156- if len(resp) > 0 {
157- if _, writeErr := p.pty.Write(resp); writeErr != nil {
158- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
159- }
160- resp = resp[:0]
161- }
162- if rc != nil {
163- if wasSyncing != isSyncing {
164- if isSyncing {
165- // The accumulated synchronized batch is the pending render only
166- // while this pane can currently reach a composition target.
167- if renderable {
168- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
169- }
170- } else if syncEnded && renderable {
171- // State removal happened under pane.mu. Publish a new urgent
172- // completion after unlock: detach/replace may have cleared the
173- // pending batch work, so fireCurrent alone could not wake a
174- // headless picker preview or replacement attachment.
175- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
176- }
177- }
178- if completeSyncRead && renderable {
179- // The entire batch completed in this read. Its final screen state
180- // is ready now, so bypass the bulk debounce without arming a
181- // watchdog for a batch that no longer exists.
182- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
183- } else if renderable && !isSyncing && wasSyncing == isSyncing {
184- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
185- }
186- }
187 }99 }
188 if err != nil {100 if err != nil {
189 if p.onExit != nil {101 if p.onExit != nil {
@@ -196,45 +108,96 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
196 }108 }
197 }109 }
198 110
199-// paint draws the composed client frame (active tab plus status bar) and111+// processPTYData is the sole VT parsing path. While a resize apply owns the
200-// sends the resulting bytes. The renderer shadow is reset on explicit invalidations112+// pane, reader calls append exact bytes and returns immediately; it never waits
201-// such as switch/create/close/rename/resize so the repaint is complete.113+// for PTY.Resize or loses a short read.
202-func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {114+func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
203- ac.resizePaint.stop()115+ rc := sess.renderCoordinator()
204- ac.resizePaintGeneration++116+ p.mu.Lock()
205- generation := ac.resizePaintGeneration117+ if bufferDuringApply && p.resizeApplying {
206- ac.resizePaintPending = true118+ p.resizePending = append(p.resizePending, data...)
207- ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {119+ p.mu.Unlock()
208- d.invalidateForResizeGeneration(sess, ac, generation)
209- })
210-}
211-
212-// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
213-// generation rejection, and cancellation while transferring only its eventual
214-// render request to the coordinator.
215-func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
216- ac.sendMu.Lock()
217- if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
218- ac.sendMu.Unlock()
219 return120 return
220 }121 }
221- ac.resizePaint.stop()122+ wasSyncing := p.screen.SyncUpdateActive()
222- ac.resizePaintPending = false123+ p.screen.Write(data)
223- ac.sendMu.Unlock()124+ p.refreshTerminalTitleLocked()
224- d.invalidateRender(sess, ac, true, "render.go")125+ isSyncing := p.screen.SyncUpdateActive()
225-}126+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
226-127+ var syncGen uint64
227-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {128+ syncEnded := false
228- d.paintForResizeGeneration(sess, ac, reset, 0, 0)129+ if wasSyncing != isSyncing {
130+ if isSyncing {
131+ syncGen = sess.syncGen.Add(1)
132+ p.syncGen = syncGen
133+ if rc != nil {
134+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
135+ p.mu.Lock()
136+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
137+ p.screen.ForceSyncEnd()
138+ }
139+ p.mu.Unlock()
140+ })
141+ }
142+ } else {
143+ syncGen = p.syncGen
144+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
145+ }
146+ }
147+ p.mu.Unlock()
148+ renderable := d.paneRenderable(sess, tb, p)
149+ markSnapshotDirty(sess)
150+ d.flushPTYEffects(sess, tb, p)
151+ if rc != nil {
152+ if wasSyncing != isSyncing {
153+ if isSyncing {
154+ if renderable {
155+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
156+ }
157+ } else if syncEnded && renderable {
158+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
159+ }
160+ }
161+ if completeSyncRead && renderable {
162+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
163+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
164+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
165+ }
166+ }
229 }167 }
230 168
231-// paintCoordinatorWake composes a coordinator wake only for its captured169+// flushPTYEffects preserves the normal response/title/attention/clipboard
232-// attachment incarnation. The epoch is checked after sendMu is acquired.170+// behavior for both direct reads and resize-buffer replay.
233-func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {171+func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
234- d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)172+ p.mu.Lock()
173+ attention := p.ptyAttention
174+ p.ptyAttention = false
175+ responses := append([]byte(nil), p.ptyResponses...)
176+ p.ptyResponses = p.ptyResponses[:0]
177+ clipboards := append([]string(nil), p.ptyClipboards...)
178+ p.ptyClipboards = p.ptyClipboards[:0]
179+ p.mu.Unlock()
180+ if attention {
181+ d.noteAttention(sess, tb)
182+ }
183+ for _, b64 := range clipboards {
184+ d.forwardClipboardAsync(sess, b64)
185+ }
186+ if len(responses) > 0 {
187+ if _, err := p.pty.Write(responses); err != nil {
188+ d.log.Warn("pty response write failed", "err", err, "session", sess.name)
189+ }
190+ }
235 }191 }
236 192
237-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {193+// paint draws the composed client frame (active tab plus status bar) and
194+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
195+// such as switch/create/close/rename/resize so the repaint is complete.
196+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) {
197+ attachmentEpoch := uint64(0)
198+ if len(epochs) != 0 {
199+ attachmentEpoch = epochs[0]
200+ }
238 tb := sess.activeTab()201 tb := sess.activeTab()
239 if tb == nil {202 if tb == nil {
240 return203 return
@@ -258,19 +221,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
258 ac.sendMu.Unlock()221 ac.sendMu.Unlock()
259 return222 return
260 }223 }
261- if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
262- ac.sendMu.Unlock()
263- return
264- }
265 // Composition owns attachment sendMu; initialize its lazy overlay state224 // Composition owns attachment sendMu; initialize its lazy overlay state
266 // under that same ownership so concurrent fallback paints cannot observe a225 // under that same ownership so concurrent fallback paints cannot observe a
267 // partially published runtime.226 // partially published runtime.
268 ac.initOverlays()227 ac.initOverlays()
269- if ac.resizePaintPending {
270- ac.resizePaint.stop()
271- ac.resizePaintPending = false
272- reset = true
273- }
274 overlays := ac.overlays.SnapshotForRender()228 overlays := ac.overlays.SnapshotForRender()
275 repaintAttachedClients := false229 repaintAttachedClients := false
276 defer func() {230 defer func() {
M internal/usecase/daemon/render_coordinator.go +250 -14
@@ -67,12 +67,13 @@ type renderWake struct {
6767
6868 // resizeRequestMetadata is the coordinator-owned latest requested resize
6969 // state: S3's transaction entry point reads it through resizeSnapshot. It is
70-// metadata ownership only — S1/S2 dispatch stays with the retained PR #71
71-// attachment timer path.
70+// metadata ownership only — S1/S2 dispatch stays with the coordinator
71+// epoch timer path.
7272 type resizeRequestMetadata struct {
73- size domain.Size
74- source *attachedClient
75- epoch uint64
73+ size domain.Size
74+ source *attachedClient
75+ epoch uint64 // latest requested epoch
76+ committed uint64 // latest published epoch
7677 }
7778
7879 // renderCoordinatorOptions wires one coordinator instance.
@@ -158,8 +159,23 @@ type renderCoordinator struct {
158159 detached bool
159160 torndown bool
160161
161- resize resizeRequestMetadata
162- metrics renderCoordinatorBurstMetrics
162+ resize resizeRequestMetadata
163+ // resizeTimer is independent from render invalidation deadlines. It owns
164+ // resize burst coalescing and is cancelled by the same lifecycle changes.
165+ resizeTimer ports.Timer
166+ resizeCancel chan struct{}
167+ // resizeDone closes after the latest timer callback has either been
168+ // rejected or completed its transaction. It provides a completion edge
169+ // without polling scheduler state.
170+ resizeDone chan struct{}
171+ resizeGen uint64
172+ // retry is a separate, latest-committed-epoch retry lane. It is cancelled
173+ // with resize requests and attachment lifecycle changes; callbacks also
174+ // validate their epoch before touching a pane.
175+ retryTimer ports.Timer
176+ retryCancel chan struct{}
177+ retryGen uint64
178+ metrics renderCoordinatorBurstMetrics
163179 // generation invalidates callbacks from superseded deadline/watchdog timers.
164180 // Each worker also owns an explicit cancellation channel: fake timers may
165181 // expose a nil or never-firing C channel, so Stop alone cannot release a
@@ -609,7 +625,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
609625 // attachment.
610626 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
611627 c.mu.Lock()
612- var timer ports.Timer
628+ var timer, resizeTimer, retryTimer ports.Timer
613629 if c.attachment == ac {
614630 c.advanceAttachmentEpochLocked(ac)
615631 c.attachment = nil
@@ -624,9 +640,22 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
624640 c.generation++
625641 c.armed = false
626642 timer = c.detachNormalTimerLocked()
643+ if c.resizeCancel != nil {
644+ close(c.resizeCancel)
645+ c.resizeCancel = nil
646+ }
647+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
648+ if c.retryCancel != nil {
649+ close(c.retryCancel)
650+ c.retryCancel = nil
651+ }
652+ retryTimer, c.retryTimer = c.retryTimer, nil
653+ c.retryGen++
627654 }
628655 c.mu.Unlock()
629656 stopTimer(timer)
657+ stopTimer(resizeTimer)
658+ stopTimer(retryTimer)
630659 }
631660
632661 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -641,7 +670,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
641670 // which predated coordinator ownership. In that case nil has no pending
642671 // identity to invalidate, and the replacement becomes the first bound
643672 // attachment atomically with this lifecycle transition.
644- var timer ports.Timer
673+ var timer, resizeTimer, retryTimer ports.Timer
645674 if c.attachment == old || c.attachment == nil {
646675 c.advanceAttachmentEpochLocked(old, replacement)
647676 c.attachment = replacement
@@ -659,9 +688,22 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
659688 c.generation++
660689 c.armed = false
661690 timer = c.detachNormalTimerLocked()
691+ if c.resizeCancel != nil {
692+ close(c.resizeCancel)
693+ c.resizeCancel = nil
694+ }
695+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
696+ if c.retryCancel != nil {
697+ close(c.retryCancel)
698+ c.retryCancel = nil
699+ }
700+ retryTimer, c.retryTimer = c.retryTimer, nil
701+ c.retryGen++
662702 }
663703 c.mu.Unlock()
664704 stopTimer(timer)
705+ stopTimer(resizeTimer)
706+ stopTimer(retryTimer)
665707 }
666708
667709 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -677,6 +719,19 @@ func (c *renderCoordinator) noteSessionTeardown() {
677719 c.previewWake = nil
678720 c.previewWakes = nil
679721 c.pending = false
722+ if c.resizeCancel != nil {
723+ close(c.resizeCancel)
724+ c.resizeCancel = nil
725+ }
726+ resizeTimer := c.resizeTimer
727+ c.resizeTimer = nil
728+ if c.retryCancel != nil {
729+ close(c.retryCancel)
730+ c.retryCancel = nil
731+ }
732+ retryTimer := c.retryTimer
733+ c.retryTimer = nil
734+ c.retryGen++
680735 c.ackDeferred = false
681736 c.pendingPreview = false
682737 c.generation++
@@ -685,22 +740,203 @@ func (c *renderCoordinator) noteSessionTeardown() {
685740 timers := c.detachSyncTimersLocked()
686741 c.mu.Unlock()
687742 stopTimer(timer)
743+ stopTimer(resizeTimer)
744+ stopTimer(retryTimer)
688745 stopTimers(timers)
689746 }
690747
691748 // recordResizeRequest records the latest requested geometry and source before
692-// the request delegates to the retained PR #71 attachment path. It returns
749+// the request delegates to the coordinator epoch path. It returns
693750 // the strictly monotonically increased epoch, or 0 when the source is stale.
694751 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
695752 c.mu.Lock()
696- defer c.mu.Unlock()
697753 if c.torndown || c.attachment != source {
754+ c.mu.Unlock()
698755 return 0
699756 }
757+ // An intervening request supersedes any failed-pane retry, even if a fake
758+ // timer delivers a stopped callback afterwards.
759+ if c.retryCancel != nil {
760+ close(c.retryCancel)
761+ c.retryCancel = nil
762+ }
763+ retryTimer := c.retryTimer
764+ c.retryTimer = nil
765+ c.retryGen++
700766 c.resize.epoch++
701767 c.resize.size = size
702768 c.resize.source = source
703- return c.resize.epoch
769+ epoch := c.resize.epoch
770+ c.mu.Unlock()
771+ stopTimer(retryTimer)
772+ return epoch
773+}
774+
775+// scheduleResize records a latest-wins request and runs apply after the bounded
776+// bulk window. The callback never runs with c.mu held.
777+func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 {
778+ epoch := c.recordResizeRequest(size, source)
779+ if epoch == 0 {
780+ return 0
781+ }
782+ c.mu.Lock()
783+ old := c.resizeTimer
784+ if c.resizeCancel != nil {
785+ close(c.resizeCancel)
786+ }
787+ c.resizeGen++
788+ gen := c.resizeGen
789+ cancel := make(chan struct{})
790+ done := make(chan struct{})
791+ c.resizeCancel = cancel
792+ c.resizeDone = done
793+ clock := c.opts.clock
794+ c.resizeTimer = nil
795+ c.mu.Unlock()
796+ stopTimer(old)
797+ if clock == nil {
798+ run(epoch)
799+ close(done)
800+ return epoch
801+ }
802+ timer := clock.NewTimer(minOutputRenderDeadline)
803+ timerC := timer.C()
804+ if timerC == nil {
805+ stopTimer(timer)
806+ run(epoch)
807+ close(done)
808+ return epoch
809+ }
810+ c.mu.Lock()
811+ if c.resizeGen != gen || c.torndown {
812+ c.mu.Unlock()
813+ stopTimer(timer)
814+ close(done)
815+ return epoch
816+ }
817+ c.resizeTimer = timer
818+ c.mu.Unlock()
819+ go func() {
820+ defer close(done)
821+ select {
822+ case <-timerC:
823+ case <-cancel:
824+ return
825+ }
826+ c.mu.Lock()
827+ valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
828+ c.mu.Unlock()
829+ c.clearResizeTimer(gen)
830+ if valid {
831+ run(epoch)
832+ }
833+ }()
834+ return epoch
835+}
836+
837+// clearResizeTimer releases timer ownership only for the matching resize generation.
838+// A stale callback must not clear a newer request's timer or cancellation channel.
839+func (c *renderCoordinator) clearResizeTimer(gen uint64) {
840+ c.mu.Lock()
841+ defer c.mu.Unlock()
842+ if c.resizeGen != gen {
843+ return
844+ }
845+ c.resizeTimer = nil
846+ c.resizeCancel = nil
847+}
848+
849+// resizeCurrent verifies that an apply/commit attempt still owns the newest
850+// request. commit also advances the separately observable committed epoch.
851+func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
852+ c.mu.Lock()
853+ defer c.mu.Unlock()
854+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch {
855+ return false
856+ }
857+ if commit {
858+ if c.resize.committed >= epoch {
859+ return false
860+ }
861+ c.resize.committed = epoch
862+ }
863+ return true
864+}
865+
866+// scheduleResizeRetry serializes failed-pane retries through the injected
867+// clock. Only a retry for the most recently committed epoch can run.
868+func (c *renderCoordinator) scheduleResizeRetry(epoch uint64, source *attachedClient, run func()) {
869+ c.mu.Lock()
870+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
871+ c.mu.Unlock()
872+ return
873+ }
874+ old := c.retryTimer
875+ if c.retryCancel != nil {
876+ close(c.retryCancel)
877+ }
878+ c.retryGen++
879+ gen := c.retryGen
880+ cancel := make(chan struct{})
881+ c.retryCancel, c.retryTimer = cancel, nil
882+ clock := c.opts.clock
883+ c.mu.Unlock()
884+ stopTimer(old)
885+ if clock == nil {
886+ run()
887+ return
888+ }
889+ timer := clock.NewTimer(minOutputRenderDeadline)
890+ timerC := timer.C()
891+ if timerC == nil {
892+ // A nil timer channel is the deterministic disabled-clock contract used
893+ // by headless tests; do not spin retries synchronously.
894+ stopTimer(timer)
895+ return
896+ }
897+ c.mu.Lock()
898+ valid := !c.torndown && c.retryGen == gen && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
899+ if valid {
900+ c.retryTimer = timer
901+ }
902+ c.mu.Unlock()
903+ if !valid {
904+ stopTimer(timer)
905+ return
906+ }
907+ go func() {
908+ select {
909+ case <-timerC:
910+ case <-cancel:
911+ return
912+ }
913+ c.mu.Lock()
914+ valid := !c.torndown && c.retryGen == gen && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
915+ if valid {
916+ c.retryTimer = nil
917+ c.retryCancel = nil
918+ }
919+ c.mu.Unlock()
920+ if valid {
921+ run()
922+ }
923+ }()
924+}
925+
926+// retryCurrent is stricter than resizeCurrent: retries may only repair the
927+// newest geometry that has already been published.
928+func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) bool {
929+ c.mu.Lock()
930+ defer c.mu.Unlock()
931+ return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
932+}
933+
934+// resizeCallbackDone returns the completion edge for the latest scheduled
935+// resize callback. A nil result means no resize deadline is pending.
936+func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
937+ c.mu.Lock()
938+ defer c.mu.Unlock()
939+ return c.resizeDone
704940 }
705941
706942 // resizeSnapshot returns a locked copy of the latest resize metadata.
@@ -744,7 +980,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
744980 }
745981 }
746982 if ac != nil {
747- d.paint(sess, ac, reset)
983+ d.paint(sess, ac, reset, 0)
748984 }
749985 }
750986
@@ -760,7 +996,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
760996 }
761997 }
762998 if ac != nil {
763- d.paint(sess, ac, reset)
999+ d.paint(sess, ac, reset, 0)
7641000 }
7651001 }
7661002
@@ -67,12 +67,13 @@ type renderWake struct {
67 67
68 // resizeRequestMetadata is the coordinator-owned latest requested resize68 // resizeRequestMetadata is the coordinator-owned latest requested resize
69 // state: S3's transaction entry point reads it through resizeSnapshot. It is69 // state: S3's transaction entry point reads it through resizeSnapshot. It is
70-// metadata ownership only — S1/S2 dispatch stays with the retained PR #7170+// metadata ownership only — S1/S2 dispatch stays with the coordinator
71-// attachment timer path.71+// epoch timer path.
72 type resizeRequestMetadata struct {72 type resizeRequestMetadata struct {
73- size domain.Size73+ size domain.Size
74- source *attachedClient74+ source *attachedClient
75- epoch uint6475+ epoch uint64 // latest requested epoch
76+ committed uint64 // latest published epoch
76 }77 }
77 78
78 // renderCoordinatorOptions wires one coordinator instance.79 // renderCoordinatorOptions wires one coordinator instance.
@@ -158,8 +159,23 @@ type renderCoordinator struct {
158 detached bool159 detached bool
159 torndown bool160 torndown bool
160 161
161- resize resizeRequestMetadata162+ resize resizeRequestMetadata
162- metrics renderCoordinatorBurstMetrics163+ // resizeTimer is independent from render invalidation deadlines. It owns
164+ // resize burst coalescing and is cancelled by the same lifecycle changes.
165+ resizeTimer ports.Timer
166+ resizeCancel chan struct{}
167+ // resizeDone closes after the latest timer callback has either been
168+ // rejected or completed its transaction. It provides a completion edge
169+ // without polling scheduler state.
170+ resizeDone chan struct{}
171+ resizeGen uint64
172+ // retry is a separate, latest-committed-epoch retry lane. It is cancelled
173+ // with resize requests and attachment lifecycle changes; callbacks also
174+ // validate their epoch before touching a pane.
175+ retryTimer ports.Timer
176+ retryCancel chan struct{}
177+ retryGen uint64
178+ metrics renderCoordinatorBurstMetrics
163 // generation invalidates callbacks from superseded deadline/watchdog timers.179 // generation invalidates callbacks from superseded deadline/watchdog timers.
164 // Each worker also owns an explicit cancellation channel: fake timers may180 // Each worker also owns an explicit cancellation channel: fake timers may
165 // expose a nil or never-firing C channel, so Stop alone cannot release a181 // expose a nil or never-firing C channel, so Stop alone cannot release a
@@ -609,7 +625,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
609 // attachment.625 // attachment.
610 func (c *renderCoordinator) noteDetach(ac *attachedClient) {626 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
611 c.mu.Lock()627 c.mu.Lock()
612- var timer ports.Timer628+ var timer, resizeTimer, retryTimer ports.Timer
613 if c.attachment == ac {629 if c.attachment == ac {
614 c.advanceAttachmentEpochLocked(ac)630 c.advanceAttachmentEpochLocked(ac)
615 c.attachment = nil631 c.attachment = nil
@@ -624,9 +640,22 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
624 c.generation++640 c.generation++
625 c.armed = false641 c.armed = false
626 timer = c.detachNormalTimerLocked()642 timer = c.detachNormalTimerLocked()
643+ if c.resizeCancel != nil {
644+ close(c.resizeCancel)
645+ c.resizeCancel = nil
646+ }
647+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
648+ if c.retryCancel != nil {
649+ close(c.retryCancel)
650+ c.retryCancel = nil
651+ }
652+ retryTimer, c.retryTimer = c.retryTimer, nil
653+ c.retryGen++
627 }654 }
628 c.mu.Unlock()655 c.mu.Unlock()
629 stopTimer(timer)656 stopTimer(timer)
657+ stopTimer(resizeTimer)
658+ stopTimer(retryTimer)
630 }659 }
631 660
632 // noteReplace hands the coordinator from old to replacement; callbacks661 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -641,7 +670,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
641 // which predated coordinator ownership. In that case nil has no pending670 // which predated coordinator ownership. In that case nil has no pending
642 // identity to invalidate, and the replacement becomes the first bound671 // identity to invalidate, and the replacement becomes the first bound
643 // attachment atomically with this lifecycle transition.672 // attachment atomically with this lifecycle transition.
644- var timer ports.Timer673+ var timer, resizeTimer, retryTimer ports.Timer
645 if c.attachment == old || c.attachment == nil {674 if c.attachment == old || c.attachment == nil {
646 c.advanceAttachmentEpochLocked(old, replacement)675 c.advanceAttachmentEpochLocked(old, replacement)
647 c.attachment = replacement676 c.attachment = replacement
@@ -659,9 +688,22 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
659 c.generation++688 c.generation++
660 c.armed = false689 c.armed = false
661 timer = c.detachNormalTimerLocked()690 timer = c.detachNormalTimerLocked()
691+ if c.resizeCancel != nil {
692+ close(c.resizeCancel)
693+ c.resizeCancel = nil
694+ }
695+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
696+ if c.retryCancel != nil {
697+ close(c.retryCancel)
698+ c.retryCancel = nil
699+ }
700+ retryTimer, c.retryTimer = c.retryTimer, nil
701+ c.retryGen++
662 }702 }
663 c.mu.Unlock()703 c.mu.Unlock()
664 stopTimer(timer)704 stopTimer(timer)
705+ stopTimer(resizeTimer)
706+ stopTimer(retryTimer)
665 }707 }
666 708
667 // notePark invalidates pending wakes when the attachment parks for resume.709 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -677,6 +719,19 @@ func (c *renderCoordinator) noteSessionTeardown() {
677 c.previewWake = nil719 c.previewWake = nil
678 c.previewWakes = nil720 c.previewWakes = nil
679 c.pending = false721 c.pending = false
722+ if c.resizeCancel != nil {
723+ close(c.resizeCancel)
724+ c.resizeCancel = nil
725+ }
726+ resizeTimer := c.resizeTimer
727+ c.resizeTimer = nil
728+ if c.retryCancel != nil {
729+ close(c.retryCancel)
730+ c.retryCancel = nil
731+ }
732+ retryTimer := c.retryTimer
733+ c.retryTimer = nil
734+ c.retryGen++
680 c.ackDeferred = false735 c.ackDeferred = false
681 c.pendingPreview = false736 c.pendingPreview = false
682 c.generation++737 c.generation++
@@ -685,22 +740,203 @@ func (c *renderCoordinator) noteSessionTeardown() {
685 timers := c.detachSyncTimersLocked()740 timers := c.detachSyncTimersLocked()
686 c.mu.Unlock()741 c.mu.Unlock()
687 stopTimer(timer)742 stopTimer(timer)
743+ stopTimer(resizeTimer)
744+ stopTimer(retryTimer)
688 stopTimers(timers)745 stopTimers(timers)
689 }746 }
690 747
691 // recordResizeRequest records the latest requested geometry and source before748 // recordResizeRequest records the latest requested geometry and source before
692-// the request delegates to the retained PR #71 attachment path. It returns749+// the request delegates to the coordinator epoch path. It returns
693 // the strictly monotonically increased epoch, or 0 when the source is stale.750 // the strictly monotonically increased epoch, or 0 when the source is stale.
694 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {751 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
695 c.mu.Lock()752 c.mu.Lock()
696- defer c.mu.Unlock()
697 if c.torndown || c.attachment != source {753 if c.torndown || c.attachment != source {
754+ c.mu.Unlock()
698 return 0755 return 0
699 }756 }
757+ // An intervening request supersedes any failed-pane retry, even if a fake
758+ // timer delivers a stopped callback afterwards.
759+ if c.retryCancel != nil {
760+ close(c.retryCancel)
761+ c.retryCancel = nil
762+ }
763+ retryTimer := c.retryTimer
764+ c.retryTimer = nil
765+ c.retryGen++
700 c.resize.epoch++766 c.resize.epoch++
701 c.resize.size = size767 c.resize.size = size
702 c.resize.source = source768 c.resize.source = source
703- return c.resize.epoch769+ epoch := c.resize.epoch
770+ c.mu.Unlock()
771+ stopTimer(retryTimer)
772+ return epoch
773+}
774+
775+// scheduleResize records a latest-wins request and runs apply after the bounded
776+// bulk window. The callback never runs with c.mu held.
777+func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 {
778+ epoch := c.recordResizeRequest(size, source)
779+ if epoch == 0 {
780+ return 0
781+ }
782+ c.mu.Lock()
783+ old := c.resizeTimer
784+ if c.resizeCancel != nil {
785+ close(c.resizeCancel)
786+ }
787+ c.resizeGen++
788+ gen := c.resizeGen
789+ cancel := make(chan struct{})
790+ done := make(chan struct{})
791+ c.resizeCancel = cancel
792+ c.resizeDone = done
793+ clock := c.opts.clock
794+ c.resizeTimer = nil
795+ c.mu.Unlock()
796+ stopTimer(old)
797+ if clock == nil {
798+ run(epoch)
799+ close(done)
800+ return epoch
801+ }
802+ timer := clock.NewTimer(minOutputRenderDeadline)
803+ timerC := timer.C()
804+ if timerC == nil {
805+ stopTimer(timer)
806+ run(epoch)
807+ close(done)
808+ return epoch
809+ }
810+ c.mu.Lock()
811+ if c.resizeGen != gen || c.torndown {
812+ c.mu.Unlock()
813+ stopTimer(timer)
814+ close(done)
815+ return epoch
816+ }
817+ c.resizeTimer = timer
818+ c.mu.Unlock()
819+ go func() {
820+ defer close(done)
821+ select {
822+ case <-timerC:
823+ case <-cancel:
824+ return
825+ }
826+ c.mu.Lock()
827+ valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
828+ c.mu.Unlock()
829+ c.clearResizeTimer(gen)
830+ if valid {
831+ run(epoch)
832+ }
833+ }()
834+ return epoch
835+}
836+
837+// clearResizeTimer releases timer ownership only for the matching resize generation.
838+// A stale callback must not clear a newer request's timer or cancellation channel.
839+func (c *renderCoordinator) clearResizeTimer(gen uint64) {
840+ c.mu.Lock()
841+ defer c.mu.Unlock()
842+ if c.resizeGen != gen {
843+ return
844+ }
845+ c.resizeTimer = nil
846+ c.resizeCancel = nil
847+}
848+
849+// resizeCurrent verifies that an apply/commit attempt still owns the newest
850+// request. commit also advances the separately observable committed epoch.
851+func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
852+ c.mu.Lock()
853+ defer c.mu.Unlock()
854+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch {
855+ return false
856+ }
857+ if commit {
858+ if c.resize.committed >= epoch {
859+ return false
860+ }
861+ c.resize.committed = epoch
862+ }
863+ return true
864+}
865+
866+// scheduleResizeRetry serializes failed-pane retries through the injected
867+// clock. Only a retry for the most recently committed epoch can run.
868+func (c *renderCoordinator) scheduleResizeRetry(epoch uint64, source *attachedClient, run func()) {
869+ c.mu.Lock()
870+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
871+ c.mu.Unlock()
872+ return
873+ }
874+ old := c.retryTimer
875+ if c.retryCancel != nil {
876+ close(c.retryCancel)
877+ }
878+ c.retryGen++
879+ gen := c.retryGen
880+ cancel := make(chan struct{})
881+ c.retryCancel, c.retryTimer = cancel, nil
882+ clock := c.opts.clock
883+ c.mu.Unlock()
884+ stopTimer(old)
885+ if clock == nil {
886+ run()
887+ return
888+ }
889+ timer := clock.NewTimer(minOutputRenderDeadline)
890+ timerC := timer.C()
891+ if timerC == nil {
892+ // A nil timer channel is the deterministic disabled-clock contract used
893+ // by headless tests; do not spin retries synchronously.
894+ stopTimer(timer)
895+ return
896+ }
897+ c.mu.Lock()
898+ valid := !c.torndown && c.retryGen == gen && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
899+ if valid {
900+ c.retryTimer = timer
901+ }
902+ c.mu.Unlock()
903+ if !valid {
904+ stopTimer(timer)
905+ return
906+ }
907+ go func() {
908+ select {
909+ case <-timerC:
910+ case <-cancel:
911+ return
912+ }
913+ c.mu.Lock()
914+ valid := !c.torndown && c.retryGen == gen && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
915+ if valid {
916+ c.retryTimer = nil
917+ c.retryCancel = nil
918+ }
919+ c.mu.Unlock()
920+ if valid {
921+ run()
922+ }
923+ }()
924+}
925+
926+// retryCurrent is stricter than resizeCurrent: retries may only repair the
927+// newest geometry that has already been published.
928+func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) bool {
929+ c.mu.Lock()
930+ defer c.mu.Unlock()
931+ return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
932+}
933+
934+// resizeCallbackDone returns the completion edge for the latest scheduled
935+// resize callback. A nil result means no resize deadline is pending.
936+func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
937+ c.mu.Lock()
938+ defer c.mu.Unlock()
939+ return c.resizeDone
704 }940 }
705 941
706 // resizeSnapshot returns a locked copy of the latest resize metadata.942 // resizeSnapshot returns a locked copy of the latest resize metadata.
@@ -744,7 +980,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
744 }980 }
745 }981 }
746 if ac != nil {982 if ac != nil {
747- d.paint(sess, ac, reset)983+ d.paint(sess, ac, reset, 0)
748 }984 }
749 }985 }
750 986
@@ -760,7 +996,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
760 }996 }
761 }997 }
762 if ac != nil {998 if ac != nil {
763- d.paint(sess, ac, reset)999+ d.paint(sess, ac, reset, 0)
764 }1000 }
765 }1001 }
766 1002
M internal/usecase/daemon/render_coordinator_publication_test.go +1 -1
@@ -16,7 +16,7 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
1616 rc.attach(old)
1717 rc.noteReplace(old, replacement)
1818
19- // This models a PR #71 resize callback that passed its sendMu generation
19+ // This models a stale resize callback that passed its sendMu generation
2020 // check immediately before a replacement was published.
2121 rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})
2222 require.Empty(t, invalidations)
@@ -16,7 +16,7 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
16 rc.attach(old)16 rc.attach(old)
17 rc.noteReplace(old, replacement)17 rc.noteReplace(old, replacement)
18 18
19- // This models a PR #71 resize callback that passed its sendMu generation19+ // This models a stale resize callback that passed its sendMu generation
20 // check immediately before a replacement was published.20 // check immediately before a replacement was published.
21 rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})21 rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})
22 require.Empty(t, invalidations)22 require.Empty(t, invalidations)
M internal/usecase/daemon/render_coordinator_test.go +45 -18
@@ -198,6 +198,15 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
198198 }
199199 }
200200
201+func awaitResizeCallback(t *testing.T, rc *renderCoordinator) {
202+ t.Helper()
203+ done := rc.resizeCallbackDone()
204+ if done == nil {
205+ t.Fatal("coordinator did not publish a resize callback completion")
206+ }
207+ <-done
208+}
209+
201210 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
202211 t.Helper()
203212 select {
@@ -989,13 +998,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
989998 require.Same(t, owner, snap.source)
990999 require.Equal(t, uint64(3), snap.epoch)
9911000
992- // Metadata ownership is independent of the retained PR #71
993- // attachment dispatch state.
994- owner.sendMu.Lock()
995- generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
996- owner.sendMu.Unlock()
997- require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
998- require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
1001+ // Resize state is exclusively coordinator-owned.
1002+ require.Zero(t, snap.committed)
9991003 },
10001004 },
10011005 {
@@ -1028,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
10281032 h.rc.attach(staleOwner)
10291033 require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
10301034
1031- // This models a retained PR #71 callback which captured the old
1035+ // This models a stale callback which captured the old
10321036 // attachment before lifecycle ownership changed.
10331037 staleCallback := func() uint64 {
10341038 return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
@@ -1169,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) {
11691173 name: "retained resize dispatch",
11701174 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
11711175 clk := newCoordinatorMockClock(t, 2)
1172- d.clock = clk.clock
1176+ // The coordinator owns deadline time; install the deterministic
1177+ // clock on the already-attached coordinator rather than changing
1178+ // Daemon's construction-time clock afterwards.
1179+ sess.renderCoordinator().opts.clock = clk.clock
11731180 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
11741181 awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}
1182+ awaitResizeCallback(t, sess.renderCoordinator())
11751183 },
11761184 },
11771185 {
@@ -1237,7 +1245,7 @@ func TestProducerInvalidationInventory(t *testing.T) {
12371245 }
12381246 }
12391247
1240-// --- retained PR #71 resize dispatch ----------------------------------------------
1248+// --- coordinator resize dispatch ----------------------------------------------
12411249
12421250 func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
12431251 p, release := newBlockingPTY(t)
@@ -1324,6 +1332,29 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
13241332 requireNoCoordinatorOutputFrame(t, ownerSends)
13251333 }
13261334
1335+func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) {
1336+ rc := newRenderCoordinator(renderCoordinatorOptions{})
1337+ newer := &portsmocks.MockTimer{}
1338+
1339+ rc.mu.Lock()
1340+ rc.resizeGen = 2
1341+ rc.resizeTimer = newer
1342+ rc.resizeCancel = make(chan struct{})
1343+ rc.mu.Unlock()
1344+
1345+ rc.clearResizeTimer(1)
1346+ rc.mu.Lock()
1347+ require.Same(t, newer, rc.resizeTimer)
1348+ require.NotNil(t, rc.resizeCancel)
1349+ rc.mu.Unlock()
1350+
1351+ rc.clearResizeTimer(2)
1352+ rc.mu.Lock()
1353+ require.Nil(t, rc.resizeTimer)
1354+ require.Nil(t, rc.resizeCancel)
1355+ rc.mu.Unlock()
1356+}
1357+
13271358 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
13281359 clock := portsmocks.NewMockClock(t)
13291360 timer := portsmocks.NewMockTimer(t)
@@ -1452,7 +1483,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14521483 })
14531484 }
14541485
1455-func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1486+func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
14561487 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {
14571488 t.Helper()
14581489 p, releasePTY := newBlockingPTY(t)
@@ -1478,12 +1509,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
14781509 timer := awaitCoordinatorScheduledTimer(t, clk)
14791510 require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
14801511 require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1481- ac.sendMu.Lock()
1482- generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
1483- ac.sendMu.Unlock()
1484- require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
1485- require.True(t, pending, "PR #71 pending dispatch must remain armed")
1486-
14871512 snap := sess.renderCoordinator().resizeSnapshot()
14881513 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
14891514 "the coordinator must record the latest requested resize before delegating")
@@ -1492,6 +1517,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
14921517 requireNoCoordinatorOutputFrame(t, sends)
14931518
14941519 timer.ch <- time.Time{}
1520+ awaitResizeCallback(t, sess.renderCoordinator())
14951521 inv := awaitInvalidation(t, invs)
14961522 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
14971523 requireNoInvalidation(t, invs)
@@ -1513,6 +1539,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
15131539 requireNoCoordinatorOutputFrame(t, sends)
15141540
15151541 latest.ch <- time.Time{}
1542+ awaitResizeCallback(t, sess.renderCoordinator())
15161543 inv := awaitInvalidation(t, invs)
15171544 require.True(t, inv.reset)
15181545 requireNoInvalidation(t, invs)
@@ -1525,7 +1552,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
15251552
15261553 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
15271554 timer := awaitCoordinatorScheduledTimer(t, clk)
1528- ac.cancelResizePaint()
1555+ sess.renderCoordinator().noteDetach(ac)
15291556
15301557 timer.ch <- time.Time{}
15311558 requireNoInvalidation(t, invs)
@@ -198,6 +198,15 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
198 }198 }
199 }199 }
200 200
201+func awaitResizeCallback(t *testing.T, rc *renderCoordinator) {
202+ t.Helper()
203+ done := rc.resizeCallbackDone()
204+ if done == nil {
205+ t.Fatal("coordinator did not publish a resize callback completion")
206+ }
207+ <-done
208+}
209+
201 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {210 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
202 t.Helper()211 t.Helper()
203 select {212 select {
@@ -989,13 +998,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
989 require.Same(t, owner, snap.source)998 require.Same(t, owner, snap.source)
990 require.Equal(t, uint64(3), snap.epoch)999 require.Equal(t, uint64(3), snap.epoch)
991 1000
992- // Metadata ownership is independent of the retained PR #711001+ // Resize state is exclusively coordinator-owned.
993- // attachment dispatch state.1002+ require.Zero(t, snap.committed)
994- owner.sendMu.Lock()
995- generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
996- owner.sendMu.Unlock()
997- require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
998- require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
999 },1003 },
1000 },1004 },
1001 {1005 {
@@ -1028,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1028 h.rc.attach(staleOwner)1032 h.rc.attach(staleOwner)
1029 require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))1033 require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
1030 1034
1031- // This models a retained PR #71 callback which captured the old1035+ // This models a stale callback which captured the old
1032 // attachment before lifecycle ownership changed.1036 // attachment before lifecycle ownership changed.
1033 staleCallback := func() uint64 {1037 staleCallback := func() uint64 {
1034 return h.rc.recordResizeRequest(sz(80, 20), staleOwner)1038 return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
@@ -1169,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) {
1169 name: "retained resize dispatch",1173 name: "retained resize dispatch",
1170 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1174 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1171 clk := newCoordinatorMockClock(t, 2)1175 clk := newCoordinatorMockClock(t, 2)
1172- d.clock = clk.clock1176+ // The coordinator owns deadline time; install the deterministic
1177+ // clock on the already-attached coordinator rather than changing
1178+ // Daemon's construction-time clock afterwards.
1179+ sess.renderCoordinator().opts.clock = clk.clock
1173 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})1180 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1174 awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}1181 awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}
1182+ awaitResizeCallback(t, sess.renderCoordinator())
1175 },1183 },
1176 },1184 },
1177 {1185 {
@@ -1237,7 +1245,7 @@ func TestProducerInvalidationInventory(t *testing.T) {
1237 }1245 }
1238 }1246 }
1239 1247
1240-// --- retained PR #71 resize dispatch ----------------------------------------------1248+// --- coordinator resize dispatch ----------------------------------------------
1241 1249
1242 func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {1250 func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1243 p, release := newBlockingPTY(t)1251 p, release := newBlockingPTY(t)
@@ -1324,6 +1332,29 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
1324 requireNoCoordinatorOutputFrame(t, ownerSends)1332 requireNoCoordinatorOutputFrame(t, ownerSends)
1325 }1333 }
1326 1334
1335+func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) {
1336+ rc := newRenderCoordinator(renderCoordinatorOptions{})
1337+ newer := &portsmocks.MockTimer{}
1338+
1339+ rc.mu.Lock()
1340+ rc.resizeGen = 2
1341+ rc.resizeTimer = newer
1342+ rc.resizeCancel = make(chan struct{})
1343+ rc.mu.Unlock()
1344+
1345+ rc.clearResizeTimer(1)
1346+ rc.mu.Lock()
1347+ require.Same(t, newer, rc.resizeTimer)
1348+ require.NotNil(t, rc.resizeCancel)
1349+ rc.mu.Unlock()
1350+
1351+ rc.clearResizeTimer(2)
1352+ rc.mu.Lock()
1353+ require.Nil(t, rc.resizeTimer)
1354+ require.Nil(t, rc.resizeCancel)
1355+ rc.mu.Unlock()
1356+}
1357+
1327 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {1358 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1328 clock := portsmocks.NewMockClock(t)1359 clock := portsmocks.NewMockClock(t)
1329 timer := portsmocks.NewMockTimer(t)1360 timer := portsmocks.NewMockTimer(t)
@@ -1452,7 +1483,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1452 })1483 })
1453 }1484 }
1454 1485
1455-func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {1486+func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1456 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {1487 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {
1457 t.Helper()1488 t.Helper()
1458 p, releasePTY := newBlockingPTY(t)1489 p, releasePTY := newBlockingPTY(t)
@@ -1478,12 +1509,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1478 timer := awaitCoordinatorScheduledTimer(t, clk)1509 timer := awaitCoordinatorScheduledTimer(t, clk)
1479 require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)1510 require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
1480 require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)1511 require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1481- ac.sendMu.Lock()
1482- generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
1483- ac.sendMu.Unlock()
1484- require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
1485- require.True(t, pending, "PR #71 pending dispatch must remain armed")
1486-
1487 snap := sess.renderCoordinator().resizeSnapshot()1512 snap := sess.renderCoordinator().resizeSnapshot()
1488 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,1513 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
1489 "the coordinator must record the latest requested resize before delegating")1514 "the coordinator must record the latest requested resize before delegating")
@@ -1492,6 +1517,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1492 requireNoCoordinatorOutputFrame(t, sends)1517 requireNoCoordinatorOutputFrame(t, sends)
1493 1518
1494 timer.ch <- time.Time{}1519 timer.ch <- time.Time{}
1520+ awaitResizeCallback(t, sess.renderCoordinator())
1495 inv := awaitInvalidation(t, invs)1521 inv := awaitInvalidation(t, invs)
1496 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")1522 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1497 requireNoInvalidation(t, invs)1523 requireNoInvalidation(t, invs)
@@ -1513,6 +1539,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1513 requireNoCoordinatorOutputFrame(t, sends)1539 requireNoCoordinatorOutputFrame(t, sends)
1514 1540
1515 latest.ch <- time.Time{}1541 latest.ch <- time.Time{}
1542+ awaitResizeCallback(t, sess.renderCoordinator())
1516 inv := awaitInvalidation(t, invs)1543 inv := awaitInvalidation(t, invs)
1517 require.True(t, inv.reset)1544 require.True(t, inv.reset)
1518 requireNoInvalidation(t, invs)1545 requireNoInvalidation(t, invs)
@@ -1525,7 +1552,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1525 1552
1526 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})1553 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1527 timer := awaitCoordinatorScheduledTimer(t, clk)1554 timer := awaitCoordinatorScheduledTimer(t, clk)
1528- ac.cancelResizePaint()1555+ sess.renderCoordinator().noteDetach(ac)
1529 1556
1530 timer.ch <- time.Time{}1557 timer.ch <- time.Time{}
1531 requireNoInvalidation(t, invs)1558 requireNoInvalidation(t, invs)
M internal/usecase/daemon/resume.go +0 -4
@@ -48,10 +48,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
4848 if !d.prepareParkAttachment(sess, ac) {
4949 return false
5050 }
51- // A parked attachment has no valid visible destination. Cancel its
52- // resize-idle callback before registering the grace timer; resume performs
53- // a fresh full paint.
54- ac.cancelResizePaint()
5551 d.mu.Lock()
5652 if d.closing {
5753 d.mu.Unlock()
@@ -48,10 +48,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
48 if !d.prepareParkAttachment(sess, ac) {48 if !d.prepareParkAttachment(sess, ac) {
49 return false49 return false
50 }50 }
51- // A parked attachment has no valid visible destination. Cancel its
52- // resize-idle callback before registering the grace timer; resume performs
53- // a fresh full paint.
54- ac.cancelResizePaint()
55 d.mu.Lock()51 d.mu.Lock()
56 if d.closing {52 if d.closing {
57 d.mu.Unlock()53 d.mu.Unlock()
M internal/usecase/daemon/session.go +13 -3
@@ -53,6 +53,13 @@ type session struct {
5353 // clipboard.go) written for this session, removed best-effort in
5454 // killSession.
5555 clipFiles []string
56+
57+ // floatingLaunchMu is ownership synchronization for external floating
58+ // launches. It is separate from mu so PTYFactory.Open never holds a
59+ // session, tab, or daemon architecture lock.
60+ floatingLaunchMu sync.Mutex
61+ floatingLaunchStopping bool
62+ floatingLaunches map[*floatingLaunch]struct{}
5663 }
5764
5865 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -124,7 +131,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
124131 closeTabs(tabs)
125132 return nil, err
126133 }
127- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
134+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
128135 if err != nil {
129136 closeTabs(tabs)
130137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -265,7 +272,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
265272 if err != nil {
266273 return err
267274 }
268- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
275+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
269276 if err != nil {
270277 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
271278 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -724,12 +731,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
724731 rc.noteSessionTeardown()
725732 }
726733 if ac != nil {
727- ac.cancelResizePaint()
728734 d.unregisterPreview(ac)
729735 ac.clearPreviousSession()
730736 ac.setSession(nil)
731737 }
732738
739+ // Prevent queued launches from entering Open, then cancel the parent
740+ // context. The launch worker owns any late PTY result and closes it rather
741+ // than publishing it into the torn-down tab.
742+ sess.stopFloatingLaunches()
733743 sess.cancel()
734744 sess.mu.Lock()
735745 tabs := append([]*tab(nil), sess.tabs...)
@@ -53,6 +53,13 @@ type session struct {
53 // clipboard.go) written for this session, removed best-effort in53 // clipboard.go) written for this session, removed best-effort in
54 // killSession.54 // killSession.
55 clipFiles []string55 clipFiles []string
56+
57+ // floatingLaunchMu is ownership synchronization for external floating
58+ // launches. It is separate from mu so PTYFactory.Open never holds a
59+ // session, tab, or daemon architecture lock.
60+ floatingLaunchMu sync.Mutex
61+ floatingLaunchStopping bool
62+ floatingLaunches map[*floatingLaunch]struct{}
56 }63 }
57 64
58 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.65 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -124,7 +131,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
124 closeTabs(tabs)131 closeTabs(tabs)
125 return nil, err132 return nil, err
126 }133 }
127- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)134+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
128 if err != nil {135 if err != nil {
129 closeTabs(tabs)136 closeTabs(tabs)
130 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -265,7 +272,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
265 if err != nil {272 if err != nil {
266 return err273 return err
267 }274 }
268- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)275+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
269 if err != nil {276 if err != nil {
270 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")277 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
271 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)278 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -724,12 +731,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
724 rc.noteSessionTeardown()731 rc.noteSessionTeardown()
725 }732 }
726 if ac != nil {733 if ac != nil {
727- ac.cancelResizePaint()
728 d.unregisterPreview(ac)734 d.unregisterPreview(ac)
729 ac.clearPreviousSession()735 ac.clearPreviousSession()
730 ac.setSession(nil)736 ac.setSession(nil)
731 }737 }
732 738
739+ // Prevent queued launches from entering Open, then cancel the parent
740+ // context. The launch worker owns any late PTY result and closes it rather
741+ // than publishing it into the torn-down tab.
742+ sess.stopFloatingLaunches()
733 sess.cancel()743 sess.cancel()
734 sess.mu.Lock()744 sess.mu.Lock()
735 tabs := append([]*tab(nil), sess.tabs...)745 tabs := append([]*tab(nil), sess.tabs...)
M internal/usecase/daemon/session_test.go +82 -51
@@ -21,6 +21,11 @@ import (
2121 )
2222
2323 // --- test doubles -----------------------------------------------------------
24+func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
25+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
26+ return got != normalSize && got.Valid()
27+ })).Return(floating, nil).Maybe()
28+}
2429
2530 // stubClock returns timers whose channel never fires, so a scheduler under it
2631 // blocks in its debounce loop until the session context is cancelled. Used by
@@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
3439 f := portsmocks.NewMockPTYFactory(t)
3540 var dirs []string
3641 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
42+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
43+ func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
3944 dirs = append(dirs, dir)
4045 if len(dirs) == 1 {
4146 return first, nil
@@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4449 },
4550 ).Twice()
4651 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48- return got != normalSize && got.Valid()
49- })).Return(floating, nil).Once()
52+ expectFloatingPrewarmOpen(f, normalSize, floating)
5053
5154 d := newTestDaemon(t, f, stubClock{})
5255 tr := portsmocks.NewMockTransport(t)
@@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
7275 releaseFirst()
7376 releaseSecond()
7477 d.sessWg.Wait()
75- select {
76- case <-floating.done:
77- default:
78- t.Fatal("floating prewarm PTY was not closed")
79- }
78+ // Teardown may cancel a queued prewarm before it enters Open; an opened
79+ // prewarm is closed by teardownFloating.
8080 }
8181
8282 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
202202 p1, release1 := newBlockingPTY(t)
203203 defer release1()
204204 p2 := portsmocks.NewMockPTY(t)
205- opened := make(chan struct{})
205+ openCtx := make(chan context.Context, 1)
206206 releaseOpen := make(chan struct{})
207207 closed := make(chan struct{})
208208 p2.EXPECT().Close().RunAndReturn(func() error {
@@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
213213
214214 f := portsmocks.NewMockPTYFactory(t)
215215 normalSize := domain.Size{Cols: 80, Rows: 22}
216- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
216+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
217217 floating := newQuietPTY()
218- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
219- return got != normalSize && got.Valid()
220- })).Return(floating, nil).Once()
221- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
222- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
223- close(opened)
224- <-releaseOpen
218+ expectFloatingPrewarmOpen(f, normalSize, floating)
219+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
221+ openCtx <- ctx
222+ select {
223+ case <-ctx.Done():
224+ case <-releaseOpen:
225+ }
225226 return p2, nil
226227 },
227228 ).Once()
@@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
237238
238239 errCh := make(chan error, 1)
239240 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
240- <-opened
241+ ctx := <-openCtx
241242 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
243+
244+ cancelled := true
245+ select {
246+ case <-ctx.Done():
247+ case <-time.After(time.Second):
248+ cancelled = false
249+ }
242250 close(releaseOpen)
243251
252+ if !cancelled {
253+ t.Error("killSession did not cancel the context passed to PTYFactory.Open")
254+ }
244255 require.Error(t, <-errCh)
245256 select {
246257 case <-closed:
@@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
251262 releaseConn()
252263 hg.Wait()
253264 d.sessWg.Wait()
254- select {
255- case <-floating.done:
256- default:
257- t.Fatal("floating prewarm PTY was not closed")
258- }
265+ // A queued prewarm may be cancelled before Open during teardown.
259266 }
260267
261268 // --- ephemeral numbering ----------------------------------------------------
@@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
508515 // --- shutdown/create interlock ------------------------------------------------
509516
510517 // TestHelloRacingShutdownIsRejected covers the interlock between the
511-// registry-empty shutdown decision and session creation: once killSession has
512-// removed the last session (making shutdown irreversible via doneOnce), a
513-// Hello landing at any later instant must be rejected cleanly — never spawn a
514-// child that no teardown pass would reap. The interleaving is deterministic:
515-// killSession runs to completion before the racing Hellos are handled, which
516-// models the worst case (insertion attempt after the shutdown snapshot).
518+// registry-empty shutdown decision and session creation. In particular, the
519+// old `.Once()` factory expectation was wrong: firstPaint legitimately queues
520+// a floating prewarm, so an Open that starts before killSession is valid work,
521+// not a leaked post-shutdown child. The factory markers classify timing at the
522+// Open boundary: this test first observes that valid prewarm, then verifies
523+// that d.done (closed synchronously by killSession) is never followed by an
524+// Open start.
517525 func TestHelloRacingShutdownIsRejected(t *testing.T) {
518526 p, releasePTY := newBlockingPTY(t)
527+ floating := newQuietPTY()
519528 f := portsmocks.NewMockPTYFactory(t)
520- // Exactly one child may ever be spawned; a second Open call (a leaked
521- // child) fails the test via mock expectations.
522- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
529+ normalSize := domain.Size{Cols: 80, Rows: 22}
530+ preShutdownFloatingOpen := make(chan struct{}, 1)
531+ var opensAfterShutdown atomic.Int32
523532 d := newTestDaemon(t, f, stubClock{})
533+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
534+ func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
535+ select {
536+ case <-d.done:
537+ opensAfterShutdown.Add(1)
538+ default:
539+ }
540+ if size == normalSize {
541+ return p, nil
542+ }
543+ select {
544+ case preShutdownFloatingOpen <- struct{}{}:
545+ default:
546+ }
547+ return floating, nil
548+ },
549+ ).Maybe()
524550
525551 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
526552 var hg sync.WaitGroup
527553 hg.Go(func() { d.handleConn(tr1) })
528554 awaitFrame(t, sends1, ports.MsgWelcome)
555+ // firstPaint starts the background prewarm asynchronously. It is valid for
556+ // this Open to occur before shutdown, so do not mistake it for the racing
557+ // Hello's forbidden launch.
558+ awaitFrame(t, sends1, ports.MsgOutput)
559+ select {
560+ case <-preShutdownFloatingOpen:
561+ case <-time.After(time.Second):
562+ t.Fatal("legitimate pre-shutdown floating prewarm did not start")
563+ }
529564 sess := firstSession(d)
530565 require.NotNil(t, sess)
531566
@@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
555590 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
556591 }
557592 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
593+ require.Zero(t, opensAfterShutdown.Load(), "no PTYFactory.Open may start after d.done/shutdown completion")
558594
559595 release1()
560596 releasePTY()
561597 hg.Wait()
562598 d.sessWg.Wait()
563599 d.waitNotifies()
600+ require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown")
564601 }
565602
566603 // --- wedged-client teardown ---------------------------------------------------
@@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
10901127
10911128 var gotEnv []string
10921129 f := portsmocks.NewMockPTYFactory(t)
1093- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1094- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1130+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1131+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
10951132 gotEnv = append([]string(nil), env...)
10961133 return p, nil
10971134 },
@@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12431280 var opens [][]string
12441281 f := portsmocks.NewMockPTYFactory(t)
12451282 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1246- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1247- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1283+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1284+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12481285 opens = append(opens, append([]string(nil), env...))
12491286 if len(opens) == 1 {
12501287 return p1, nil
@@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12531290 },
12541291 ).Twice()
12551292 floating := newQuietPTY()
1256- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1257- return got != normalSize && got.Valid()
1258- })).Return(floating, nil).Once()
1293+ expectFloatingPrewarmOpen(f, normalSize, floating)
12591294
12601295 d := newTestDaemon(t, f, stubClock{})
12611296 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12881323 var opens [][]string
12891324 f := portsmocks.NewMockPTYFactory(t)
12901325 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1291- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1292- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1326+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1327+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12931328 opens = append(opens, append([]string(nil), env...))
12941329 if len(opens) == 1 {
12951330 return p1, nil
@@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12981333 },
12991334 ).Twice()
13001335 floating := newQuietPTY()
1301- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1302- return got != normalSize && got.Valid()
1303- })).Return(floating, nil).Once()
1336+ expectFloatingPrewarmOpen(f, normalSize, floating)
13041337
13051338 d := newTestDaemon(t, f, stubClock{})
13061339 tr1 := portsmocks.NewMockTransport(t)
@@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13351368 var opens [][]string
13361369 f := portsmocks.NewMockPTYFactory(t)
13371370 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1338- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1339- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1371+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1372+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
13401373 opens = append(opens, append([]string(nil), env...))
13411374 if len(opens) == 1 {
13421375 return p1, nil
@@ -1345,9 +1378,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13451378 },
13461379 ).Twice()
13471380 floating := newQuietPTY()
1348- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1349- return got != normalSize && got.Valid()
1350- })).Return(floating, nil).Once()
1381+ expectFloatingPrewarmOpen(f, normalSize, floating)
13511382 d := newTestDaemon(t, f, stubClock{})
13521383 tr := portsmocks.NewMockTransport(t)
13531384 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -21,6 +21,11 @@ import (
21 )21 )
22 22
23 // --- test doubles -----------------------------------------------------------23 // --- test doubles -----------------------------------------------------------
24+func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
25+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
26+ return got != normalSize && got.Valid()
27+ })).Return(floating, nil).Maybe()
28+}
24 29
25 // stubClock returns timers whose channel never fires, so a scheduler under it30 // stubClock returns timers whose channel never fires, so a scheduler under it
26 // blocks in its debounce loop until the session context is cancelled. Used by31 // blocks in its debounce loop until the session context is cancelled. Used by
@@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
34 f := portsmocks.NewMockPTYFactory(t)39 f := portsmocks.NewMockPTYFactory(t)
35 var dirs []string40 var dirs []string
36 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}41 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(42+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {43+ func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
39 dirs = append(dirs, dir)44 dirs = append(dirs, dir)
40 if len(dirs) == 1 {45 if len(dirs) == 1 {
41 return first, nil46 return first, nil
@@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
44 },49 },
45 ).Twice()50 ).Twice()
46 floating := newQuietPTY()51 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {52+ expectFloatingPrewarmOpen(f, normalSize, floating)
48- return got != normalSize && got.Valid()
49- })).Return(floating, nil).Once()
50 53
51 d := newTestDaemon(t, f, stubClock{})54 d := newTestDaemon(t, f, stubClock{})
52 tr := portsmocks.NewMockTransport(t)55 tr := portsmocks.NewMockTransport(t)
@@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
72 releaseFirst()75 releaseFirst()
73 releaseSecond()76 releaseSecond()
74 d.sessWg.Wait()77 d.sessWg.Wait()
75- select {78+ // Teardown may cancel a queued prewarm before it enters Open; an opened
76- case <-floating.done:79+ // prewarm is closed by teardownFloating.
77- default:
78- t.Fatal("floating prewarm PTY was not closed")
79- }
80 }80 }
81 81
82 func TestHandshakeEphemeralHappy(t *testing.T) {82 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
202 p1, release1 := newBlockingPTY(t)202 p1, release1 := newBlockingPTY(t)
203 defer release1()203 defer release1()
204 p2 := portsmocks.NewMockPTY(t)204 p2 := portsmocks.NewMockPTY(t)
205- opened := make(chan struct{})205+ openCtx := make(chan context.Context, 1)
206 releaseOpen := make(chan struct{})206 releaseOpen := make(chan struct{})
207 closed := make(chan struct{})207 closed := make(chan struct{})
208 p2.EXPECT().Close().RunAndReturn(func() error {208 p2.EXPECT().Close().RunAndReturn(func() error {
@@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
213 213
214 f := portsmocks.NewMockPTYFactory(t)214 f := portsmocks.NewMockPTYFactory(t)
215 normalSize := domain.Size{Cols: 80, Rows: 22}215 normalSize := domain.Size{Cols: 80, Rows: 22}
216- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()216+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
217 floating := newQuietPTY()217 floating := newQuietPTY()
218- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {218+ expectFloatingPrewarmOpen(f, normalSize, floating)
219- return got != normalSize && got.Valid()219+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
220- })).Return(floating, nil).Once()220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
221- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(221+ openCtx <- ctx
222- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {222+ select {
223- close(opened)223+ case <-ctx.Done():
224- <-releaseOpen224+ case <-releaseOpen:
225+ }
225 return p2, nil226 return p2, nil
226 },227 },
227 ).Once()228 ).Once()
@@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
237 238
238 errCh := make(chan error, 1)239 errCh := make(chan error, 1)
239 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()240 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
240- <-opened241+ ctx := <-openCtx
241 _ = d.killSession(sess, ports.ReasonSessionKilled, false)242 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
243+
244+ cancelled := true
245+ select {
246+ case <-ctx.Done():
247+ case <-time.After(time.Second):
248+ cancelled = false
249+ }
242 close(releaseOpen)250 close(releaseOpen)
243 251
252+ if !cancelled {
253+ t.Error("killSession did not cancel the context passed to PTYFactory.Open")
254+ }
244 require.Error(t, <-errCh)255 require.Error(t, <-errCh)
245 select {256 select {
246 case <-closed:257 case <-closed:
@@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
251 releaseConn()262 releaseConn()
252 hg.Wait()263 hg.Wait()
253 d.sessWg.Wait()264 d.sessWg.Wait()
254- select {265+ // A queued prewarm may be cancelled before Open during teardown.
255- case <-floating.done:
256- default:
257- t.Fatal("floating prewarm PTY was not closed")
258- }
259 }266 }
260 267
261 // --- ephemeral numbering ----------------------------------------------------268 // --- ephemeral numbering ----------------------------------------------------
@@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
508 // --- shutdown/create interlock ------------------------------------------------515 // --- shutdown/create interlock ------------------------------------------------
509 516
510 // TestHelloRacingShutdownIsRejected covers the interlock between the517 // TestHelloRacingShutdownIsRejected covers the interlock between the
511-// registry-empty shutdown decision and session creation: once killSession has518+// registry-empty shutdown decision and session creation. In particular, the
512-// removed the last session (making shutdown irreversible via doneOnce), a519+// old `.Once()` factory expectation was wrong: firstPaint legitimately queues
513-// Hello landing at any later instant must be rejected cleanly — never spawn a520+// a floating prewarm, so an Open that starts before killSession is valid work,
514-// child that no teardown pass would reap. The interleaving is deterministic:521+// not a leaked post-shutdown child. The factory markers classify timing at the
515-// killSession runs to completion before the racing Hellos are handled, which522+// Open boundary: this test first observes that valid prewarm, then verifies
516-// models the worst case (insertion attempt after the shutdown snapshot).523+// that d.done (closed synchronously by killSession) is never followed by an
524+// Open start.
517 func TestHelloRacingShutdownIsRejected(t *testing.T) {525 func TestHelloRacingShutdownIsRejected(t *testing.T) {
518 p, releasePTY := newBlockingPTY(t)526 p, releasePTY := newBlockingPTY(t)
527+ floating := newQuietPTY()
519 f := portsmocks.NewMockPTYFactory(t)528 f := portsmocks.NewMockPTYFactory(t)
520- // Exactly one child may ever be spawned; a second Open call (a leaked529+ normalSize := domain.Size{Cols: 80, Rows: 22}
521- // child) fails the test via mock expectations.530+ preShutdownFloatingOpen := make(chan struct{}, 1)
522- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()531+ var opensAfterShutdown atomic.Int32
523 d := newTestDaemon(t, f, stubClock{})532 d := newTestDaemon(t, f, stubClock{})
533+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
534+ func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
535+ select {
536+ case <-d.done:
537+ opensAfterShutdown.Add(1)
538+ default:
539+ }
540+ if size == normalSize {
541+ return p, nil
542+ }
543+ select {
544+ case preShutdownFloatingOpen <- struct{}{}:
545+ default:
546+ }
547+ return floating, nil
548+ },
549+ ).Maybe()
524 550
525 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))551 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
526 var hg sync.WaitGroup552 var hg sync.WaitGroup
527 hg.Go(func() { d.handleConn(tr1) })553 hg.Go(func() { d.handleConn(tr1) })
528 awaitFrame(t, sends1, ports.MsgWelcome)554 awaitFrame(t, sends1, ports.MsgWelcome)
555+ // firstPaint starts the background prewarm asynchronously. It is valid for
556+ // this Open to occur before shutdown, so do not mistake it for the racing
557+ // Hello's forbidden launch.
558+ awaitFrame(t, sends1, ports.MsgOutput)
559+ select {
560+ case <-preShutdownFloatingOpen:
561+ case <-time.After(time.Second):
562+ t.Fatal("legitimate pre-shutdown floating prewarm did not start")
563+ }
529 sess := firstSession(d)564 sess := firstSession(d)
530 require.NotNil(t, sess)565 require.NotNil(t, sess)
531 566
@@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
555 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)590 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
556 }591 }
557 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")592 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
593+ require.Zero(t, opensAfterShutdown.Load(), "no PTYFactory.Open may start after d.done/shutdown completion")
558 594
559 release1()595 release1()
560 releasePTY()596 releasePTY()
561 hg.Wait()597 hg.Wait()
562 d.sessWg.Wait()598 d.sessWg.Wait()
563 d.waitNotifies()599 d.waitNotifies()
600+ require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown")
564 }601 }
565 602
566 // --- wedged-client teardown ---------------------------------------------------603 // --- wedged-client teardown ---------------------------------------------------
@@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1090 1127
1091 var gotEnv []string1128 var gotEnv []string
1092 f := portsmocks.NewMockPTYFactory(t)1129 f := portsmocks.NewMockPTYFactory(t)
1093- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1130+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1094- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1131+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1095 gotEnv = append([]string(nil), env...)1132 gotEnv = append([]string(nil), env...)
1096 return p, nil1133 return p, nil
1097 },1134 },
@@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1243 var opens [][]string1280 var opens [][]string
1244 f := portsmocks.NewMockPTYFactory(t)1281 f := portsmocks.NewMockPTYFactory(t)
1245 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1282 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1246- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1283+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1247- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1284+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1248 opens = append(opens, append([]string(nil), env...))1285 opens = append(opens, append([]string(nil), env...))
1249 if len(opens) == 1 {1286 if len(opens) == 1 {
1250 return p1, nil1287 return p1, nil
@@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1253 },1290 },
1254 ).Twice()1291 ).Twice()
1255 floating := newQuietPTY()1292 floating := newQuietPTY()
1256- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1293+ expectFloatingPrewarmOpen(f, normalSize, floating)
1257- return got != normalSize && got.Valid()
1258- })).Return(floating, nil).Once()
1259 1294
1260 d := newTestDaemon(t, f, stubClock{})1295 d := newTestDaemon(t, f, stubClock{})
1261 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}1296 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1288 var opens [][]string1323 var opens [][]string
1289 f := portsmocks.NewMockPTYFactory(t)1324 f := portsmocks.NewMockPTYFactory(t)
1290 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1325 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1291- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1326+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1292- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1327+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1293 opens = append(opens, append([]string(nil), env...))1328 opens = append(opens, append([]string(nil), env...))
1294 if len(opens) == 1 {1329 if len(opens) == 1 {
1295 return p1, nil1330 return p1, nil
@@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1298 },1333 },
1299 ).Twice()1334 ).Twice()
1300 floating := newQuietPTY()1335 floating := newQuietPTY()
1301- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1336+ expectFloatingPrewarmOpen(f, normalSize, floating)
1302- return got != normalSize && got.Valid()
1303- })).Return(floating, nil).Once()
1304 1337
1305 d := newTestDaemon(t, f, stubClock{})1338 d := newTestDaemon(t, f, stubClock{})
1306 tr1 := portsmocks.NewMockTransport(t)1339 tr1 := portsmocks.NewMockTransport(t)
@@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1335 var opens [][]string1368 var opens [][]string
1336 f := portsmocks.NewMockPTYFactory(t)1369 f := portsmocks.NewMockPTYFactory(t)
1337 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1370 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1338- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1371+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1339- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1372+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1340 opens = append(opens, append([]string(nil), env...))1373 opens = append(opens, append([]string(nil), env...))
1341 if len(opens) == 1 {1374 if len(opens) == 1 {
1342 return p1, nil1375 return p1, nil
@@ -1345,9 +1378,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1345 },1378 },
1346 ).Twice()1379 ).Twice()
1347 floating := newQuietPTY()1380 floating := newQuietPTY()
1348- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1381+ expectFloatingPrewarmOpen(f, normalSize, floating)
1349- return got != normalSize && got.Valid()
1350- })).Return(floating, nil).Once()
1351 d := newTestDaemon(t, f, stubClock{})1382 d := newTestDaemon(t, f, stubClock{})
1352 tr := portsmocks.NewMockTransport(t)1383 tr := portsmocks.NewMockTransport(t)
1353 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1384 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
M internal/usecase/daemon/snapshot.go +1 -1
@@ -141,7 +141,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141141 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142142 }
143143 }
144- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
144+ pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145145 if err != nil {
146146 closeOpened()
147147 return err
@@ -141,7 +141,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141 return fmt.Errorf("snapshot: generating pane identity: %w", err)141 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142 }142 }
143 }143 }
144- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)144+ pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145 if err != nil {145 if err != nil {
146 closeOpened()146 closeOpened()
147 return err147 return err
M internal/usecase/daemon/snapshot_restore_test.go +16 -2
@@ -251,6 +251,19 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
251251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252252 }
253253
254+func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
255+ factory := &restorePTYFactory{}
256+ d := newTestDaemon(t, factory, stubClock{})
257+ ctx, cancel := context.WithCancel(context.Background())
258+ defer cancel()
259+
260+ require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
261+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},
262+ }}}))
263+ require.Len(t, factory.opens, 1)
264+ require.Same(t, ctx, factory.opens[0].ctx)
265+}
266+
254267 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
255268 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
256269 Name: "stacked",
@@ -438,18 +451,19 @@ type restorePTYFactory struct {
438451 }
439452
440453 type restorePTYOpen struct {
454+ ctx context.Context
441455 dir string
442456 size domain.Size
443457 env []string
444458 pty *restorePTY
445459 }
446460
447-func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
461+func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448462 f.mu.Lock()
449463 defer f.mu.Unlock()
450464 pty := newRestorePTY()
451465 pty.writeErr = f.writeErr
452- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
466+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
453467 return pty, nil
454468 }
455469
@@ -251,6 +251,19 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252 }252 }
253 253
254+func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
255+ factory := &restorePTYFactory{}
256+ d := newTestDaemon(t, factory, stubClock{})
257+ ctx, cancel := context.WithCancel(context.Background())
258+ defer cancel()
259+
260+ require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
261+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},
262+ }}}))
263+ require.Len(t, factory.opens, 1)
264+ require.Same(t, ctx, factory.opens[0].ctx)
265+}
266+
254 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {267 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
255 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{268 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
256 Name: "stacked",269 Name: "stacked",
@@ -438,18 +451,19 @@ type restorePTYFactory struct {
438 }451 }
439 452
440 type restorePTYOpen struct {453 type restorePTYOpen struct {
454+ ctx context.Context
441 dir string455 dir string
442 size domain.Size456 size domain.Size
443 env []string457 env []string
444 pty *restorePTY458 pty *restorePTY
445 }459 }
446 460
447-func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {461+func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448 f.mu.Lock()462 f.mu.Lock()
449 defer f.mu.Unlock()463 defer f.mu.Unlock()
450 pty := newRestorePTY()464 pty := newRestorePTY()
451 pty.writeErr = f.writeErr465 pty.writeErr = f.writeErr
452- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})466+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
453 return pty, nil467 return pty, nil
454 }468 }
455 469
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -385,7 +385,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
385385 newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
386386 newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
387387 newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
388- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
388+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
389389
390390 require.NoError(t, d.splitPane(sess, nil, layout.Right))
391391 require.True(t, sess.snapDirty.Load())
@@ -385,7 +385,7 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
385 newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()385 newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
386 newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()386 newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
387 newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()387 newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
388- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()388+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
389 389
390 require.NoError(t, d.splitPane(sess, nil, layout.Right))390 require.NoError(t, d.splitPane(sess, nil, layout.Right))
391 require.True(t, sess.snapDirty.Load())391 require.True(t, sess.snapDirty.Load())
M internal/usecase/daemon/status_test.go +1 -1
@@ -1332,7 +1332,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
13321332 }, 2*time.Second, time.Millisecond)
13331333
13341334 // The queued create, switch, and resize transitions may collapse into one
1335- // latest-state wake. Advance every retained PR #71/coordinator deadline
1335+ // latest-state wake. Advance every retained coordinator deadline
13361336 // until that coalesced output is observable.
13371337 var resized ports.Frame
13381338 deadline := time.NewTimer(2 * time.Second)
@@ -1332,7 +1332,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1332 }, 2*time.Second, time.Millisecond)1332 }, 2*time.Second, time.Millisecond)
1333 1333
1334 // The queued create, switch, and resize transitions may collapse into one1334 // The queued create, switch, and resize transitions may collapse into one
1335- // latest-state wake. Advance every retained PR #71/coordinator deadline1335+ // latest-state wake. Advance every retained coordinator deadline
1336 // until that coalesced output is observable.1336 // until that coalesced output is observable.
1337 var resized ports.Frame1337 var resized ports.Frame
1338 deadline := time.NewTimer(2 * time.Second)1338 deadline := time.NewTimer(2 * time.Second)
A internal/usecase/daemon/transactional_resize.go +266 -0
@@ -0,0 +1,266 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// resizeMember is an unpublished layout decision. Prepare only reads guarded
9+// state; apply performs the external PTY call; commit is the sole publisher.
10+type resizeMember struct {
11+ session *session
12+ tab *tab
13+ pane *pane
14+ rect domain.Rect
15+ floating floatingGeometry
16+ isFloating bool
17+ retry bool
18+ ok bool
19+ screenResized bool
20+}
21+
22+type resizePlan struct {
23+ size domain.Size
24+ tabs []*tab
25+ members []resizeMember
26+}
27+
28+func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
29+ plan := resizePlan{size: size}
30+ sess.mu.Lock()
31+ plan.tabs = append(plan.tabs, sess.tabs...)
32+ active := sess.active
33+ sess.mu.Unlock()
34+ for i, tb := range plan.tabs {
35+ tb.mu.Lock()
36+ if tb.tree != nil && tb.tree.Root != nil {
37+ ts := tabSize(size)
38+ area := domain.Rect{Width: ts.Cols, Height: ts.Rows}
39+ if placements, ok := layout.Solve(tb.tree.Root, area); ok {
40+ for _, pl := range placements {
41+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
42+ if p := tb.panes[pl.ID]; p != nil {
43+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: pl.Content})
44+ }
45+ }
46+ }
47+ } else if tb.tree.Root.Kind == layout.Leaf {
48+ // A single pane remains a valid PTY target even when a tiny client
49+ // is below the interactive layout minimum. Keeping it live avoids
50+ // stale screen dimensions (and lost scrollback) until the client
51+ // grows back into a solvable layout.
52+ if p := tb.panes[tb.tree.Root.Leaf]; p != nil {
53+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area})
54+ }
55+ }
56+ }
57+ // Hidden retained popups enter this same primitive when shown; only a
58+ // visible popup participates in an unrelated client resize.
59+ if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
60+ g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
61+ if g.valid() {
62+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
63+ }
64+ }
65+ tb.mu.Unlock()
66+ }
67+ return plan
68+}
69+
70+// applyResize deliberately holds only a pane's resizeMu around its PTY call;
71+// no daemon, session, tab, or pane lock crosses the external boundary.
72+func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
73+ for i := range plan.members {
74+ if len(current) != 0 && !current[0]() {
75+ return false
76+ }
77+ m := &plan.members[i]
78+ m.pane.resizeMu.Lock()
79+ m.pane.mu.Lock()
80+ old, pty := m.pane.rect, m.pane.pty
81+ screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}
82+ needsApply := m.retry || old.Width != m.rect.Width || old.Height != m.rect.Height || screenSize != rectSize(m.rect)
83+ if needsApply && pty != nil {
84+ // Publish this gate before the external call. ptyReader keeps draining
85+ // but does not parse bytes at the stale width while Resize is in flight.
86+ m.pane.resizeApplying = true
87+ }
88+ m.pane.mu.Unlock()
89+ if !needsApply || pty == nil {
90+ m.ok = true
91+ } else if err := pty.Resize(rectSize(m.rect)); err != nil {
92+ d.log.Warn("pty resize failed", "err", err)
93+ d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
94+ } else {
95+ m.ok = true
96+ d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)
97+ m.screenResized = true
98+ }
99+ m.pane.resizeMu.Unlock()
100+ }
101+ return true
102+}
103+
104+// replayResizePending completes a pane's apply epoch. It keeps the gate set
105+// while each buffered batch goes through the normal VT path, so reads arriving
106+// during replay join the same ordered stream. Failure deliberately omits the
107+// Screen.Resize, parsing every byte at the old dimensions.
108+func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
109+ first := true
110+ for {
111+ p.mu.Lock()
112+ if first && resized {
113+ p.screen.Resize(size.Width, size.Height)
114+ }
115+ first = false
116+ data := append([]byte(nil), p.resizePending...)
117+ p.resizePending = p.resizePending[:0]
118+ if len(data) == 0 {
119+ p.resizeApplying = false
120+ p.mu.Unlock()
121+ return
122+ }
123+ p.mu.Unlock()
124+ if sess == nil {
125+ p.mu.Lock()
126+ p.screen.Write(data)
127+ p.refreshTerminalTitleLocked()
128+ p.mu.Unlock()
129+ continue
130+ }
131+ d.processPTYData(sess, tb, p, data, false)
132+ }
133+}
134+
135+func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
136+ // Publish tab layout and every rectangle together only after the coordinator
137+ // has rejected obsolete work. A later request may have applied already, but
138+ // it can never publish through this epoch.
139+ for _, tb := range plan.tabs {
140+ tb.mu.Lock()
141+ tb.size = tabSize(plan.size)
142+ tb.mu.Unlock()
143+ }
144+ for _, m := range plan.members {
145+ // Layout geometry always publishes together. Failed members deliberately
146+ // retain their old VT screen; capture clips that screen to this new rect
147+ // and pads the uncovered cells with normal blanks.
148+ m.pane.mu.Lock()
149+ m.pane.rect = m.rect
150+ if m.isFloating {
151+ m.pane.popupGeometry = m.floating
152+ }
153+ if m.ok && !m.screenResized {
154+ m.pane.screen.Resize(m.rect.Width, m.rect.Height)
155+ }
156+ m.pane.mu.Unlock()
157+ }
158+ if ac != nil {
159+ ac.sendMu.Lock()
160+ ac.size = plan.size
161+ ac.sendMu.Unlock()
162+ }
163+ markSnapshotDirty(sess)
164+}
165+
166+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
167+ rc := sess.renderCoordinator()
168+ if rc == nil {
169+ return
170+ }
171+ snap := rc.resizeSnapshot()
172+ if !rc.resizeCurrent(epoch, ac, false) {
173+ return
174+ }
175+ d.exitCopyMode(ac)
176+ plan := d.prepareResize(sess, snap.size)
177+ if !rc.resizeCurrent(epoch, ac, false) {
178+ return
179+ }
180+ if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
181+ return
182+ }
183+ if !rc.resizeCurrent(epoch, ac, true) {
184+ return
185+ }
186+ d.commitResize(sess, ac, plan)
187+ failed := make([]resizeMember, 0, len(plan.members))
188+ for _, member := range plan.members {
189+ if !member.ok {
190+ failed = append(failed, member)
191+ }
192+ }
193+ if len(failed) != 0 {
194+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
195+ }
196+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
197+ // A successful transaction publishes exactly one full S2 frame. The
198+ // coordinator is the only emission route and stale epochs never reach it.
199+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
200+ // The resize debounce has already elapsed. Consume this sticky reset now;
201+ // fire preserves ACK and synchronized-output gates rather than scheduling a
202+ // second urgent deadline.
203+ rc.fireCurrent(false)
204+}
205+
206+// retryResizeMembers retries only members which failed the committed epoch.
207+// It never republishes geometry: an intervening resize owns that publication.
208+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uint64, members []resizeMember) {
209+ rc := sess.renderCoordinator()
210+ if rc == nil || !rc.retryCurrent(epoch, ac) {
211+ return
212+ }
213+ plan := resizePlan{members: append([]resizeMember(nil), members...)}
214+ for i := range plan.members {
215+ plan.members[i].retry = true
216+ }
217+ if !d.applyResize(&plan, func() bool { return rc.retryCurrent(epoch, ac) }) || !rc.retryCurrent(epoch, ac) {
218+ return
219+ }
220+ failed := make([]resizeMember, 0, len(plan.members))
221+ succeeded := false
222+ for _, member := range plan.members {
223+ if !member.ok {
224+ failed = append(failed, member)
225+ continue
226+ }
227+ member.pane.mu.Lock()
228+ // The rect is the already committed layout target. A successful apply
229+ // already resized before replaying buffered bytes; PTY-less members still
230+ // need their VT resized here. Force a reset below in either case.
231+ if !member.screenResized {
232+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
233+ }
234+ member.pane.mu.Unlock()
235+ succeeded = true
236+ }
237+ if len(failed) != 0 {
238+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
239+ }
240+ if succeeded {
241+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
242+ }
243+}
244+
245+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
246+ if sess == nil || !size.Valid() {
247+ return
248+ }
249+ if ac == nil {
250+ // Headless geometry has no coordinator/transport to coalesce, but keeps
251+ // the same prepare/apply/commit ordering.
252+ plan := d.prepareResize(sess, size)
253+ d.applyResize(&plan)
254+ d.commitResize(sess, nil, plan)
255+ return
256+ }
257+ rc := d.attachCoordinator(sess, nil, ac, true)
258+ if immediate {
259+ epoch := rc.recordResizeRequest(size, ac)
260+ if epoch != 0 {
261+ d.runResizeTransaction(sess, ac, epoch)
262+ }
263+ return
264+ }
265+ rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })
266+}
@@ -0,0 +1,266 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// resizeMember is an unpublished layout decision. Prepare only reads guarded
9+// state; apply performs the external PTY call; commit is the sole publisher.
10+type resizeMember struct {
11+ session *session
12+ tab *tab
13+ pane *pane
14+ rect domain.Rect
15+ floating floatingGeometry
16+ isFloating bool
17+ retry bool
18+ ok bool
19+ screenResized bool
20+}
21+
22+type resizePlan struct {
23+ size domain.Size
24+ tabs []*tab
25+ members []resizeMember
26+}
27+
28+func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
29+ plan := resizePlan{size: size}
30+ sess.mu.Lock()
31+ plan.tabs = append(plan.tabs, sess.tabs...)
32+ active := sess.active
33+ sess.mu.Unlock()
34+ for i, tb := range plan.tabs {
35+ tb.mu.Lock()
36+ if tb.tree != nil && tb.tree.Root != nil {
37+ ts := tabSize(size)
38+ area := domain.Rect{Width: ts.Cols, Height: ts.Rows}
39+ if placements, ok := layout.Solve(tb.tree.Root, area); ok {
40+ for _, pl := range placements {
41+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
42+ if p := tb.panes[pl.ID]; p != nil {
43+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: pl.Content})
44+ }
45+ }
46+ }
47+ } else if tb.tree.Root.Kind == layout.Leaf {
48+ // A single pane remains a valid PTY target even when a tiny client
49+ // is below the interactive layout minimum. Keeping it live avoids
50+ // stale screen dimensions (and lost scrollback) until the client
51+ // grows back into a solvable layout.
52+ if p := tb.panes[tb.tree.Root.Leaf]; p != nil {
53+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area})
54+ }
55+ }
56+ }
57+ // Hidden retained popups enter this same primitive when shown; only a
58+ // visible popup participates in an unrelated client resize.
59+ if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
60+ g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
61+ if g.valid() {
62+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
63+ }
64+ }
65+ tb.mu.Unlock()
66+ }
67+ return plan
68+}
69+
70+// applyResize deliberately holds only a pane's resizeMu around its PTY call;
71+// no daemon, session, tab, or pane lock crosses the external boundary.
72+func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
73+ for i := range plan.members {
74+ if len(current) != 0 && !current[0]() {
75+ return false
76+ }
77+ m := &plan.members[i]
78+ m.pane.resizeMu.Lock()
79+ m.pane.mu.Lock()
80+ old, pty := m.pane.rect, m.pane.pty
81+ screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}
82+ needsApply := m.retry || old.Width != m.rect.Width || old.Height != m.rect.Height || screenSize != rectSize(m.rect)
83+ if needsApply && pty != nil {
84+ // Publish this gate before the external call. ptyReader keeps draining
85+ // but does not parse bytes at the stale width while Resize is in flight.
86+ m.pane.resizeApplying = true
87+ }
88+ m.pane.mu.Unlock()
89+ if !needsApply || pty == nil {
90+ m.ok = true
91+ } else if err := pty.Resize(rectSize(m.rect)); err != nil {
92+ d.log.Warn("pty resize failed", "err", err)
93+ d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
94+ } else {
95+ m.ok = true
96+ d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)
97+ m.screenResized = true
98+ }
99+ m.pane.resizeMu.Unlock()
100+ }
101+ return true
102+}
103+
104+// replayResizePending completes a pane's apply epoch. It keeps the gate set
105+// while each buffered batch goes through the normal VT path, so reads arriving
106+// during replay join the same ordered stream. Failure deliberately omits the
107+// Screen.Resize, parsing every byte at the old dimensions.
108+func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
109+ first := true
110+ for {
111+ p.mu.Lock()
112+ if first && resized {
113+ p.screen.Resize(size.Width, size.Height)
114+ }
115+ first = false
116+ data := append([]byte(nil), p.resizePending...)
117+ p.resizePending = p.resizePending[:0]
118+ if len(data) == 0 {
119+ p.resizeApplying = false
120+ p.mu.Unlock()
121+ return
122+ }
123+ p.mu.Unlock()
124+ if sess == nil {
125+ p.mu.Lock()
126+ p.screen.Write(data)
127+ p.refreshTerminalTitleLocked()
128+ p.mu.Unlock()
129+ continue
130+ }
131+ d.processPTYData(sess, tb, p, data, false)
132+ }
133+}
134+
135+func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
136+ // Publish tab layout and every rectangle together only after the coordinator
137+ // has rejected obsolete work. A later request may have applied already, but
138+ // it can never publish through this epoch.
139+ for _, tb := range plan.tabs {
140+ tb.mu.Lock()
141+ tb.size = tabSize(plan.size)
142+ tb.mu.Unlock()
143+ }
144+ for _, m := range plan.members {
145+ // Layout geometry always publishes together. Failed members deliberately
146+ // retain their old VT screen; capture clips that screen to this new rect
147+ // and pads the uncovered cells with normal blanks.
148+ m.pane.mu.Lock()
149+ m.pane.rect = m.rect
150+ if m.isFloating {
151+ m.pane.popupGeometry = m.floating
152+ }
153+ if m.ok && !m.screenResized {
154+ m.pane.screen.Resize(m.rect.Width, m.rect.Height)
155+ }
156+ m.pane.mu.Unlock()
157+ }
158+ if ac != nil {
159+ ac.sendMu.Lock()
160+ ac.size = plan.size
161+ ac.sendMu.Unlock()
162+ }
163+ markSnapshotDirty(sess)
164+}
165+
166+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
167+ rc := sess.renderCoordinator()
168+ if rc == nil {
169+ return
170+ }
171+ snap := rc.resizeSnapshot()
172+ if !rc.resizeCurrent(epoch, ac, false) {
173+ return
174+ }
175+ d.exitCopyMode(ac)
176+ plan := d.prepareResize(sess, snap.size)
177+ if !rc.resizeCurrent(epoch, ac, false) {
178+ return
179+ }
180+ if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
181+ return
182+ }
183+ if !rc.resizeCurrent(epoch, ac, true) {
184+ return
185+ }
186+ d.commitResize(sess, ac, plan)
187+ failed := make([]resizeMember, 0, len(plan.members))
188+ for _, member := range plan.members {
189+ if !member.ok {
190+ failed = append(failed, member)
191+ }
192+ }
193+ if len(failed) != 0 {
194+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
195+ }
196+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
197+ // A successful transaction publishes exactly one full S2 frame. The
198+ // coordinator is the only emission route and stale epochs never reach it.
199+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
200+ // The resize debounce has already elapsed. Consume this sticky reset now;
201+ // fire preserves ACK and synchronized-output gates rather than scheduling a
202+ // second urgent deadline.
203+ rc.fireCurrent(false)
204+}
205+
206+// retryResizeMembers retries only members which failed the committed epoch.
207+// It never republishes geometry: an intervening resize owns that publication.
208+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uint64, members []resizeMember) {
209+ rc := sess.renderCoordinator()
210+ if rc == nil || !rc.retryCurrent(epoch, ac) {
211+ return
212+ }
213+ plan := resizePlan{members: append([]resizeMember(nil), members...)}
214+ for i := range plan.members {
215+ plan.members[i].retry = true
216+ }
217+ if !d.applyResize(&plan, func() bool { return rc.retryCurrent(epoch, ac) }) || !rc.retryCurrent(epoch, ac) {
218+ return
219+ }
220+ failed := make([]resizeMember, 0, len(plan.members))
221+ succeeded := false
222+ for _, member := range plan.members {
223+ if !member.ok {
224+ failed = append(failed, member)
225+ continue
226+ }
227+ member.pane.mu.Lock()
228+ // The rect is the already committed layout target. A successful apply
229+ // already resized before replaying buffered bytes; PTY-less members still
230+ // need their VT resized here. Force a reset below in either case.
231+ if !member.screenResized {
232+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
233+ }
234+ member.pane.mu.Unlock()
235+ succeeded = true
236+ }
237+ if len(failed) != 0 {
238+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
239+ }
240+ if succeeded {
241+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
242+ }
243+}
244+
245+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
246+ if sess == nil || !size.Valid() {
247+ return
248+ }
249+ if ac == nil {
250+ // Headless geometry has no coordinator/transport to coalesce, but keeps
251+ // the same prepare/apply/commit ordering.
252+ plan := d.prepareResize(sess, size)
253+ d.applyResize(&plan)
254+ d.commitResize(sess, nil, plan)
255+ return
256+ }
257+ rc := d.attachCoordinator(sess, nil, ac, true)
258+ if immediate {
259+ epoch := rc.recordResizeRequest(size, ac)
260+ if epoch != 0 {
261+ d.runResizeTransaction(sess, ac, epoch)
262+ }
263+ return
264+ }
265+ rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })
266+}
A internal/usecase/daemon/transactional_resize_test.go +367 -0
@@ -0,0 +1,367 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
16+)
17+
18+// transactionalResizePTY is deliberately channel-scripted: a test can stop a
19+// transaction at PTY.Resize without relying on scheduler timing.
20+type transactionalResizePTY struct {
21+ mu sync.Mutex
22+ sizes []domain.Size
23+ errs []error
24+ onResize func()
25+ onWrite func([]byte)
26+}
27+
28+func (p *transactionalResizePTY) Resize(size domain.Size) error {
29+ p.mu.Lock()
30+ p.sizes = append(p.sizes, size)
31+ n := len(p.sizes)
32+ hook := p.onResize
33+ var err error
34+ if n <= len(p.errs) {
35+ err = p.errs[n-1]
36+ }
37+ p.mu.Unlock()
38+ if hook != nil {
39+ hook()
40+ }
41+ return err
42+}
43+func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
44+func (p *transactionalResizePTY) Write(b []byte) (int, error) {
45+ p.mu.Lock()
46+ hook := p.onWrite
47+ p.mu.Unlock()
48+ if hook != nil {
49+ hook(append([]byte(nil), b...))
50+ }
51+ return len(b), nil
52+}
53+func (*transactionalResizePTY) Close() error { return nil }
54+func (*transactionalResizePTY) Pid() int { return 0 }
55+func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
56+func (p *transactionalResizePTY) requested() []domain.Size {
57+ p.mu.Lock()
58+ defer p.mu.Unlock()
59+ return append([]domain.Size(nil), p.sizes...)
60+}
61+
62+// resizeReaderPTY is a deterministic child: Resize releases its redraw only
63+// after the daemon has installed resizeApplying, and waits until ptyReader has
64+// accepted that read. It deliberately has no timing dependency.
65+type resizeReaderPTY struct {
66+ redraw []byte
67+ reads chan []byte
68+ accepted chan struct{}
69+ closed chan struct{}
70+ closeOnce sync.Once
71+ mu sync.Mutex
72+ applying func() bool
73+ delivered bool
74+}
75+
76+func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
77+ return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
78+}
79+
80+func (p *resizeReaderPTY) Resize(domain.Size) error {
81+ p.reads <- p.redraw
82+ <-p.accepted
83+ // A second, empty read cannot begin until ptyReader has processed the
84+ // redraw from the first read. This is a channel rendezvous, not a timing
85+ // assumption, and proves the redraw entered resizePending before Resize
86+ // returns to replay it.
87+ p.reads <- nil
88+ <-p.accepted
89+ p.mu.Lock()
90+ p.delivered = p.applying != nil && p.applying()
91+ p.mu.Unlock()
92+ return nil
93+}
94+func (p *resizeReaderPTY) Read(dst []byte) (int, error) {
95+ select {
96+ case data := <-p.reads:
97+ n := copy(dst, data)
98+ p.accepted <- struct{}{}
99+ return n, nil
100+ case <-p.closed:
101+ return 0, io.EOF
102+ }
103+}
104+func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil }
105+func (*resizeReaderPTY) Pid() int { return 0 }
106+func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil }
107+func (p *resizeReaderPTY) Close() error { p.close(); return nil }
108+func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) }
109+func (p *resizeReaderPTY) deliveredWhileApplying() bool {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ return p.delivered
113+}
114+
115+func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
116+ t.Run("success resizes then replays", func(t *testing.T) {
117+ pty := &transactionalResizePTY{}
118+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
120+ p.resizeApplying = true
121+ p.resizePending = []byte("A\x1b[1;81HB")
122+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
123+ require.Equal(t, 120, p.screen.Frame.Width)
124+ require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
125+ require.False(t, p.resizeApplying)
126+ })
127+ t.Run("failure retains old parser width", func(t *testing.T) {
128+ pty := &transactionalResizePTY{}
129+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
130+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
131+ p.resizeApplying = true
132+ p.resizePending = []byte("\x1b[1;81HB")
133+ d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
134+ require.Equal(t, 80, p.screen.Frame.Width)
135+ require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
136+ })
137+ t.Run("batches retain read order", func(t *testing.T) {
138+ pty := &transactionalResizePTY{}
139+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
140+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
141+ p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
142+ pty.onWrite = func([]byte) {
143+ p.mu.Lock()
144+ p.resizePending = append(p.resizePending, 'B')
145+ p.mu.Unlock()
146+ }
147+ p.resizeApplying = true
148+ p.resizePending = []byte("A\x1b[6n")
149+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
150+ require.Equal(t, "AB", screenLineText(p.screen, 0)[:2])
151+ })
152+}
153+
154+func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
155+ responses := make(chan []byte, 1)
156+ pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
157+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
158+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
159+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
160+ p.screen.OnBell = func() { p.ptyAttention = true }
161+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
162+ p.resizeApplying = true
163+ p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n")
164+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
165+
166+ p.mu.Lock()
167+ title := p.title.terminalTitle
168+ p.mu.Unlock()
169+ require.Equal(t, "resized", title)
170+ require.True(t, tb.attention)
171+ require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
172+ // DSR is flushed back to the child and OSC 52 is forwarded through the
173+ // normal asynchronous client path, proving replay uses processPTYData.
174+ frame := awaitFrame(t, sends, ports.MsgOutput)
175+ out, err := ports.UnmarshalOutput(frame.Payload)
176+ require.NoError(t, err)
177+ require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a")
178+}
179+
180+// S3 acceptance: prepare may inspect layout under its locks, but apply must
181+// invoke PTY.Resize after all session/tab/pane locks are released. This is the
182+// deadlock boundary with PTY readers and scripts which synchronously re-enter
183+// daemon state.
184+func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
185+ pty := &transactionalResizePTY{}
186+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
187+ tb := sess.activeTab()
188+ p := tb.focusedPane()
189+ var tabUnlocked, paneUnlocked bool
190+ pty.onResize = func() {
191+ tabUnlocked = tb.mu.TryLock()
192+ if tabUnlocked {
193+ tb.mu.Unlock()
194+ }
195+ paneUnlocked = p.mu.TryLock()
196+ if paneUnlocked {
197+ p.mu.Unlock()
198+ }
199+ }
200+
201+ d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
202+
203+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
204+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
205+}
206+
207+// S3 acceptance: a failed member never publishes speculative parser/screen or
208+// rectangle state. Successful members and the client layout commit together;
209+// composition clips/pads the retained failed screen against that new layout.
210+func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
211+ ok := &transactionalResizePTY{}
212+ failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
213+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
214+ tb := sess.activeTab()
215+ second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
216+ tb.mu.Lock()
217+ tb.panes[second.id] = second
218+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
219+ first := tb.panes["pane-1"]
220+ oldFailedRect := second.rect
221+ oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
222+ tb.mu.Unlock()
223+
224+ d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
225+
226+ require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
227+ require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
228+ require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
229+ "all pane rectangles publish with the committed layout")
230+ require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
231+ require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
232+ require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
233+ "failed PTY retains its parser/screen size for clipped padded composition")
234+ require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
235+}
236+
237+// S3 acceptance: a failed member is retried only for the newest committed
238+// epoch. An intervening request supersedes the old retry, and a successful
239+// retry must resize its VT before the forced full-frame emission.
240+func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
241+ ok := &transactionalResizePTY{}
242+ failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
243+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
244+ tb := sess.activeTab()
245+ retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
246+ tb.mu.Lock()
247+ tb.panes[retry.id] = retry
248+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("retry")}}, Focus: "pane-1"}
249+ tb.mu.Unlock()
250+
251+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
252+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
253+
254+ // The failed pane must not be retried at 49x28 from the obsolete epoch.
255+ // S3 retries 59x32, then commits the parser/screen and requests a reset.
256+ require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
257+ "the failed old apply is historical; only the authoritative newest epoch retries it")
258+ require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
259+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
260+ "the retry belongs to the newest committed epoch")
261+}
262+
263+// Stale lifecycle callbacks may never advance a transaction owned by a
264+// replacement attachment. The coordinator test clock drives callbacks through
265+// channels elsewhere in this package; this ownership assertion is deliberately
266+// synchronous so it cannot hide a race behind a sleep.
267+func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
268+ // Each closure is a callback captured before its lifecycle transition. The
269+ // coordinator must reject it, rather than merely coalescing its paint.
270+ for _, transition := range []struct {
271+ name string
272+ run func(*renderCoordinator, *attachedClient, *attachedClient)
273+ }{
274+ {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},
275+ {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
276+ {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},
277+ {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
278+ } {
279+ t.Run(transition.name, func(t *testing.T) {
280+ rc := newRenderCoordinator(renderCoordinatorOptions{})
281+ old, next := &attachedClient{}, &attachedClient{}
282+ rc.attach(old)
283+ require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
284+ stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
285+ transition.run(rc, old, next)
286+ require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
287+ })
288+ }
289+}
290+
291+// S3 acceptance: three callbacks can be delivered even after their timers were
292+// stopped. Only the newest prepared epoch may apply, commit, and emit one full
293+// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
294+// channels make callback order explicit and do not use wall-clock waits.
295+func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
296+ clock := newCoordinatorMockClock(t, 8)
297+ pty := &transactionalResizePTY{}
298+ d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
299+ d.clock = clock.clock
300+
301+ for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
302+ d.resize(sess, ac, size)
303+ }
304+ // Epoch scheduling leaves PTY geometry untouched until the final
305+ // prepare/apply/commit callback.
306+ require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
307+
308+ var timers []*coordinatorMockTimer
309+ for len(timers) < 3 {
310+ timers = append(timers, <-clock.timers)
311+ }
312+ for _, timer := range timers {
313+ timer.ch <- time.Time{}
314+ }
315+ awaitResizeCallback(t, sess.renderCoordinator())
316+ require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
317+ snapshot := sess.renderCoordinator().resizeSnapshot()
318+ require.Equal(t, uint64(3), snapshot.epoch)
319+ require.Equal(t, snapshot.epoch, snapshot.committed)
320+ // The resize deadline is the only debounce: commit fires its sticky reset
321+ // through S2 immediately (subject to ACK/sync gates).
322+ frame := <-frames
323+ require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
324+ select {
325+ case extra := <-frames:
326+ t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
327+ default:
328+ }
329+}
330+
331+// Entering copy mode is invalidated at transaction prepare time, while a
332+// blocked apply keeps the old committed pane rectangle visible to mouse and
333+// composition. Channels, rather than a delay, make the publication boundary
334+// deterministic.
335+func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
336+ for _, tc := range []struct {
337+ name string
338+ errs []error
339+ }{
340+ {name: "success"},
341+ {name: "partial failure", errs: []error{errors.New("resize failed")}},
342+ } {
343+ t.Run(tc.name, func(t *testing.T) {
344+ entered, release := make(chan struct{}), make(chan struct{})
345+ pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
346+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
347+ tb := sess.activeTab()
348+ p := tb.focusedPane()
349+ old := p.rect
350+ d.enterCopyMode(sess, ac)
351+ ac.overlays.copyMu.Lock()
352+ ac.overlays.copySearch = &visualsearch.Model{}
353+ ac.overlays.copyMu.Unlock()
354+ done := make(chan struct{})
355+ go func() {
356+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
357+ close(done)
358+ }()
359+ <-entered
360+ require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
361+ require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
362+ require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
363+ close(release)
364+ <-done
365+ })
366+ }
367+}
@@ -0,0 +1,367 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
16+)
17+
18+// transactionalResizePTY is deliberately channel-scripted: a test can stop a
19+// transaction at PTY.Resize without relying on scheduler timing.
20+type transactionalResizePTY struct {
21+ mu sync.Mutex
22+ sizes []domain.Size
23+ errs []error
24+ onResize func()
25+ onWrite func([]byte)
26+}
27+
28+func (p *transactionalResizePTY) Resize(size domain.Size) error {
29+ p.mu.Lock()
30+ p.sizes = append(p.sizes, size)
31+ n := len(p.sizes)
32+ hook := p.onResize
33+ var err error
34+ if n <= len(p.errs) {
35+ err = p.errs[n-1]
36+ }
37+ p.mu.Unlock()
38+ if hook != nil {
39+ hook()
40+ }
41+ return err
42+}
43+func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
44+func (p *transactionalResizePTY) Write(b []byte) (int, error) {
45+ p.mu.Lock()
46+ hook := p.onWrite
47+ p.mu.Unlock()
48+ if hook != nil {
49+ hook(append([]byte(nil), b...))
50+ }
51+ return len(b), nil
52+}
53+func (*transactionalResizePTY) Close() error { return nil }
54+func (*transactionalResizePTY) Pid() int { return 0 }
55+func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
56+func (p *transactionalResizePTY) requested() []domain.Size {
57+ p.mu.Lock()
58+ defer p.mu.Unlock()
59+ return append([]domain.Size(nil), p.sizes...)
60+}
61+
62+// resizeReaderPTY is a deterministic child: Resize releases its redraw only
63+// after the daemon has installed resizeApplying, and waits until ptyReader has
64+// accepted that read. It deliberately has no timing dependency.
65+type resizeReaderPTY struct {
66+ redraw []byte
67+ reads chan []byte
68+ accepted chan struct{}
69+ closed chan struct{}
70+ closeOnce sync.Once
71+ mu sync.Mutex
72+ applying func() bool
73+ delivered bool
74+}
75+
76+func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
77+ return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
78+}
79+
80+func (p *resizeReaderPTY) Resize(domain.Size) error {
81+ p.reads <- p.redraw
82+ <-p.accepted
83+ // A second, empty read cannot begin until ptyReader has processed the
84+ // redraw from the first read. This is a channel rendezvous, not a timing
85+ // assumption, and proves the redraw entered resizePending before Resize
86+ // returns to replay it.
87+ p.reads <- nil
88+ <-p.accepted
89+ p.mu.Lock()
90+ p.delivered = p.applying != nil && p.applying()
91+ p.mu.Unlock()
92+ return nil
93+}
94+func (p *resizeReaderPTY) Read(dst []byte) (int, error) {
95+ select {
96+ case data := <-p.reads:
97+ n := copy(dst, data)
98+ p.accepted <- struct{}{}
99+ return n, nil
100+ case <-p.closed:
101+ return 0, io.EOF
102+ }
103+}
104+func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil }
105+func (*resizeReaderPTY) Pid() int { return 0 }
106+func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil }
107+func (p *resizeReaderPTY) Close() error { p.close(); return nil }
108+func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) }
109+func (p *resizeReaderPTY) deliveredWhileApplying() bool {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ return p.delivered
113+}
114+
115+func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
116+ t.Run("success resizes then replays", func(t *testing.T) {
117+ pty := &transactionalResizePTY{}
118+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
120+ p.resizeApplying = true
121+ p.resizePending = []byte("A\x1b[1;81HB")
122+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
123+ require.Equal(t, 120, p.screen.Frame.Width)
124+ require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
125+ require.False(t, p.resizeApplying)
126+ })
127+ t.Run("failure retains old parser width", func(t *testing.T) {
128+ pty := &transactionalResizePTY{}
129+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
130+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
131+ p.resizeApplying = true
132+ p.resizePending = []byte("\x1b[1;81HB")
133+ d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
134+ require.Equal(t, 80, p.screen.Frame.Width)
135+ require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
136+ })
137+ t.Run("batches retain read order", func(t *testing.T) {
138+ pty := &transactionalResizePTY{}
139+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
140+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
141+ p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
142+ pty.onWrite = func([]byte) {
143+ p.mu.Lock()
144+ p.resizePending = append(p.resizePending, 'B')
145+ p.mu.Unlock()
146+ }
147+ p.resizeApplying = true
148+ p.resizePending = []byte("A\x1b[6n")
149+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
150+ require.Equal(t, "AB", screenLineText(p.screen, 0)[:2])
151+ })
152+}
153+
154+func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
155+ responses := make(chan []byte, 1)
156+ pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
157+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
158+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
159+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
160+ p.screen.OnBell = func() { p.ptyAttention = true }
161+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
162+ p.resizeApplying = true
163+ p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n")
164+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
165+
166+ p.mu.Lock()
167+ title := p.title.terminalTitle
168+ p.mu.Unlock()
169+ require.Equal(t, "resized", title)
170+ require.True(t, tb.attention)
171+ require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
172+ // DSR is flushed back to the child and OSC 52 is forwarded through the
173+ // normal asynchronous client path, proving replay uses processPTYData.
174+ frame := awaitFrame(t, sends, ports.MsgOutput)
175+ out, err := ports.UnmarshalOutput(frame.Payload)
176+ require.NoError(t, err)
177+ require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a")
178+}
179+
180+// S3 acceptance: prepare may inspect layout under its locks, but apply must
181+// invoke PTY.Resize after all session/tab/pane locks are released. This is the
182+// deadlock boundary with PTY readers and scripts which synchronously re-enter
183+// daemon state.
184+func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
185+ pty := &transactionalResizePTY{}
186+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
187+ tb := sess.activeTab()
188+ p := tb.focusedPane()
189+ var tabUnlocked, paneUnlocked bool
190+ pty.onResize = func() {
191+ tabUnlocked = tb.mu.TryLock()
192+ if tabUnlocked {
193+ tb.mu.Unlock()
194+ }
195+ paneUnlocked = p.mu.TryLock()
196+ if paneUnlocked {
197+ p.mu.Unlock()
198+ }
199+ }
200+
201+ d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
202+
203+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
204+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
205+}
206+
207+// S3 acceptance: a failed member never publishes speculative parser/screen or
208+// rectangle state. Successful members and the client layout commit together;
209+// composition clips/pads the retained failed screen against that new layout.
210+func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
211+ ok := &transactionalResizePTY{}
212+ failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
213+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
214+ tb := sess.activeTab()
215+ second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
216+ tb.mu.Lock()
217+ tb.panes[second.id] = second
218+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
219+ first := tb.panes["pane-1"]
220+ oldFailedRect := second.rect
221+ oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
222+ tb.mu.Unlock()
223+
224+ d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
225+
226+ require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
227+ require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
228+ require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
229+ "all pane rectangles publish with the committed layout")
230+ require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
231+ require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
232+ require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
233+ "failed PTY retains its parser/screen size for clipped padded composition")
234+ require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
235+}
236+
237+// S3 acceptance: a failed member is retried only for the newest committed
238+// epoch. An intervening request supersedes the old retry, and a successful
239+// retry must resize its VT before the forced full-frame emission.
240+func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
241+ ok := &transactionalResizePTY{}
242+ failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
243+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
244+ tb := sess.activeTab()
245+ retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
246+ tb.mu.Lock()
247+ tb.panes[retry.id] = retry
248+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("retry")}}, Focus: "pane-1"}
249+ tb.mu.Unlock()
250+
251+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
252+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
253+
254+ // The failed pane must not be retried at 49x28 from the obsolete epoch.
255+ // S3 retries 59x32, then commits the parser/screen and requests a reset.
256+ require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
257+ "the failed old apply is historical; only the authoritative newest epoch retries it")
258+ require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
259+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
260+ "the retry belongs to the newest committed epoch")
261+}
262+
263+// Stale lifecycle callbacks may never advance a transaction owned by a
264+// replacement attachment. The coordinator test clock drives callbacks through
265+// channels elsewhere in this package; this ownership assertion is deliberately
266+// synchronous so it cannot hide a race behind a sleep.
267+func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
268+ // Each closure is a callback captured before its lifecycle transition. The
269+ // coordinator must reject it, rather than merely coalescing its paint.
270+ for _, transition := range []struct {
271+ name string
272+ run func(*renderCoordinator, *attachedClient, *attachedClient)
273+ }{
274+ {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},
275+ {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
276+ {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},
277+ {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
278+ } {
279+ t.Run(transition.name, func(t *testing.T) {
280+ rc := newRenderCoordinator(renderCoordinatorOptions{})
281+ old, next := &attachedClient{}, &attachedClient{}
282+ rc.attach(old)
283+ require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
284+ stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
285+ transition.run(rc, old, next)
286+ require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
287+ })
288+ }
289+}
290+
291+// S3 acceptance: three callbacks can be delivered even after their timers were
292+// stopped. Only the newest prepared epoch may apply, commit, and emit one full
293+// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
294+// channels make callback order explicit and do not use wall-clock waits.
295+func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
296+ clock := newCoordinatorMockClock(t, 8)
297+ pty := &transactionalResizePTY{}
298+ d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
299+ d.clock = clock.clock
300+
301+ for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
302+ d.resize(sess, ac, size)
303+ }
304+ // Epoch scheduling leaves PTY geometry untouched until the final
305+ // prepare/apply/commit callback.
306+ require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
307+
308+ var timers []*coordinatorMockTimer
309+ for len(timers) < 3 {
310+ timers = append(timers, <-clock.timers)
311+ }
312+ for _, timer := range timers {
313+ timer.ch <- time.Time{}
314+ }
315+ awaitResizeCallback(t, sess.renderCoordinator())
316+ require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
317+ snapshot := sess.renderCoordinator().resizeSnapshot()
318+ require.Equal(t, uint64(3), snapshot.epoch)
319+ require.Equal(t, snapshot.epoch, snapshot.committed)
320+ // The resize deadline is the only debounce: commit fires its sticky reset
321+ // through S2 immediately (subject to ACK/sync gates).
322+ frame := <-frames
323+ require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
324+ select {
325+ case extra := <-frames:
326+ t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
327+ default:
328+ }
329+}
330+
331+// Entering copy mode is invalidated at transaction prepare time, while a
332+// blocked apply keeps the old committed pane rectangle visible to mouse and
333+// composition. Channels, rather than a delay, make the publication boundary
334+// deterministic.
335+func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
336+ for _, tc := range []struct {
337+ name string
338+ errs []error
339+ }{
340+ {name: "success"},
341+ {name: "partial failure", errs: []error{errors.New("resize failed")}},
342+ } {
343+ t.Run(tc.name, func(t *testing.T) {
344+ entered, release := make(chan struct{}), make(chan struct{})
345+ pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
346+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
347+ tb := sess.activeTab()
348+ p := tb.focusedPane()
349+ old := p.rect
350+ d.enterCopyMode(sess, ac)
351+ ac.overlays.copyMu.Lock()
352+ ac.overlays.copySearch = &visualsearch.Model{}
353+ ac.overlays.copyMu.Unlock()
354+ done := make(chan struct{})
355+ go func() {
356+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
357+ close(done)
358+ }()
359+ <-entered
360+ require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
361+ require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
362+ require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
363+ close(release)
364+ <-done
365+ })
366+ }
367+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close