Merge pull request #76 from bnema/perf/transactional-resize
perf(daemon): make resize transactions coordinator-owned
34 files · +1686 -505
M internal/adapters/pty/pty_linux.go +6 -2
| @@ -8,6 +8,7 @@ package pty | |||
| 8 | 8 | // x/sys/unix plus the standard library. | |
| 9 | 9 | ||
| 10 | 10 | import ( | |
| 11 | + | "context" | |
| 11 | 12 | "errors" | |
| 12 | 13 | "fmt" | |
| 13 | 14 | "io" | |
| @@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} } | |||
| 38 | 39 | // verbatim (nil means inherit the current process environment); the caller | |
| 39 | 40 | // decides TERM and friends. sz sets the terminal window size before the child | |
| 40 | 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 | 46 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | |
| 43 | 47 | if err != nil { | |
| 44 | 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 | 87 | cmd.Env = env | |
| 84 | 88 | cmd.Dir = platform.DirOrHome(dir) | |
| 85 | 89 | 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 caller | 39 | // verbatim (nil means inherit the current process environment); the caller | ||
| 39 | // decides TERM and friends. sz sets the terminal window size before the child | 40 | // 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 = env | 87 | cmd.Env = env | ||
| 84 | cmd.Dir = platform.DirOrHome(dir) | 88 | cmd.Dir = platform.DirOrHome(dir) | ||
| 85 | cmd.Stdin = slave | 89 | cmd.Stdin = slave | ||
M internal/adapters/pty/pty_linux_test.go +8 -7
| @@ -4,6 +4,7 @@ package pty_test | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "context" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "io" | |
| 9 | 10 | "os" | |
| @@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) { | |||
| 53 | 54 | t.Skip("skipping pty integration test in -short mode") | |
| 54 | 55 | } | |
| 55 | 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 | 58 | require.NoError(t, err) | |
| 58 | 59 | t.Cleanup(func() { _ = p.Close() }) | |
| 59 | 60 | ||
| @@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||
| 70 | 71 | t.Skip("skipping pty integration test in -short mode") | |
| 71 | 72 | } | |
| 72 | 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 | 75 | require.NoError(t, err) | |
| 75 | 76 | t.Cleanup(func() { _ = p.Close() }) | |
| 76 | 77 | ||
| @@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) { | |||
| 103 | 104 | t.Skip("skipping pty integration test in -short mode") | |
| 104 | 105 | } | |
| 105 | 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 | 108 | require.NoError(t, err) | |
| 108 | 109 | t.Cleanup(func() { _ = p.Close() }) | |
| 109 | 110 | ||
| @@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) { | |||
| 141 | 142 | for _, tt := range tests { | |
| 142 | 143 | t.Run(tt.name, func(t *testing.T) { | |
| 143 | 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 | 146 | require.NoError(t, err) | |
| 146 | 147 | t.Cleanup(func() { _ = p.Close() }) | |
| 147 | 148 | ||
| @@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) { | |||
| 160 | 161 | t.Skip("skipping pty integration test in -short mode") | |
| 161 | 162 | } | |
| 162 | 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 | 165 | require.NoError(t, err) | |
| 165 | 166 | ||
| 166 | 167 | pid := p.Pid() | |
| @@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||
| 191 | 192 | t.Skip("skipping pty integration test in -short mode") | |
| 192 | 193 | } | |
| 193 | 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 | 196 | require.NoError(t, err) | |
| 196 | 197 | pid := p.Pid() | |
| 197 | 198 | ||
| @@ -212,7 +213,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) { | |||
| 212 | 213 | // deliver SIGWINCH to the foreground process group. | |
| 213 | 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 | 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 | 217 | require.NoError(t, err) | |
| 217 | 218 | t.Cleanup(func() { _ = p.Close() }) | |
| 218 | 219 | ||
| @@ -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 { | |||
| 499 | 499 | } | |
| 500 | 500 | ||
| 501 | 501 | // 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) | |
| 504 | 504 | ||
| 505 | 505 | if len(ret) == 0 { | |
| 506 | 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 | 509 | var r0 ports.PTY | |
| 510 | 510 | 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) | |
| 513 | 513 | } | |
| 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) | |
| 516 | 516 | } else { | |
| 517 | 517 | if ret.Get(0) != nil { | |
| 518 | 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 { | |
| 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) | |
| 523 | 523 | } else { | |
| 524 | 524 | r1 = ret.Error(1) | |
| 525 | 525 | } | |
| @@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct { | |||
| 532 | 532 | } | |
| 533 | 533 | ||
| 534 | 534 | // Open is a helper method to define mock.On call | |
| 535 | + | // - ctx context.Context | |
| 535 | 536 | // - cmd string | |
| 536 | 537 | // - args []string | |
| 537 | 538 | // - env []string | |
| 538 | 539 | // - dir string | |
| 539 | 540 | // - 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)} | |
| 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 | 546 | _c.Call.Run(func(args mock.Arguments) { | |
| 546 | - | var arg0 string | |
| 547 | + | var arg0 context.Context | |
| 547 | 548 | if args[0] != nil { | |
| 548 | - | arg0 = args[0].(string) | |
| 549 | + | arg0 = args[0].(context.Context) | |
| 549 | 550 | } | |
| 550 | - | var arg1 []string | |
| 551 | + | var arg1 string | |
| 551 | 552 | if args[1] != nil { | |
| 552 | - | arg1 = args[1].([]string) | |
| 553 | + | arg1 = args[1].(string) | |
| 553 | 554 | } | |
| 554 | 555 | var arg2 []string | |
| 555 | 556 | if args[2] != nil { | |
| 556 | 557 | arg2 = args[2].([]string) | |
| 557 | 558 | } | |
| 558 | - | var arg3 string | |
| 559 | + | var arg3 []string | |
| 559 | 560 | if args[3] != nil { | |
| 560 | - | arg3 = args[3].(string) | |
| 561 | + | arg3 = args[3].([]string) | |
| 561 | 562 | } | |
| 562 | - | var arg4 domain.Size | |
| 563 | + | var arg4 string | |
| 563 | 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 | 571 | run( | |
| 567 | 572 | arg0, | |
| @@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env | |||
| 569 | 574 | arg2, | |
| 570 | 575 | arg3, | |
| 571 | 576 | arg4, | |
| 577 | + | arg5, | |
| 572 | 578 | ) | |
| 573 | 579 | }) | |
| 574 | 580 | return _c | |
| @@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac | |||
| 579 | 585 | 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 | 589 | _c.Call.Return(run) | |
| 584 | 590 | return _c | |
| 585 | 591 | } | |
| @@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter { | |||||
| 499 | } | 499 | } | ||
| 500 | 500 | ||||
| 501 | // Open provides a mock function for the type MockPTYFactory | 501 | // 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.PTY | 509 | var r0 ports.PTY | ||
| 510 | var r1 error | 510 | 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 call | 534 | // Open is a helper method to define mock.On call | ||
| 535 | + | // - ctx context.Context | |||
| 535 | // - cmd string | 536 | // - cmd string | ||
| 536 | // - args []string | 537 | // - args []string | ||
| 537 | // - env []string | 538 | // - env []string | ||
| 538 | // - dir string | 539 | // - dir string | ||
| 539 | // - sz domain.Size | 540 | // - 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 string | 547 | + | 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 []string | 551 | + | 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 []string | 555 | 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 string | 559 | + | 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.Size | 563 | + | 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 _c | 580 | return _c | ||
| @@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac | |||||
| 579 | return _c | 585 | 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 _c | 590 | return _c | ||
| 585 | } | 591 | } | ||
M internal/ports/ports.go +3 -1
| @@ -24,7 +24,9 @@ type PTY interface { | |||
| 24 | 24 | ||
| 25 | 25 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |
| 26 | 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 | 32 | // 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 { | |||
| 39 | 39 | // pipelineCache is the last successfully emitted composition. pipelineScratch | |
| 40 | 40 | // is its attachment-owned alternate buffer; both are only touched under | |
| 41 | 41 | // 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 | |
| 58 | 55 | // previousSession is guarded independently. It is retained through temporary | |
| 59 | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 60 | 57 | previousSession Guarded[*session] | |
| @@ -71,6 +68,8 @@ type cursorOut struct { | |||
| 71 | 68 | hasStyle bool | |
| 72 | 69 | } | |
| 73 | 70 | ||
| 71 | + | // pendingByteTimer is retained for overlay input timers. Resize timing belongs | |
| 72 | + | // exclusively to renderCoordinator. | |
| 74 | 73 | type pendingByteTimer struct { | |
| 75 | 74 | timer ports.Timer | |
| 76 | 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 | 102 | func (ac *attachedClient) initOverlays() { | |
| 114 | 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 | 402 | // paint occurs under sendMu, closing a park/resume race after this | |
| 414 | 403 | // unlocked coordinator validation. | |
| 415 | 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 | 408 | ackReady: func() bool { | |
| @@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 459 | 448 | if old == nil { | |
| 460 | 449 | return | |
| 461 | 450 | } | |
| 462 | - | old.cancelResizePaint() | |
| 463 | - | ||
| 464 | 451 | d.unregisterPreview(old) | |
| 465 | 452 | old.clearPreviousSession() | |
| 466 | 453 | old.setSession(nil) | |
| @@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 487 | 474 | d.resizeForFirstPaint(sess, ac, clientSize) | |
| 488 | 475 | } | |
| 489 | 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 | 479 | d.activateTab(sess, tb) | |
| 480 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 492 | 481 | } | |
| 493 | 482 | ||
| 494 | 483 | // 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 | 552 | if !sess.detachIfCurrent(ac) { | |
| 564 | 553 | return // already displaced by a newer client; nothing to do | |
| 565 | 554 | } | |
| 566 | - | ac.cancelResizePaint() | |
| 567 | 555 | if rc := sess.renderCoordinator(); rc != nil { | |
| 568 | 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 | 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: | |
| 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. | |
| 629 | 617 | 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) | |
| 675 | 619 | } | |
| 676 | 620 | ||
| 677 | 621 | // 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 | 625 | return | |
| 682 | 626 | } | |
| 683 | 627 | if sess.detachIfCurrent(ac) { | |
| 684 | - | ac.cancelResizePaint() | |
| 685 | 628 | if rc := sess.renderCoordinator(); rc != nil { | |
| 686 | 629 | rc.noteDetach(ac) | |
| 687 | 630 | } | |
| @@ -39,22 +39,19 @@ type attachedClient struct { | |||||
| 39 | // pipelineCache is the last successfully emitted composition. pipelineScratch | 39 | // pipelineCache is the last successfully emitted composition. pipelineScratch | ||
| 40 | // is its attachment-owned alternate buffer; both are only touched under | 40 | // 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 composeCacheInput | 42 | + | pipelineCache composeCacheInput |
| 43 | - | pipelineScratch composeCacheInput | 43 | + | pipelineScratch composeCacheInput |
| 44 | - | renderScratch renderCaptureScratch // only touched while sendMu is held | 44 | + | renderScratch renderCaptureScratch // only touched while sendMu is held |
| 45 | - | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | 45 | + | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held |
| 46 | - | resizePaint pendingByteTimer // guarded by sendMu | 46 | + | size domain.Size |
| 47 | - | resizePaintGeneration uint64 // guarded by sendMu | 47 | + | keys *keys.Router |
| 48 | - | resizePaintPending bool // guarded by sendMu | 48 | + | sess Guarded[*session] |
| 49 | - | size domain.Size | 49 | + | mouseScan mouse.Scanner |
| 50 | - | keys *keys.Router | 50 | + | themeMu sync.Mutex |
| 51 | - | sess Guarded[*session] | 51 | + | theme themeui.Theme |
| 52 | - | mouseScan mouse.Scanner | 52 | + | clientTheme themeui.Theme |
| 53 | - | themeMu sync.Mutex | 53 | + | lastCursor cursorOut |
| 54 | - | theme themeui.Theme | 54 | + | 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 temporary | 55 | // 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 bool | 68 | 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.Timer | 74 | 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 this | 402 | // 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 | return | 449 | 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 messages | 483 | // 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 do | 553 | 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 session | 621 | // 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 | return | 625 | 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 ( | |||
| 22 | 22 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | |
| 23 | 23 | const ( | |
| 24 | 24 | minDebounceInterval = 2 * time.Millisecond | |
| 25 | - | maxDebounceInterval = 16 * time.Millisecond | |
| 26 | 25 | maxSyncUpdateDuration = 500 * time.Millisecond | |
| 27 | 26 | ) | |
| 28 | 27 | ||
| @@ -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.Millisecond | 24 | minDebounceInterval = 2 * time.Millisecond | ||
| 25 | - | maxDebounceInterval = 16 * time.Millisecond | |||
| 26 | maxSyncUpdateDuration = 500 * time.Millisecond | 25 | 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 { | |||
| 142 | 142 | 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 | 146 | <-f.release | |
| 147 | 147 | return nil, io.ErrClosedPipe | |
| 148 | 148 | } | |
| @@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | |||
| 154 | 154 | t.Helper() | |
| 155 | 155 | f := portsmocks.NewMockPTYFactory(t) | |
| 156 | 156 | 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) { | |
| 159 | 159 | if !normal.Valid() { | |
| 160 | 160 | normal = sz | |
| 161 | 161 | } | |
| @@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||
| 174 | 174 | var mu sync.Mutex | |
| 175 | 175 | var normal domain.Size | |
| 176 | 176 | 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) { | |
| 179 | 179 | mu.Lock() | |
| 180 | 180 | defer mu.Unlock() | |
| 181 | 181 | if !normal.Valid() { | |
| @@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory { | |||||
| 142 | return f | 142 | 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.release | 146 | <-f.release | ||
| 147 | return nil, io.ErrClosedPipe | 147 | 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.Size | 156 | 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 = sz | 160 | normal = sz | ||
| 161 | } | 161 | } | ||
| @@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||||
| 174 | var mu sync.Mutex | 174 | var mu sync.Mutex | ||
| 175 | var normal domain.Size | 175 | var normal domain.Size | ||
| 176 | next := 0 | 176 | 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) { | |||
| 169 | 169 | return | |
| 170 | 170 | } | |
| 171 | 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 | 181 | // 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 | 203 | return nil | |
| 198 | 204 | } | |
| 199 | 205 | 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) | |
| 204 | 210 | } | |
| 205 | 211 | return nil | |
| 206 | 212 | } | |
| @@ -218,6 +224,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) { | |||
| 218 | 224 | ||
| 219 | 225 | // floatingLaunchSpec is an immutable snapshot of one launch. It is built | |
| 220 | 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 | 261 | type floatingLaunchSpec struct { | |
| 222 | 262 | sessionName string | |
| 223 | 263 | cwd string | |
| @@ -240,10 +280,18 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi | |||
| 240 | 280 | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err) | |
| 241 | 281 | 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 | 288 | // Count the launch before its goroutine starts. It remains counted through | |
| 244 | 289 | // install, including the reader/coordinator Adds, so shutdown cannot return | |
| 245 | 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 | 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 | 348 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | |
| 301 | 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) { | |
| 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) | |
| 304 | 364 | if err != nil { | |
| 305 | 365 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | |
| 306 | 366 | return | |
| @@ -343,13 +403,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 343 | 403 | d.reapplyThemeSession(sess) | |
| 344 | 404 | d.startPaneGoroutines(sess, tb, p) | |
| 345 | 405 | if visible { | |
| 346 | - | d.resizeActiveFloating(tb) | |
| 347 | 406 | sess.mu.Lock() | |
| 348 | 407 | ac := sess.client | |
| 349 | 408 | 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) | |
| 353 | 413 | } | |
| 354 | 414 | } | |
| 355 | 415 | ||
| @@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||||
| 169 | return | 169 | 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 slot | 181 | // 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 nil | 203 | 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 nil | 211 | 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 built | 225 | // 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 string | 262 | sessionName string | ||
| 223 | cwd string | 263 | 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 | return | 281 | 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 through | 288 | // Count the launch before its goroutine starts. It remains counted through | ||
| 244 | // install, including the reader/coordinator Adds, so shutdown cannot return | 289 | // 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 operation | 348 | // 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 | return | 366 | 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.client | 407 | 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, | |||
| 133 | 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 | - | } | |
| @@ -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 ( | |||
| 14 | 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 | 36 | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 18 | 37 | geometry := floatingGeometry{ | |
| 19 | 38 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| @@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||
| 200 | 219 | p.rect, p.popupGeometry = initial.Inner, initial | |
| 201 | 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 | 223 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | |
| 205 | 224 | require.Equal(t, tt.next.Inner, p.rect) | |
| 206 | 225 | require.Equal(t, tt.next, p.popupGeometry) | |
| @@ -329,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 329 | 348 | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | |
| 330 | 349 | require.Empty(t, pty.sizes()) | |
| 331 | 350 | ||
| 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. | |
| 334 | 353 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 335 | 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 | 356 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 338 | 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 | 360 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 342 | 361 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |
| @@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||
| 362 | 381 | d := newTestDaemon(t, nil, stubClock{}) | |
| 363 | 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 | 385 | tb.mu.Lock() | |
| 367 | 386 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 368 | 387 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | |
| @@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 388 | 407 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |
| 389 | 408 | d := newTestDaemon(t, nil, stubClock{}) | |
| 390 | 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 | 411 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |
| 393 | 412 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | |
| 394 | 413 | require.Equal(t, 5, p.screen.Frame.Width) | |
| @@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 404 | 423 | done1 := make(chan bool, 1) | |
| 405 | 424 | done2 := make(chan bool, 1) | |
| 406 | 425 | secondStarted := make(chan struct{}) | |
| 407 | - | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |
| 426 | + | go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }() | |
| 408 | 427 | <-pty.entered | |
| 409 | 428 | go func() { | |
| 410 | 429 | close(secondStarted) | |
| 411 | - | done2 <- d.resizeFloatingPane(p, second) | |
| 430 | + | done2 <- applyFloatingResizePlanForTest(d, p, second) | |
| 412 | 431 | }() | |
| 413 | 432 | <-secondStarted | |
| 414 | 433 | 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, initial | 219 | 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 the | 351 | + | // 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)) // hide | 360 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 342 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 361 | 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.entered | 427 | <-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 | <-secondStarted | 432 | <-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 | |||
| 36 | 36 | var releaseOnce sync.Once | |
| 37 | 37 | releaseOpen := func() { releaseOnce.Do(func() { close(release) }) } | |
| 38 | 38 | 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) { | |
| 41 | 41 | opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size} | |
| 42 | 42 | <-release | |
| 43 | 43 | return result, openErr | |
| @@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||
| 467 | 467 | t.Run(tt.name, func(t *testing.T) { | |
| 468 | 468 | factory := portsmocks.NewMockPTYFactory(t) | |
| 469 | 469 | 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) { | |
| 472 | 472 | opened <- cwd | |
| 473 | 473 | return nil, io.ErrUnexpectedEOF | |
| 474 | 474 | }).Once() | |
| @@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm | |||||
| 36 | var releaseOnce sync.Once | 36 | 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 | <-release | 42 | <-release | ||
| 43 | return result, openErr | 43 | 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 <- cwd | 472 | opened <- cwd | ||
| 473 | return nil, io.ErrUnexpectedEOF | 473 | 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) { | |||
| 24 | 24 | sess.tabs = []*tab{first, second} | |
| 25 | 25 | ||
| 26 | 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 | 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 30 | 30 | 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) { | |||
| 279 | 279 | {name: "stream window", window: maxUnackedOutputStates}, | |
| 280 | 280 | } { | |
| 281 | 281 | 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))) | |
| 284 | 286 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 285 | 287 | ac.output.maxOutstanding = tc.window | |
| 286 | 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 | 294 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | |
| 289 | 295 | d.paint(sess, ac, true) | |
| 290 | 296 | client := vt.NewScreen(80, 24) | |
| 291 | 297 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 292 | 298 | ac.ackOutputState(initial.NewStateNum) | |
| 293 | 299 | ||
| 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 | |
| 297 | 309 | 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") | |
| 300 | 315 | ||
| 301 | 316 | client.Resize(120, 24) | |
| 302 | 317 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 303 | 318 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | |
| 304 | 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 | } | |
| @@ -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.window | 287 | 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 resize | 300 | + | d.sessWg.Add(1) |
| 295 | - | // output is being prepared. The first visible grown frame must contain | 301 | + | 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) { | |||
| 371 | 371 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 372 | 372 | defer releases[0]() | |
| 373 | 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 | 375 | d.ptys = ptys | |
| 376 | 376 | ||
| 377 | 377 | 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 = ptys | 375 | 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 ( | |||
| 16 | 16 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 17 | 17 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| 18 | 18 | 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 | |
| 28 | 36 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | |
| 29 | 37 | title paneTitleState | |
| 30 | 38 | 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.PaneID | 19 | + | id layout.PaneID |
| 20 | - | stableID string | 20 | + | stableID string |
| 21 | - | pty ports.PTY | 21 | + | pty ports.PTY |
| 22 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | 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 | 23 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu |
| 24 | - | screen *vt.Screen | 24 | + | screen *vt.Screen |
| 25 | - | scrollback *scopy.Scrollback | 25 | + | scrollback *scopy.Scrollback |
| 26 | - | syncGen uint64 | 26 | + | syncGen uint64 |
| 27 | - | rect domain.Rect | 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 | |||
| 28 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | 36 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | ||
| 29 | title paneTitleState | 37 | title paneTitleState | ||
| 30 | ctx context.Context | 38 | ctx context.Context | ||
M internal/usecase/daemon/pane_actions.go +4 -4
| @@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 69 | 69 | tb.mu.Unlock() | |
| 70 | 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 | 73 | if err != nil { | |
| 74 | 74 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 75 | 75 | tb.mu.Lock() | |
| @@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 111 | 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 112 | 112 | if !ok { | |
| 113 | 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 | 116 | return | |
| 117 | 117 | } | |
| @@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 120 | 120 | continue | |
| 121 | 121 | } | |
| 122 | 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 | 129 | if p == nil { | |
| 130 | 130 | return | |
| 131 | 131 | } | |
| @@ -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 | return | 116 | return | ||
| 117 | } | 117 | } | ||
| @@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||||
| 120 | continue | 120 | 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 | return | 130 | return | ||
| 131 | } | 131 | } | ||
M internal/usecase/daemon/pane_actions_test.go +9 -9
| @@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 39 | 39 | newPTY := portsmocks.NewMockPTY(t) | |
| 40 | 40 | oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once() | |
| 41 | 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 | 44 | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | |
| 45 | 45 | ||
| @@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 68 | 68 | newPTY := portsmocks.NewMockPTY(t) | |
| 69 | 69 | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 70 | 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 | 73 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 74 | 74 | ||
| @@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 84 | 84 | ||
| 85 | 85 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |
| 86 | 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 | 89 | tb := sess.activeTab() | |
| 90 | 90 | tb.mu.Lock() | |
| @@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||
| 231 | 231 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | |
| 232 | 232 | newPTY := portsmocks.NewMockPTY(t) | |
| 233 | 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 | 236 | require.NoError(t, d.stackPane(sess, nil)) | |
| 237 | 237 | tb := sess.activeTab() | |
| @@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 251 | 251 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | |
| 252 | 252 | newPTY := portsmocks.NewMockPTY(t) | |
| 253 | 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 | 255 | require.NoError(t, d.stackPane(sess, nil)) | |
| 256 | 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 | 261 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 262 | 262 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | |
| 263 | 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 | 265 | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | |
| 266 | 266 | require.Len(t, tb.panes, 2) | |
| 267 | 267 | } | |
| @@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||
| 291 | 291 | defer releaseNew() | |
| 292 | 292 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 293 | 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 | 296 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 297 | 297 | tb := sess.activeTab() | |
| @@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) { | |||
| 350 | 350 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 351 | 351 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 352 | 352 | 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) { | |
| 355 | 355 | gotEnv = append([]string(nil), env...) | |
| 356 | 356 | return newPTY, nil | |
| 357 | 357 | }, | |
| @@ -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 []string | 352 | 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, nil | 356 | return newPTY, nil | ||
| 357 | }, | 357 | }, | ||
M internal/usecase/daemon/performance_benchmark_test.go +211 -11
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "sync" | |
| 7 | 7 | "testing" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ||
| @@ -92,14 +93,49 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) { | |||
| 92 | 93 | require.Zero(t, emissions, "emit counts only prepare and transport success") | |
| 93 | 94 | } | |
| 94 | 95 | ||
| 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}} | |
| 97 | 99 | ||
| 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) | |
| 100 | 102 | require.Equal(t, want, fixture.ac.size) | |
| 101 | 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 | 141 | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |
| @@ -155,8 +191,29 @@ func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | |||
| 155 | 191 | func BenchmarkDaemonHistoryCopySearch(b *testing.B) { | |
| 156 | 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) { | |
| 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 | + | }) | |
| 160 | 217 | } | |
| 161 | 218 | ||
| 162 | 219 | func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) { | |
| @@ -237,6 +294,16 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||
| 237 | 294 | b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op") | |
| 238 | 295 | b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op") | |
| 239 | 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 | 307 | b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio") | |
| 241 | 308 | b.ReportMetric(float64(historyRows), "historyrows/pane") | |
| 242 | 309 | } | |
| @@ -286,6 +353,7 @@ type performanceConfig struct { | |||
| 286 | 353 | tabs int | |
| 287 | 354 | panes int | |
| 288 | 355 | historyRows int | |
| 356 | + | ptyErrors []error | |
| 289 | 357 | } | |
| 290 | 358 | ||
| 291 | 359 | type performanceMetrics struct { | |
| @@ -300,6 +368,78 @@ type performanceMetrics struct { | |||
| 300 | 368 | renderCaptures uint64 | |
| 301 | 369 | renderCompositions uint64 | |
| 302 | 370 | 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 | 445 | type performanceFixture struct { | |
| @@ -315,6 +455,13 @@ type performanceFixture struct { | |||
| 315 | 455 | resizeSizes [2]domain.Size | |
| 316 | 456 | resizes int | |
| 317 | 457 | 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 | 465 | coordinatorBaseline renderCoordinatorBurstMetricsSnapshot | |
| 319 | 466 | renderCaptures uint64 | |
| 320 | 467 | renderCompositions uint64 | |
| @@ -335,8 +482,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 335 | 482 | if config.historyRows == 0 { | |
| 336 | 483 | config.historyRows = 1 | |
| 337 | 484 | } | |
| 485 | + | pty := &scriptedPerformancePTY{} | |
| 338 | 486 | ptys := make([]ports.PTY, config.tabs) | |
| 487 | + | ptys[0] = pty | |
| 339 | 488 | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | |
| 489 | + | clock := &performanceClock{} | |
| 490 | + | d.clock = clock | |
| 340 | 491 | output := &countingOutputTransport{} | |
| 341 | 492 | ac.tr = output | |
| 342 | 493 | ac.size = config.size | |
| @@ -351,6 +502,8 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 351 | 502 | ac: ac, | |
| 352 | 503 | output: output, | |
| 353 | 504 | snaps: &countingSnapshotStore{}, | |
| 505 | + | pty: pty, | |
| 506 | + | clock: clock, | |
| 354 | 507 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | |
| 355 | 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 | 512 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | |
| 360 | 513 | } | |
| 361 | 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 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. | |
| 366 | 521 | ac.renderStages = renderStageHooks{ | |
| 367 | 522 | capture: func() { fixture.renderCaptures++ }, | |
| 368 | 523 | compose: func() { fixture.renderCompositions++ }, | |
| @@ -457,9 +612,41 @@ func (f *performanceFixture) searchMatches() int { | |||
| 457 | 612 | } | |
| 458 | 613 | ||
| 459 | 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 | 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 | 652 | func (f *performanceFixture) resized() bool { | |
| @@ -483,6 +670,11 @@ func (f *performanceFixture) resetMetrics() { | |||
| 483 | 670 | f.renderCaptures = 0 | |
| 484 | 671 | f.renderCompositions = 0 | |
| 485 | 672 | f.renderEmissions = 0 | |
| 673 | + | f.resizeRequests = 0 | |
| 674 | + | f.resizeCommits = 0 | |
| 675 | + | f.resizeCommitNanos = 0 | |
| 676 | + | f.ptyFailures = 0 | |
| 677 | + | f.ptyRetries = 0 | |
| 486 | 678 | if rc := f.sess.renderCoordinator(); rc != nil { | |
| 487 | 679 | f.coordinatorBaseline = rc.burstMetricsSnapshot() | |
| 488 | 680 | } | |
| @@ -495,6 +687,14 @@ func (f *performanceFixture) metrics() performanceMetrics { | |||
| 495 | 687 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | |
| 496 | 688 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | |
| 497 | 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 | 699 | if rc := f.sess.renderCoordinator(); rc != nil { | |
| 500 | 700 | 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 int | 353 | tabs int | ||
| 287 | panes int | 354 | panes int | ||
| 288 | historyRows int | 355 | 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 uint64 | 368 | renderCaptures uint64 | ||
| 301 | renderCompositions uint64 | 369 | renderCompositions uint64 | ||
| 302 | renderEmissions uint64 | 370 | 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.Size | 455 | resizeSizes [2]domain.Size | ||
| 316 | resizes int | 456 | resizes int | ||
| 317 | resizedSize domain.Size | 457 | 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 renderCoordinatorBurstMetricsSnapshot | 465 | coordinatorBaseline renderCoordinatorBurstMetricsSnapshot | ||
| 319 | renderCaptures uint64 | 466 | renderCaptures uint64 | ||
| 320 | renderCompositions uint64 | 467 | 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 = 1 | 483 | 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 = output | 492 | ac.tr = output | ||
| 342 | ac.size = config.size | 493 | 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 is | 519 | + | // The fixture uses the production coordinator. performanceClock's nil timer |
| 364 | - | // completed synchronously by the coordinator, keeping benchmark operations | 520 | + | // 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 = 0 | 670 | f.renderCaptures = 0 | ||
| 484 | f.renderCompositions = 0 | 671 | f.renderCompositions = 0 | ||
| 485 | f.renderEmissions = 0 | 672 | 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 | |||
| 410 | 410 | d.touchMRU(targetSess) | |
| 411 | 411 | ac.recordPreviousSession(from) | |
| 412 | 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 | 413 | return old | |
| 420 | 414 | } | |
| 421 | 415 | ||
| @@ -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 old | 413 | return old | ||
| 420 | } | 414 | } | ||
| 421 | 415 | ||||
M internal/usecase/daemon/picker_test.go +10 -14
| @@ -258,7 +258,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 258 | 258 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 259 | 259 | } | |
| 260 | 260 | ||
| 261 | - | func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) { | |
| 261 | + | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |
| 262 | 262 | p1, releasePTY1 := newBlockingPTY(t) | |
| 263 | 263 | p2, releasePTY2 := newBlockingPTY(t) | |
| 264 | 264 | defer releasePTY1() | |
| @@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) { | |||
| 281 | 281 | ||
| 282 | 282 | d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24}) | |
| 283 | 283 | 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 | |
| 288 | 285 | ||
| 289 | 286 | 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) | |
| 294 | 290 | timer.ch <- time.Time{} | |
| 295 | 291 | } | |
| 296 | 292 | ||
| @@ -403,8 +399,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 403 | 399 | defer releasePTY3() | |
| 404 | 400 | var openedEnv []string | |
| 405 | 401 | 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) { | |
| 408 | 404 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | |
| 409 | 405 | return newQuietPTY(), nil | |
| 410 | 406 | } | |
| @@ -518,8 +514,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 518 | 514 | var opens [][]string | |
| 519 | 515 | f := portsmocks.NewMockPTYFactory(t) | |
| 520 | 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( | |
| 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) { | |
| 523 | 519 | opens = append(opens, append([]string(nil), env...)) | |
| 524 | 520 | if len(opens) == 1 { | |
| 525 | 521 | return p1, nil | |
| @@ -528,7 +524,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 528 | 524 | }, | |
| 529 | 525 | ).Twice() | |
| 530 | 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 | 528 | return got != normalSize && got.Valid() | |
| 533 | 529 | })).Return(floating, nil).Once() | |
| 534 | 530 | 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.timers | 283 | 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 []string | 400 | 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(), nil | 405 | return newQuietPTY(), nil | ||
| 410 | } | 406 | } | ||
| @@ -518,8 +514,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 518 | var opens [][]string | 514 | 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, nil | 521 | 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 ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/internal/ports" | |
| 9 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -19,13 +18,6 @@ func completedSynchronizedUpdate(data []byte) bool { | |||
| 19 | 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 | 21 | // paneRenderable reports dynamic composition visibility. Callers must not | |
| 30 | 22 | // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks. | |
| 31 | 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 | 34 | if tb.floating.pane == p { | |
| 43 | 35 | return active && attached && tb.floating.state == floatingVisible | |
| 44 | 36 | } | |
| 45 | - | if !((active && attached) || preview) { | |
| 37 | + | if (!active || !attached) && !preview { | |
| 46 | 38 | return false | |
| 47 | 39 | } | |
| 48 | 40 | placements, ok := solvedPlacementsLocked(tb) | |
| @@ -93,97 +85,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 93 | 85 | return | |
| 94 | 86 | } | |
| 95 | 87 | buf := make([]byte, ptyReadBufSize) | |
| 96 | - | var resp []byte | |
| 97 | - | var clipboards []string | |
| 98 | - | attentionCh := make(chan struct{}, 1) | |
| 99 | 88 | 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) } | |
| 105 | 94 | p.mu.Unlock() | |
| 106 | 95 | for { | |
| 107 | 96 | n, err := p.pty.Read(buf) | |
| 108 | 97 | 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) | |
| 187 | 99 | } | |
| 188 | 100 | if err != nil { | |
| 189 | 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) 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() | |
| 219 | 120 | return | |
| 220 | 121 | } | |
| 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 | + | } | |
| 229 | 167 | } | |
| 230 | 168 | ||
| 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 | + | } | |
| 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 | 201 | tb := sess.activeTab() | |
| 239 | 202 | if tb == nil { | |
| 240 | 203 | return | |
| @@ -258,19 +221,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 258 | 221 | ac.sendMu.Unlock() | |
| 259 | 222 | return | |
| 260 | 223 | } | |
| 261 | - | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | |
| 262 | - | ac.sendMu.Unlock() | |
| 263 | - | return | |
| 264 | - | } | |
| 265 | 224 | // Composition owns attachment sendMu; initialize its lazy overlay state | |
| 266 | 225 | // under that same ownership so concurrent fallback paints cannot observe a | |
| 267 | 226 | // partially published runtime. | |
| 268 | 227 | ac.initOverlays() | |
| 269 | - | if ac.resizePaintPending { | |
| 270 | - | ac.resizePaint.stop() | |
| 271 | - | ac.resizePaintPending = false | |
| 272 | - | reset = true | |
| 273 | - | } | |
| 274 | 228 | overlays := ac.overlays.SnapshotForRender() | |
| 275 | 229 | repaintAttachedClients := false | |
| 276 | 230 | 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 not | 21 | // 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 == floatingVisible | 35 | return active && attached && tb.floating.state == floatingVisible | ||
| 44 | } | 36 | } | ||
| 45 | - | if !((active && attached) || preview) { | 37 | + | if (!active || !attached) && !preview { |
| 46 | return false | 38 | 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 | return | 85 | 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) and | 111 | + | // 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 invalidations | 112 | + | // 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.resizePaintGeneration | 117 | + | if bufferDuringApply && p.resizeApplying { |
| 206 | - | ac.resizePaintPending = true | 118 | + | 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 | return | 120 | return | ||
| 220 | } | 121 | } | ||
| 221 | - | ac.resizePaint.stop() | 122 | + | wasSyncing := p.screen.SyncUpdateActive() |
| 222 | - | ac.resizePaintPending = false | 123 | + | 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 captured | 169 | + | // 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 | return | 203 | return | ||
| @@ -258,19 +221,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 258 | ac.sendMu.Unlock() | 221 | ac.sendMu.Unlock() | ||
| 259 | return | 222 | 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 state | 224 | // Composition owns attachment sendMu; initialize its lazy overlay state | ||
| 266 | // under that same ownership so concurrent fallback paints cannot observe a | 225 | // 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 := false | 229 | repaintAttachedClients := false | ||
| 276 | defer func() { | 230 | defer func() { | ||
M internal/usecase/daemon/render_coordinator.go +250 -14
| @@ -67,12 +67,13 @@ type renderWake struct { | |||
| 67 | 67 | ||
| 68 | 68 | // resizeRequestMetadata is the coordinator-owned latest requested resize | |
| 69 | 69 | // 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. | |
| 72 | 72 | 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 | |
| 76 | 77 | } | |
| 77 | 78 | ||
| 78 | 79 | // renderCoordinatorOptions wires one coordinator instance. | |
| @@ -158,8 +159,23 @@ type renderCoordinator struct { | |||
| 158 | 159 | detached bool | |
| 159 | 160 | torndown bool | |
| 160 | 161 | ||
| 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 | |
| 163 | 179 | // generation invalidates callbacks from superseded deadline/watchdog timers. | |
| 164 | 180 | // Each worker also owns an explicit cancellation channel: fake timers may | |
| 165 | 181 | // 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 | 625 | // attachment. | |
| 610 | 626 | func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |
| 611 | 627 | c.mu.Lock() | |
| 612 | - | var timer ports.Timer | |
| 628 | + | var timer, resizeTimer, retryTimer ports.Timer | |
| 613 | 629 | if c.attachment == ac { | |
| 614 | 630 | c.advanceAttachmentEpochLocked(ac) | |
| 615 | 631 | c.attachment = nil | |
| @@ -624,9 +640,22 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 624 | 640 | c.generation++ | |
| 625 | 641 | c.armed = false | |
| 626 | 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 | 655 | c.mu.Unlock() | |
| 629 | 656 | stopTimer(timer) | |
| 657 | + | stopTimer(resizeTimer) | |
| 658 | + | stopTimer(retryTimer) | |
| 630 | 659 | } | |
| 631 | 660 | ||
| 632 | 661 | // noteReplace hands the coordinator from old to replacement; callbacks | |
| @@ -641,7 +670,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||
| 641 | 670 | // which predated coordinator ownership. In that case nil has no pending | |
| 642 | 671 | // identity to invalidate, and the replacement becomes the first bound | |
| 643 | 672 | // attachment atomically with this lifecycle transition. | |
| 644 | - | var timer ports.Timer | |
| 673 | + | var timer, resizeTimer, retryTimer ports.Timer | |
| 645 | 674 | if c.attachment == old || c.attachment == nil { | |
| 646 | 675 | c.advanceAttachmentEpochLocked(old, replacement) | |
| 647 | 676 | c.attachment = replacement | |
| @@ -659,9 +688,22 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||
| 659 | 688 | c.generation++ | |
| 660 | 689 | c.armed = false | |
| 661 | 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 | 703 | c.mu.Unlock() | |
| 664 | 704 | stopTimer(timer) | |
| 705 | + | stopTimer(resizeTimer) | |
| 706 | + | stopTimer(retryTimer) | |
| 665 | 707 | } | |
| 666 | 708 | ||
| 667 | 709 | // notePark invalidates pending wakes when the attachment parks for resume. | |
| @@ -677,6 +719,19 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 677 | 719 | c.previewWake = nil | |
| 678 | 720 | c.previewWakes = nil | |
| 679 | 721 | 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 | 735 | c.ackDeferred = false | |
| 681 | 736 | c.pendingPreview = false | |
| 682 | 737 | c.generation++ | |
| @@ -685,22 +740,203 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 685 | 740 | timers := c.detachSyncTimersLocked() | |
| 686 | 741 | c.mu.Unlock() | |
| 687 | 742 | stopTimer(timer) | |
| 743 | + | stopTimer(resizeTimer) | |
| 744 | + | stopTimer(retryTimer) | |
| 688 | 745 | stopTimers(timers) | |
| 689 | 746 | } | |
| 690 | 747 | ||
| 691 | 748 | // 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 | |
| 693 | 750 | // the strictly monotonically increased epoch, or 0 when the source is stale. | |
| 694 | 751 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | |
| 695 | 752 | c.mu.Lock() | |
| 696 | - | defer c.mu.Unlock() | |
| 697 | 753 | if c.torndown || c.attachment != source { | |
| 754 | + | c.mu.Unlock() | |
| 698 | 755 | 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 | 766 | c.resize.epoch++ | |
| 701 | 767 | c.resize.size = size | |
| 702 | 768 | 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 | |
| 704 | 940 | } | |
| 705 | 941 | ||
| 706 | 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 | 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 | 998 | if ac != nil { | |
| 763 | - | d.paint(sess, ac, reset) | |
| 999 | + | d.paint(sess, ac, reset, 0) | |
| 764 | 1000 | } | |
| 765 | 1001 | } | |
| 766 | 1002 | ||
| @@ -67,12 +67,13 @@ type renderWake struct { | |||||
| 67 | 67 | ||||
| 68 | // resizeRequestMetadata is the coordinator-owned latest requested resize | 68 | // resizeRequestMetadata is the coordinator-owned latest requested resize | ||
| 69 | // state: S3's transaction entry point reads it through resizeSnapshot. It is | 69 | // 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 | 70 | + | // 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.Size | 73 | + | size domain.Size |
| 74 | - | source *attachedClient | 74 | + | source *attachedClient |
| 75 | - | epoch uint64 | 75 | + | 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 bool | 159 | detached bool | ||
| 159 | torndown bool | 160 | torndown bool | ||
| 160 | 161 | ||||
| 161 | - | resize resizeRequestMetadata | 162 | + | resize resizeRequestMetadata |
| 162 | - | metrics renderCoordinatorBurstMetrics | 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 | |||
| 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 may | 180 | // 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 a | 181 | // 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.Timer | 628 | + | 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 = nil | 631 | c.attachment = nil | ||
| @@ -624,9 +640,22 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||||
| 624 | c.generation++ | 640 | c.generation++ | ||
| 625 | c.armed = false | 641 | 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; callbacks | 661 | // 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 pending | 670 | // which predated coordinator ownership. In that case nil has no pending | ||
| 642 | // identity to invalidate, and the replacement becomes the first bound | 671 | // 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.Timer | 673 | + | 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 = replacement | 676 | c.attachment = replacement | ||
| @@ -659,9 +688,22 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||||
| 659 | c.generation++ | 688 | c.generation++ | ||
| 660 | c.armed = false | 689 | 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 = nil | 719 | c.previewWake = nil | ||
| 678 | c.previewWakes = nil | 720 | c.previewWakes = nil | ||
| 679 | c.pending = false | 721 | 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 = false | 735 | c.ackDeferred = false | ||
| 681 | c.pendingPreview = false | 736 | 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 before | 748 | // 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 |
| 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 0 | 755 | 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 = size | 767 | c.resize.size = size | ||
| 702 | c.resize.source = source | 768 | 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 | |||
| 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 | |||
| 16 | 16 | rc.attach(old) | |
| 17 | 17 | rc.noteReplace(old, replacement) | |
| 18 | 18 | ||
| 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 | |
| 20 | 20 | // check immediately before a replacement was published. | |
| 21 | 21 | rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"}) | |
| 22 | 22 | 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 generation | 19 | + | // 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) { | |||
| 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 | 210 | func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer { | |
| 202 | 211 | t.Helper() | |
| 203 | 212 | select { | |
| @@ -989,13 +998,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||
| 989 | 998 | require.Same(t, owner, snap.source) | |
| 990 | 999 | require.Equal(t, uint64(3), snap.epoch) | |
| 991 | 1000 | ||
| 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) | |
| 999 | 1003 | }, | |
| 1000 | 1004 | }, | |
| 1001 | 1005 | { | |
| @@ -1028,7 +1032,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||
| 1028 | 1032 | h.rc.attach(staleOwner) | |
| 1029 | 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 old | |
| 1035 | + | // This models a stale callback which captured the old | |
| 1032 | 1036 | // attachment before lifecycle ownership changed. | |
| 1033 | 1037 | staleCallback := func() uint64 { | |
| 1034 | 1038 | return h.rc.recordResizeRequest(sz(80, 20), staleOwner) | |
| @@ -1169,9 +1173,13 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1169 | 1173 | name: "retained resize dispatch", | |
| 1170 | 1174 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1171 | 1175 | 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 | |
| 1173 | 1180 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26}) | |
| 1174 | 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 | 1250 | func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) { | |
| 1243 | 1251 | p, release := newBlockingPTY(t) | |
| @@ -1324,6 +1332,29 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal | |||
| 1324 | 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 | 1358 | func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) { | |
| 1328 | 1359 | clock := portsmocks.NewMockClock(t) | |
| 1329 | 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 | 1487 | newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) { | |
| 1457 | 1488 | t.Helper() | |
| 1458 | 1489 | p, releasePTY := newBlockingPTY(t) | |
| @@ -1478,12 +1509,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||
| 1478 | 1509 | timer := awaitCoordinatorScheduledTimer(t, clk) | |
| 1479 | 1510 | require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline) | |
| 1480 | 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 | 1512 | snap := sess.renderCoordinator().resizeSnapshot() | |
| 1488 | 1513 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size, | |
| 1489 | 1514 | "the coordinator must record the latest requested resize before delegating") | |
| @@ -1492,6 +1517,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||
| 1492 | 1517 | requireNoCoordinatorOutputFrame(t, sends) | |
| 1493 | 1518 | ||
| 1494 | 1519 | timer.ch <- time.Time{} | |
| 1520 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 1495 | 1521 | inv := awaitInvalidation(t, invs) | |
| 1496 | 1522 | require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation") | |
| 1497 | 1523 | requireNoInvalidation(t, invs) | |
| @@ -1513,6 +1539,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||
| 1513 | 1539 | requireNoCoordinatorOutputFrame(t, sends) | |
| 1514 | 1540 | ||
| 1515 | 1541 | latest.ch <- time.Time{} | |
| 1542 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 1516 | 1543 | inv := awaitInvalidation(t, invs) | |
| 1517 | 1544 | require.True(t, inv.reset) | |
| 1518 | 1545 | requireNoInvalidation(t, invs) | |
| @@ -1525,7 +1552,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||
| 1525 | 1552 | ||
| 1526 | 1553 | d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30}) | |
| 1527 | 1554 | timer := awaitCoordinatorScheduledTimer(t, clk) | |
| 1528 | - | ac.cancelResizePaint() | |
| 1555 | + | sess.renderCoordinator().noteDetach(ac) | |
| 1529 | 1556 | ||
| 1530 | 1557 | timer.ch <- time.Time{} | |
| 1531 | 1558 | 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 #71 | 1001 | + | // 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 old | 1035 | + | // 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.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 | |||
| 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 { | |||
| 48 | 48 | if !d.prepareParkAttachment(sess, ac) { | |
| 49 | 49 | 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 | 51 | d.mu.Lock() | |
| 56 | 52 | if d.closing { | |
| 57 | 53 | 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 false | 49 | 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 { | |||
| 53 | 53 | // clipboard.go) written for this session, removed best-effort in | |
| 54 | 54 | // killSession. | |
| 55 | 55 | 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 | 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 | 131 | closeTabs(tabs) | |
| 125 | 132 | 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 | 135 | if err != nil { | |
| 129 | 136 | closeTabs(tabs) | |
| 130 | 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 | 272 | if err != nil { | |
| 266 | 273 | 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 | 276 | if err != nil { | |
| 270 | 277 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 271 | 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 | 731 | rc.noteSessionTeardown() | |
| 725 | 732 | } | |
| 726 | 733 | if ac != nil { | |
| 727 | - | ac.cancelResizePaint() | |
| 728 | 734 | d.unregisterPreview(ac) | |
| 729 | 735 | ac.clearPreviousSession() | |
| 730 | 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 | 743 | sess.cancel() | |
| 734 | 744 | sess.mu.Lock() | |
| 735 | 745 | tabs := append([]*tab(nil), sess.tabs...) | |
| @@ -53,6 +53,13 @@ type session struct { | |||||
| 53 | // clipboard.go) written for this session, removed best-effort in | 53 | // clipboard.go) written for this session, removed best-effort in | ||
| 54 | // killSession. | 54 | // killSession. | ||
| 55 | clipFiles []string | 55 | 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, err | 132 | 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 err | 273 | 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 ( | |||
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 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 | 30 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 26 | 31 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| @@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 34 | 39 | f := portsmocks.NewMockPTYFactory(t) | |
| 35 | 40 | var dirs []string | |
| 36 | 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( | |
| 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) { | |
| 39 | 44 | dirs = append(dirs, dir) | |
| 40 | 45 | if len(dirs) == 1 { | |
| 41 | 46 | return first, nil | |
| @@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 44 | 49 | }, | |
| 45 | 50 | ).Twice() | |
| 46 | 51 | 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) | |
| 50 | 53 | ||
| 51 | 54 | d := newTestDaemon(t, f, stubClock{}) | |
| 52 | 55 | tr := portsmocks.NewMockTransport(t) | |
| @@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 72 | 75 | releaseFirst() | |
| 73 | 76 | releaseSecond() | |
| 74 | 77 | 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. | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | func TestHandshakeEphemeralHappy(t *testing.T) { | |
| @@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 202 | 202 | p1, release1 := newBlockingPTY(t) | |
| 203 | 203 | defer release1() | |
| 204 | 204 | p2 := portsmocks.NewMockPTY(t) | |
| 205 | - | opened := make(chan struct{}) | |
| 205 | + | openCtx := make(chan context.Context, 1) | |
| 206 | 206 | releaseOpen := make(chan struct{}) | |
| 207 | 207 | closed := make(chan struct{}) | |
| 208 | 208 | p2.EXPECT().Close().RunAndReturn(func() error { | |
| @@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 213 | 213 | ||
| 214 | 214 | f := portsmocks.NewMockPTYFactory(t) | |
| 215 | 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 | 217 | 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 | + | } | |
| 225 | 226 | return p2, nil | |
| 226 | 227 | }, | |
| 227 | 228 | ).Once() | |
| @@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 237 | 238 | ||
| 238 | 239 | errCh := make(chan error, 1) | |
| 239 | 240 | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | |
| 240 | - | <-opened | |
| 241 | + | ctx := <-openCtx | |
| 241 | 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 | 250 | close(releaseOpen) | |
| 243 | 251 | ||
| 252 | + | if !cancelled { | |
| 253 | + | t.Error("killSession did not cancel the context passed to PTYFactory.Open") | |
| 254 | + | } | |
| 244 | 255 | require.Error(t, <-errCh) | |
| 245 | 256 | select { | |
| 246 | 257 | case <-closed: | |
| @@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 251 | 262 | releaseConn() | |
| 252 | 263 | hg.Wait() | |
| 253 | 264 | 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. | |
| 259 | 266 | } | |
| 260 | 267 | ||
| 261 | 268 | // --- ephemeral numbering ---------------------------------------------------- | |
| @@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) { | |||
| 508 | 515 | // --- shutdown/create interlock ------------------------------------------------ | |
| 509 | 516 | ||
| 510 | 517 | // 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. | |
| 517 | 525 | func TestHelloRacingShutdownIsRejected(t *testing.T) { | |
| 518 | 526 | p, releasePTY := newBlockingPTY(t) | |
| 527 | + | floating := newQuietPTY() | |
| 519 | 528 | 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 | |
| 523 | 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 | 551 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | |
| 526 | 552 | var hg sync.WaitGroup | |
| 527 | 553 | hg.Go(func() { d.handleConn(tr1) }) | |
| 528 | 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 | 564 | sess := firstSession(d) | |
| 530 | 565 | require.NotNil(t, sess) | |
| 531 | 566 | ||
| @@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) { | |||
| 555 | 590 | require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name) | |
| 556 | 591 | } | |
| 557 | 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 | 595 | release1() | |
| 560 | 596 | releasePTY() | |
| 561 | 597 | hg.Wait() | |
| 562 | 598 | d.sessWg.Wait() | |
| 563 | 599 | d.waitNotifies() | |
| 600 | + | require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown") | |
| 564 | 601 | } | |
| 565 | 602 | ||
| 566 | 603 | // --- wedged-client teardown --------------------------------------------------- | |
| @@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||
| 1090 | 1127 | ||
| 1091 | 1128 | var gotEnv []string | |
| 1092 | 1129 | 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) { | |
| 1095 | 1132 | gotEnv = append([]string(nil), env...) | |
| 1096 | 1133 | return p, nil | |
| 1097 | 1134 | }, | |
| @@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1243 | 1280 | var opens [][]string | |
| 1244 | 1281 | f := portsmocks.NewMockPTYFactory(t) | |
| 1245 | 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( | |
| 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) { | |
| 1248 | 1285 | opens = append(opens, append([]string(nil), env...)) | |
| 1249 | 1286 | if len(opens) == 1 { | |
| 1250 | 1287 | return p1, nil | |
| @@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1253 | 1290 | }, | |
| 1254 | 1291 | ).Twice() | |
| 1255 | 1292 | 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) | |
| 1259 | 1294 | ||
| 1260 | 1295 | d := newTestDaemon(t, f, stubClock{}) | |
| 1261 | 1296 | d.baseEnv = []string{"KEEP=1", "COLORTERM=old"} | |
| @@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1288 | 1323 | var opens [][]string | |
| 1289 | 1324 | f := portsmocks.NewMockPTYFactory(t) | |
| 1290 | 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( | |
| 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) { | |
| 1293 | 1328 | opens = append(opens, append([]string(nil), env...)) | |
| 1294 | 1329 | if len(opens) == 1 { | |
| 1295 | 1330 | return p1, nil | |
| @@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1298 | 1333 | }, | |
| 1299 | 1334 | ).Twice() | |
| 1300 | 1335 | 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) | |
| 1304 | 1337 | ||
| 1305 | 1338 | d := newTestDaemon(t, f, stubClock{}) | |
| 1306 | 1339 | tr1 := portsmocks.NewMockTransport(t) | |
| @@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1335 | 1368 | var opens [][]string | |
| 1336 | 1369 | f := portsmocks.NewMockPTYFactory(t) | |
| 1337 | 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( | |
| 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) { | |
| 1340 | 1373 | opens = append(opens, append([]string(nil), env...)) | |
| 1341 | 1374 | if len(opens) == 1 { | |
| 1342 | 1375 | return p1, nil | |
| @@ -1345,9 +1378,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1345 | 1378 | }, | |
| 1346 | 1379 | ).Twice() | |
| 1347 | 1380 | 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) | |
| 1351 | 1382 | d := newTestDaemon(t, f, stubClock{}) | |
| 1352 | 1383 | tr := portsmocks.NewMockTransport(t) | |
| 1353 | 1384 | 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 it | 30 | // 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 by | 31 | // 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 []string | 40 | 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, nil | 46 | 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 | - | <-releaseOpen | 224 | + | case <-releaseOpen: |
| 225 | + | } | |||
| 225 | return p2, nil | 226 | 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 | - | <-opened | 241 | + | 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 the | 517 | // TestHelloRacingShutdownIsRejected covers the interlock between the | ||
| 511 | - | // registry-empty shutdown decision and session creation: once killSession has | 518 | + | // registry-empty shutdown decision and session creation. In particular, the |
| 512 | - | // removed the last session (making shutdown irreversible via doneOnce), a | 519 | + | // old `.Once()` factory expectation was wrong: firstPaint legitimately queues |
| 513 | - | // Hello landing at any later instant must be rejected cleanly — never spawn a | 520 | + | // 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, which | 522 | + | // 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 leaked | 529 | + | 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.WaitGroup | 552 | 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 []string | 1128 | 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, nil | 1133 | return p, nil | ||
| 1097 | }, | 1134 | }, | ||
| @@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1243 | var opens [][]string | 1280 | 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, nil | 1287 | 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 [][]string | 1323 | 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, nil | 1330 | 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 [][]string | 1368 | 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, nil | 1375 | 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 | |||
| 141 | 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 | 145 | if err != nil { | |
| 146 | 146 | closeOpened() | |
| 147 | 147 | 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 err | 147 | return err | ||
M internal/usecase/daemon/snapshot_restore_test.go +16 -2
| @@ -251,6 +251,19 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |||
| 251 | 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 | 267 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| 255 | 268 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 256 | 269 | Name: "stacked", | |
| @@ -438,18 +451,19 @@ type restorePTYFactory struct { | |||
| 438 | 451 | } | |
| 439 | 452 | ||
| 440 | 453 | type restorePTYOpen struct { | |
| 454 | + | ctx context.Context | |
| 441 | 455 | dir string | |
| 442 | 456 | size domain.Size | |
| 443 | 457 | env []string | |
| 444 | 458 | 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 | 462 | f.mu.Lock() | |
| 449 | 463 | defer f.mu.Unlock() | |
| 450 | 464 | pty := newRestorePTY() | |
| 451 | 465 | 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 | 467 | return pty, nil | |
| 454 | 468 | } | |
| 455 | 469 | ||
| @@ -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 string | 455 | dir string | ||
| 442 | size domain.Size | 456 | size domain.Size | ||
| 443 | env []string | 457 | env []string | ||
| 444 | pty *restorePTY | 458 | 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.writeErr | 465 | 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, nil | 467 | 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) { | |||
| 385 | 385 | newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 386 | 386 | newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 387 | 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 | 390 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 391 | 391 | 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) { | |||
| 1332 | 1332 | }, 2*time.Second, time.Millisecond) | |
| 1333 | 1333 | ||
| 1334 | 1334 | // 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 | |
| 1336 | 1336 | // until that coalesced output is observable. | |
| 1337 | 1337 | var resized ports.Frame | |
| 1338 | 1338 | 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 one | 1334 | // 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 |
| 1336 | // until that coalesced output is observable. | 1336 | // until that coalesced output is observable. | ||
| 1337 | var resized ports.Frame | 1337 | 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 | + | } | |||