fix(runtime): harden lifecycle and validation boundaries
- commit
- aaad00607b176ce11f8316e310ee6420d251482f
- author
- brice · · 2026-07-13 21:20 UTC
- parent
- b8c07f6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
46 files · +1398 -453
M cmd/vev-perf-harness/launcher.go +20 -14
| @@ -281,26 +281,38 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||
| 281 | 281 | runtimeDir := l.runtimes[runDir] | |
| 282 | 282 | l.mu.Unlock() | |
| 283 | 283 | peerOwnsRuntime := runtimeDir == "" | |
| 284 | + | fail := func(err error) error { | |
| 285 | + | if peerOwnsRuntime { | |
| 286 | + | return errors.Join(err, l.releaseRuntime(runDir)) | |
| 287 | + | } | |
| 288 | + | return err | |
| 289 | + | } | |
| 284 | 290 | if peerOwnsRuntime { | |
| 285 | 291 | var err error | |
| 286 | 292 | runtimeDir, err = l.runtimeDir(runDir) | |
| 287 | 293 | if err != nil { | |
| 288 | - | return nil, err | |
| 294 | + | return nil, fail(err) | |
| 289 | 295 | } | |
| 290 | 296 | } | |
| 291 | 297 | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 292 | - | return nil, err | |
| 298 | + | return nil, fail(err) | |
| 293 | 299 | } | |
| 294 | 300 | if err := safedir.EnsurePrivate(runtimeDir); err != nil { | |
| 295 | - | return nil, err | |
| 301 | + | return nil, fail(err) | |
| 296 | 302 | } | |
| 297 | 303 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 298 | - | return nil, err | |
| 304 | + | return nil, fail(err) | |
| 299 | 305 | } | |
| 300 | 306 | if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") { | |
| 301 | - | return nil, fmt.Errorf("unsupported public peer command %q", role.Args) | |
| 307 | + | return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args)) | |
| 302 | 308 | } | |
| 303 | 309 | route := peerRoute{mapping: m, command: role} | |
| 310 | + | closeNetem := func(err error) error { | |
| 311 | + | if route.netem != nil { | |
| 312 | + | err = errors.Join(err, route.netem.Close()) | |
| 313 | + | } | |
| 314 | + | return fail(err) | |
| 315 | + | } | |
| 304 | 316 | if m.Role == "udp_peer" { | |
| 305 | 317 | route.pidPath = filepath.Join(runDir, "udp-peer.pid") | |
| 306 | 318 | factory := l.netemFactory | |
| @@ -313,7 +325,7 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||
| 313 | 325 | TargetPath: filepath.Join(runDir, "udp-peer.target"), | |
| 314 | 326 | }) | |
| 315 | 327 | if err != nil { | |
| 316 | - | return nil, fmt.Errorf("start harness UDP netem: %w", err) | |
| 328 | + | return nil, fail(fmt.Errorf("start harness UDP netem: %w", err)) | |
| 317 | 329 | } | |
| 318 | 330 | route.netem = netem | |
| 319 | 331 | } | |
| @@ -354,10 +366,7 @@ case "$*" in | |||
| 354 | 366 | esac | |
| 355 | 367 | `, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run) | |
| 356 | 368 | if err := os.WriteFile(shim, []byte(body), 0o700); err != nil { | |
| 357 | - | if route.netem != nil { | |
| 358 | - | _ = route.netem.Close() | |
| 359 | - | } | |
| 360 | - | return nil, err | |
| 369 | + | return nil, closeNetem(err) | |
| 361 | 370 | } | |
| 362 | 371 | l.mu.Lock() | |
| 363 | 372 | if l.peers == nil { | |
| @@ -365,10 +374,7 @@ esac | |||
| 365 | 374 | } | |
| 366 | 375 | if _, exists := l.peers[runDir]; exists { | |
| 367 | 376 | l.mu.Unlock() | |
| 368 | - | if route.netem != nil { | |
| 369 | - | _ = route.netem.Close() | |
| 370 | - | } | |
| 371 | - | return nil, fmt.Errorf("duplicate transport peer for %s", runDir) | |
| 377 | + | return nil, closeNetem(fmt.Errorf("duplicate transport peer for %s", runDir)) | |
| 372 | 378 | } | |
| 373 | 379 | l.peers[runDir] = route | |
| 374 | 380 | l.mu.Unlock() | |
| @@ -281,26 +281,38 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||||
| 281 | runtimeDir := l.runtimes[runDir] | 281 | runtimeDir := l.runtimes[runDir] | ||
| 282 | l.mu.Unlock() | 282 | l.mu.Unlock() | ||
| 283 | peerOwnsRuntime := runtimeDir == "" | 283 | peerOwnsRuntime := runtimeDir == "" | ||
| 284 | + | fail := func(err error) error { | |||
| 285 | + | if peerOwnsRuntime { | |||
| 286 | + | return errors.Join(err, l.releaseRuntime(runDir)) | |||
| 287 | + | } | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 284 | if peerOwnsRuntime { | 290 | if peerOwnsRuntime { | ||
| 285 | var err error | 291 | var err error | ||
| 286 | runtimeDir, err = l.runtimeDir(runDir) | 292 | runtimeDir, err = l.runtimeDir(runDir) | ||
| 287 | if err != nil { | 293 | if err != nil { | ||
| 288 | - | return nil, err | 294 | + | return nil, fail(err) |
| 289 | } | 295 | } | ||
| 290 | } | 296 | } | ||
| 291 | if err := safedir.EnsurePrivate(runDir); err != nil { | 297 | if err := safedir.EnsurePrivate(runDir); err != nil { | ||
| 292 | - | return nil, err | 298 | + | return nil, fail(err) |
| 293 | } | 299 | } | ||
| 294 | if err := safedir.EnsurePrivate(runtimeDir); err != nil { | 300 | if err := safedir.EnsurePrivate(runtimeDir); err != nil { | ||
| 295 | - | return nil, err | 301 | + | return nil, fail(err) |
| 296 | } | 302 | } | ||
| 297 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | 303 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | ||
| 298 | - | return nil, err | 304 | + | return nil, fail(err) |
| 299 | } | 305 | } | ||
| 300 | if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") { | 306 | if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") { | ||
| 301 | - | return nil, fmt.Errorf("unsupported public peer command %q", role.Args) | 307 | + | return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args)) |
| 302 | } | 308 | } | ||
| 303 | route := peerRoute{mapping: m, command: role} | 309 | route := peerRoute{mapping: m, command: role} | ||
| 310 | + | closeNetem := func(err error) error { | |||
| 311 | + | if route.netem != nil { | |||
| 312 | + | err = errors.Join(err, route.netem.Close()) | |||
| 313 | + | } | |||
| 314 | + | return fail(err) | |||
| 315 | + | } | |||
| 304 | if m.Role == "udp_peer" { | 316 | if m.Role == "udp_peer" { | ||
| 305 | route.pidPath = filepath.Join(runDir, "udp-peer.pid") | 317 | route.pidPath = filepath.Join(runDir, "udp-peer.pid") | ||
| 306 | factory := l.netemFactory | 318 | factory := l.netemFactory | ||
| @@ -313,7 +325,7 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||||
| 313 | TargetPath: filepath.Join(runDir, "udp-peer.target"), | 325 | TargetPath: filepath.Join(runDir, "udp-peer.target"), | ||
| 314 | }) | 326 | }) | ||
| 315 | if err != nil { | 327 | if err != nil { | ||
| 316 | - | return nil, fmt.Errorf("start harness UDP netem: %w", err) | 328 | + | return nil, fail(fmt.Errorf("start harness UDP netem: %w", err)) |
| 317 | } | 329 | } | ||
| 318 | route.netem = netem | 330 | route.netem = netem | ||
| 319 | } | 331 | } | ||
| @@ -354,10 +366,7 @@ case "$*" in | |||||
| 354 | esac | 366 | esac | ||
| 355 | `, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run) | 367 | `, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run) | ||
| 356 | if err := os.WriteFile(shim, []byte(body), 0o700); err != nil { | 368 | if err := os.WriteFile(shim, []byte(body), 0o700); err != nil { | ||
| 357 | - | if route.netem != nil { | 369 | + | return nil, closeNetem(err) |
| 358 | - | _ = route.netem.Close() | |||
| 359 | - | } | |||
| 360 | - | return nil, err | |||
| 361 | } | 370 | } | ||
| 362 | l.mu.Lock() | 371 | l.mu.Lock() | ||
| 363 | if l.peers == nil { | 372 | if l.peers == nil { | ||
| @@ -365,10 +374,7 @@ esac | |||||
| 365 | } | 374 | } | ||
| 366 | if _, exists := l.peers[runDir]; exists { | 375 | if _, exists := l.peers[runDir]; exists { | ||
| 367 | l.mu.Unlock() | 376 | l.mu.Unlock() | ||
| 368 | - | if route.netem != nil { | 377 | + | return nil, closeNetem(fmt.Errorf("duplicate transport peer for %s", runDir)) |
| 369 | - | _ = route.netem.Close() | |||
| 370 | - | } | |||
| 371 | - | return nil, fmt.Errorf("duplicate transport peer for %s", runDir) | |||
| 372 | } | 378 | } | ||
| 373 | l.peers[runDir] = route | 379 | l.peers[runDir] = route | ||
| 374 | l.mu.Unlock() | 380 | l.mu.Unlock() | ||
M cmd/vev-perf-harness/launcher_test.go +34 -4
| @@ -2,6 +2,7 @@ package main | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "net" | |
| 7 | 8 | "os" | |
| @@ -10,6 +11,8 @@ import ( | |||
| 10 | 11 | "strings" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |
| 13 | 16 | ) | |
| 14 | 17 | ||
| 15 | 18 | func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | |
| @@ -32,11 +35,16 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | |||
| 32 | 35 | ||
| 33 | 36 | func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) { | |
| 34 | 37 | path := filepath.Join(t.TempDir(), "daemon.sock") | |
| 35 | - | listener, err := net.Listen("unix", path) | |
| 38 | + | var lc net.ListenConfig | |
| 39 | + | listener, err := lc.Listen(context.Background(), "unix", path) | |
| 36 | 40 | if err != nil { | |
| 37 | 41 | t.Fatal(err) | |
| 38 | 42 | } | |
| 39 | - | defer listener.Close() | |
| 43 | + | t.Cleanup(func() { | |
| 44 | + | if err := listener.Close(); err != nil { | |
| 45 | + | t.Error(err) | |
| 46 | + | } | |
| 47 | + | }) | |
| 40 | 48 | if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil { | |
| 41 | 49 | t.Fatal(err) | |
| 42 | 50 | } | |
| @@ -55,7 +63,7 @@ func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) { | |||
| 55 | 63 | launcher := &cliLauncher{bin: bin} | |
| 56 | 64 | for run := 1; run <= 3; run++ { | |
| 57 | 65 | runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run)) | |
| 58 | - | if err := os.MkdirAll(runDir, 0o700); err != nil { | |
| 66 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 59 | 67 | t.Fatal(err) | |
| 60 | 68 | } | |
| 61 | 69 | roles := []string{"daemon", "client"} | |
| @@ -95,6 +103,28 @@ func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) { | |||
| 95 | 103 | } | |
| 96 | 104 | } | |
| 97 | 105 | ||
| 106 | + | func TestPreparePeerReleasesOwnedRuntimeOnPreparationFailure(t *testing.T) { | |
| 107 | + | t.Setenv("TMPDIR", t.TempDir()) | |
| 108 | + | runDir := filepath.Join(t.TempDir(), "run") | |
| 109 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | launcher := &cliLauncher{} | |
| 113 | + | _, err := launcher.preparePeer(processMapping{ | |
| 114 | + | Role: "ssh_stdio_peer", | |
| 115 | + | TracePath: filepath.Join(runDir, "peer.jsonl"), | |
| 116 | + | }, roleCommand{Args: []string{"invalid"}}) | |
| 117 | + | if err == nil || !strings.Contains(err.Error(), "unsupported public peer command") { | |
| 118 | + | t.Fatalf("preparePeer error = %v", err) | |
| 119 | + | } | |
| 120 | + | if runtimeDir := launcher.runtimes[runDir]; runtimeDir != "" { | |
| 121 | + | t.Fatalf("failed peer preparation leaked runtime directory %q", runtimeDir) | |
| 122 | + | } | |
| 123 | + | if paths, globErr := filepath.Glob(filepath.Join(os.TempDir(), "vev-harness-runtime-*")); globErr != nil || len(paths) != 0 { | |
| 124 | + | t.Fatalf("failed peer preparation left runtime directories %q: %v", paths, globErr) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 98 | 128 | func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) { | |
| 99 | 129 | wait := make(chan error, 1) | |
| 100 | 130 | wait <- nil | |
| @@ -153,7 +183,7 @@ func TestHarnessCanonicalLocalSmokeRealRoles(t *testing.T) { | |||
| 153 | 183 | if err != nil { | |
| 154 | 184 | t.Fatal(err) | |
| 155 | 185 | } | |
| 156 | - | defer raw.Close() | |
| 186 | + | closeTestFile(t, raw) | |
| 157 | 187 | h := defaultHarness() | |
| 158 | 188 | h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test | |
| 159 | 189 | h.launcher = &cliLauncher{bin: bin} | |
| @@ -2,6 +2,7 @@ package main | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "net" | 7 | "net" | ||
| 7 | "os" | 8 | "os" | ||
| @@ -10,6 +11,8 @@ import ( | |||||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "testing" | 12 | "testing" | ||
| 12 | "time" | 13 | "time" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 13 | ) | 16 | ) | ||
| 14 | 17 | ||||
| 15 | func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | 18 | func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | ||
| @@ -32,11 +35,16 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | |||||
| 32 | 35 | ||||
| 33 | func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) { | 36 | func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) { | ||
| 34 | path := filepath.Join(t.TempDir(), "daemon.sock") | 37 | path := filepath.Join(t.TempDir(), "daemon.sock") | ||
| 35 | - | listener, err := net.Listen("unix", path) | 38 | + | var lc net.ListenConfig |
| 39 | + | listener, err := lc.Listen(context.Background(), "unix", path) | |||
| 36 | if err != nil { | 40 | if err != nil { | ||
| 37 | t.Fatal(err) | 41 | t.Fatal(err) | ||
| 38 | } | 42 | } | ||
| 39 | - | defer listener.Close() | 43 | + | t.Cleanup(func() { |
| 44 | + | if err := listener.Close(); err != nil { | |||
| 45 | + | t.Error(err) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 40 | if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil { | 48 | if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil { | ||
| 41 | t.Fatal(err) | 49 | t.Fatal(err) | ||
| 42 | } | 50 | } | ||
| @@ -55,7 +63,7 @@ func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) { | |||||
| 55 | launcher := &cliLauncher{bin: bin} | 63 | launcher := &cliLauncher{bin: bin} | ||
| 56 | for run := 1; run <= 3; run++ { | 64 | for run := 1; run <= 3; run++ { | ||
| 57 | runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run)) | 65 | runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run)) | ||
| 58 | - | if err := os.MkdirAll(runDir, 0o700); err != nil { | 66 | + | if err := safedir.EnsurePrivate(runDir); err != nil { |
| 59 | t.Fatal(err) | 67 | t.Fatal(err) | ||
| 60 | } | 68 | } | ||
| 61 | roles := []string{"daemon", "client"} | 69 | roles := []string{"daemon", "client"} | ||
| @@ -95,6 +103,28 @@ func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) { | |||||
| 95 | } | 103 | } | ||
| 96 | } | 104 | } | ||
| 97 | 105 | ||||
| 106 | + | func TestPreparePeerReleasesOwnedRuntimeOnPreparationFailure(t *testing.T) { | |||
| 107 | + | t.Setenv("TMPDIR", t.TempDir()) | |||
| 108 | + | runDir := filepath.Join(t.TempDir(), "run") | |||
| 109 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | launcher := &cliLauncher{} | |||
| 113 | + | _, err := launcher.preparePeer(processMapping{ | |||
| 114 | + | Role: "ssh_stdio_peer", | |||
| 115 | + | TracePath: filepath.Join(runDir, "peer.jsonl"), | |||
| 116 | + | }, roleCommand{Args: []string{"invalid"}}) | |||
| 117 | + | if err == nil || !strings.Contains(err.Error(), "unsupported public peer command") { | |||
| 118 | + | t.Fatalf("preparePeer error = %v", err) | |||
| 119 | + | } | |||
| 120 | + | if runtimeDir := launcher.runtimes[runDir]; runtimeDir != "" { | |||
| 121 | + | t.Fatalf("failed peer preparation leaked runtime directory %q", runtimeDir) | |||
| 122 | + | } | |||
| 123 | + | if paths, globErr := filepath.Glob(filepath.Join(os.TempDir(), "vev-harness-runtime-*")); globErr != nil || len(paths) != 0 { | |||
| 124 | + | t.Fatalf("failed peer preparation left runtime directories %q: %v", paths, globErr) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 98 | func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) { | 128 | func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) { | ||
| 99 | wait := make(chan error, 1) | 129 | wait := make(chan error, 1) | ||
| 100 | wait <- nil | 130 | wait <- nil | ||
| @@ -153,7 +183,7 @@ func TestHarnessCanonicalLocalSmokeRealRoles(t *testing.T) { | |||||
| 153 | if err != nil { | 183 | if err != nil { | ||
| 154 | t.Fatal(err) | 184 | t.Fatal(err) | ||
| 155 | } | 185 | } | ||
| 156 | - | defer raw.Close() | 186 | + | closeTestFile(t, raw) |
| 157 | h := defaultHarness() | 187 | h := defaultHarness() | ||
| 158 | h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test | 188 | h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test | ||
| 159 | h.launcher = &cliLauncher{bin: bin} | 189 | h.launcher = &cliLauncher{bin: bin} | ||
M cmd/vev-perf-harness/options_manifest.go +21 -11
| @@ -87,7 +87,13 @@ func validateManifest(m manifest) error { | |||
| 87 | 87 | } | |
| 88 | 88 | tops := map[string]bool{} | |
| 89 | 89 | for _, t := range m.Topologies { | |
| 90 | - | if t.ID == "" || t.Geometry != "120x40" || t.RowsPerPane != 10000 { | |
| 90 | + | if t.ID == "" { | |
| 91 | + | return errors.New("empty topology id") | |
| 92 | + | } | |
| 93 | + | if tops[t.ID] { | |
| 94 | + | return fmt.Errorf("duplicate topology id %q", t.ID) | |
| 95 | + | } | |
| 96 | + | if t.Geometry != "120x40" || t.RowsPerPane != 10000 { | |
| 91 | 97 | return fmt.Errorf("invalid topology %q", t.ID) | |
| 92 | 98 | } | |
| 93 | 99 | tops[t.ID] = true | |
| @@ -98,7 +104,16 @@ func validateManifest(m manifest) error { | |||
| 98 | 104 | return fmt.Errorf("missing canonical topology %s", v) | |
| 99 | 105 | } | |
| 100 | 106 | } | |
| 101 | - | works := set(m.Workloads) | |
| 107 | + | works := map[string]bool{} | |
| 108 | + | for _, workload := range m.Workloads { | |
| 109 | + | if workload == "" { | |
| 110 | + | return errors.New("empty workload id") | |
| 111 | + | } | |
| 112 | + | if works[workload] { | |
| 113 | + | return fmt.Errorf("duplicate workload id %q", workload) | |
| 114 | + | } | |
| 115 | + | works[workload] = true | |
| 116 | + | } | |
| 102 | 117 | for _, v := range []string{"idle", "active_output", "all_output", "inactive_output", "interactive_flood", "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch"} { | |
| 103 | 118 | if !works[v] { | |
| 104 | 119 | return fmt.Errorf("missing canonical workload %s", v) | |
| @@ -107,7 +122,10 @@ func validateManifest(m manifest) error { | |||
| 107 | 122 | trans := map[string]bool{} | |
| 108 | 123 | for _, t := range m.Transports { | |
| 109 | 124 | if t.ID == "" { | |
| 110 | - | return errors.New("transport missing id") | |
| 125 | + | return errors.New("empty transport id") | |
| 126 | + | } | |
| 127 | + | if trans[t.ID] { | |
| 128 | + | return fmt.Errorf("duplicate transport id %q", t.ID) | |
| 111 | 129 | } | |
| 112 | 130 | if err := validateTransportFixture(t); err != nil { | |
| 113 | 131 | return err | |
| @@ -211,11 +229,3 @@ func validateTransportFixture(t transport) error { | |||
| 211 | 229 | } | |
| 212 | 230 | return nil | |
| 213 | 231 | } | |
| 214 | - | ||
| 215 | - | func set(v []string) map[string]bool { | |
| 216 | - | r := map[string]bool{} | |
| 217 | - | for _, x := range v { | |
| 218 | - | r[x] = true | |
| 219 | - | } | |
| 220 | - | return r | |
| 221 | - | } | |
| @@ -87,7 +87,13 @@ func validateManifest(m manifest) error { | |||||
| 87 | } | 87 | } | ||
| 88 | tops := map[string]bool{} | 88 | tops := map[string]bool{} | ||
| 89 | for _, t := range m.Topologies { | 89 | for _, t := range m.Topologies { | ||
| 90 | - | if t.ID == "" || t.Geometry != "120x40" || t.RowsPerPane != 10000 { | 90 | + | if t.ID == "" { |
| 91 | + | return errors.New("empty topology id") | |||
| 92 | + | } | |||
| 93 | + | if tops[t.ID] { | |||
| 94 | + | return fmt.Errorf("duplicate topology id %q", t.ID) | |||
| 95 | + | } | |||
| 96 | + | if t.Geometry != "120x40" || t.RowsPerPane != 10000 { | |||
| 91 | return fmt.Errorf("invalid topology %q", t.ID) | 97 | return fmt.Errorf("invalid topology %q", t.ID) | ||
| 92 | } | 98 | } | ||
| 93 | tops[t.ID] = true | 99 | tops[t.ID] = true | ||
| @@ -98,7 +104,16 @@ func validateManifest(m manifest) error { | |||||
| 98 | return fmt.Errorf("missing canonical topology %s", v) | 104 | return fmt.Errorf("missing canonical topology %s", v) | ||
| 99 | } | 105 | } | ||
| 100 | } | 106 | } | ||
| 101 | - | works := set(m.Workloads) | 107 | + | works := map[string]bool{} |
| 108 | + | for _, workload := range m.Workloads { | |||
| 109 | + | if workload == "" { | |||
| 110 | + | return errors.New("empty workload id") | |||
| 111 | + | } | |||
| 112 | + | if works[workload] { | |||
| 113 | + | return fmt.Errorf("duplicate workload id %q", workload) | |||
| 114 | + | } | |||
| 115 | + | works[workload] = true | |||
| 116 | + | } | |||
| 102 | for _, v := range []string{"idle", "active_output", "all_output", "inactive_output", "interactive_flood", "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch"} { | 117 | for _, v := range []string{"idle", "active_output", "all_output", "inactive_output", "interactive_flood", "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch"} { | ||
| 103 | if !works[v] { | 118 | if !works[v] { | ||
| 104 | return fmt.Errorf("missing canonical workload %s", v) | 119 | return fmt.Errorf("missing canonical workload %s", v) | ||
| @@ -107,7 +122,10 @@ func validateManifest(m manifest) error { | |||||
| 107 | trans := map[string]bool{} | 122 | trans := map[string]bool{} | ||
| 108 | for _, t := range m.Transports { | 123 | for _, t := range m.Transports { | ||
| 109 | if t.ID == "" { | 124 | if t.ID == "" { | ||
| 110 | - | return errors.New("transport missing id") | 125 | + | return errors.New("empty transport id") |
| 126 | + | } | |||
| 127 | + | if trans[t.ID] { | |||
| 128 | + | return fmt.Errorf("duplicate transport id %q", t.ID) | |||
| 111 | } | 129 | } | ||
| 112 | if err := validateTransportFixture(t); err != nil { | 130 | if err := validateTransportFixture(t); err != nil { | ||
| 113 | return err | 131 | return err | ||
| @@ -211,11 +229,3 @@ func validateTransportFixture(t transport) error { | |||||
| 211 | } | 229 | } | ||
| 212 | return nil | 230 | return nil | ||
| 213 | } | 231 | } | ||
| 214 | - | ||||
| 215 | - | func set(v []string) map[string]bool { | |||
| 216 | - | r := map[string]bool{} | |||
| 217 | - | for _, x := range v { | |||
| 218 | - | r[x] = true | |||
| 219 | - | } | |||
| 220 | - | return r | |||
| 221 | - | } | |||
M cmd/vev-perf-harness/options_manifest_test.go +31 -1
| @@ -119,6 +119,36 @@ func TestHarnessFlagsRejectShortMeasurements(t *testing.T) { | |||
| 119 | 119 | } | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | + | func TestValidateManifestRejectsEmptyAndDuplicateMatrixIDs(t *testing.T) { | |
| 123 | + | canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | for _, tc := range []struct { | |
| 128 | + | name string | |
| 129 | + | mutate func(*manifest) | |
| 130 | + | want string | |
| 131 | + | }{ | |
| 132 | + | {"empty topology", func(m *manifest) { m.Topologies[0].ID = "" }, "empty topology id"}, | |
| 133 | + | {"duplicate topology", func(m *manifest) { m.Topologies[1].ID = m.Topologies[0].ID }, "duplicate topology id"}, | |
| 134 | + | {"empty workload", func(m *manifest) { m.Workloads[0] = "" }, "empty workload id"}, | |
| 135 | + | {"duplicate workload", func(m *manifest) { m.Workloads[1] = m.Workloads[0] }, "duplicate workload id"}, | |
| 136 | + | {"empty transport", func(m *manifest) { m.Transports[0].ID = "" }, "empty transport id"}, | |
| 137 | + | {"duplicate transport", func(m *manifest) { m.Transports[1].ID = m.Transports[0].ID }, "duplicate transport id"}, | |
| 138 | + | } { | |
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |
| 140 | + | m := canonical | |
| 141 | + | m.Topologies = append([]topology(nil), canonical.Topologies...) | |
| 142 | + | m.Workloads = append([]string(nil), canonical.Workloads...) | |
| 143 | + | m.Transports = append([]transport(nil), canonical.Transports...) | |
| 144 | + | tc.mutate(&m) | |
| 145 | + | if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 146 | + | t.Fatalf("validateManifest() error = %v, want %q", err, tc.want) | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 122 | 152 | func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) { | |
| 123 | 153 | m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 124 | 154 | if err != nil { | |
| @@ -142,7 +172,7 @@ func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |||
| 142 | 172 | if err != nil { | |
| 143 | 173 | t.Fatal(err) | |
| 144 | 174 | } | |
| 145 | - | defer raw.Close() | |
| 175 | + | closeTestFile(t, raw) | |
| 146 | 176 | launcher := &fakeLauncher{} | |
| 147 | 177 | h := defaultHarness() | |
| 148 | 178 | h.clock = &fakeClock{} | |
| @@ -119,6 +119,36 @@ func TestHarnessFlagsRejectShortMeasurements(t *testing.T) { | |||||
| 119 | } | 119 | } | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | + | func TestValidateManifestRejectsEmptyAndDuplicateMatrixIDs(t *testing.T) { | |||
| 123 | + | canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | for _, tc := range []struct { | |||
| 128 | + | name string | |||
| 129 | + | mutate func(*manifest) | |||
| 130 | + | want string | |||
| 131 | + | }{ | |||
| 132 | + | {"empty topology", func(m *manifest) { m.Topologies[0].ID = "" }, "empty topology id"}, | |||
| 133 | + | {"duplicate topology", func(m *manifest) { m.Topologies[1].ID = m.Topologies[0].ID }, "duplicate topology id"}, | |||
| 134 | + | {"empty workload", func(m *manifest) { m.Workloads[0] = "" }, "empty workload id"}, | |||
| 135 | + | {"duplicate workload", func(m *manifest) { m.Workloads[1] = m.Workloads[0] }, "duplicate workload id"}, | |||
| 136 | + | {"empty transport", func(m *manifest) { m.Transports[0].ID = "" }, "empty transport id"}, | |||
| 137 | + | {"duplicate transport", func(m *manifest) { m.Transports[1].ID = m.Transports[0].ID }, "duplicate transport id"}, | |||
| 138 | + | } { | |||
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 140 | + | m := canonical | |||
| 141 | + | m.Topologies = append([]topology(nil), canonical.Topologies...) | |||
| 142 | + | m.Workloads = append([]string(nil), canonical.Workloads...) | |||
| 143 | + | m.Transports = append([]transport(nil), canonical.Transports...) | |||
| 144 | + | tc.mutate(&m) | |||
| 145 | + | if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) { | |||
| 146 | + | t.Fatalf("validateManifest() error = %v, want %q", err, tc.want) | |||
| 147 | + | } | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 122 | func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) { | 152 | func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) { | ||
| 123 | m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | 153 | m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | ||
| 124 | if err != nil { | 154 | if err != nil { | ||
| @@ -142,7 +172,7 @@ func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |||||
| 142 | if err != nil { | 172 | if err != nil { | ||
| 143 | t.Fatal(err) | 173 | t.Fatal(err) | ||
| 144 | } | 174 | } | ||
| 145 | - | defer raw.Close() | 175 | + | closeTestFile(t, raw) |
| 146 | launcher := &fakeLauncher{} | 176 | launcher := &fakeLauncher{} | ||
| 147 | h := defaultHarness() | 177 | h := defaultHarness() | ||
| 148 | h.clock = &fakeClock{} | 178 | h.clock = &fakeClock{} | ||
M cmd/vev-perf-harness/runner_test.go +3 -1
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | "strings" | |
| 12 | 12 | "testing" | |
| 13 | 13 | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) { | |
| @@ -126,7 +128,7 @@ func TestHarnessResolvesCanonicalRelativePathsBeforeRoleCWDChanges(t *testing.T) | |||
| 126 | 128 | t.Fatalf("build public CLI: %v\n%s", err, output) | |
| 127 | 129 | } | |
| 128 | 130 | manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json") | |
| 129 | - | if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { | |
| 131 | + | if err := safedir.EnsurePrivate(filepath.Dir(manifestPath)); err != nil { | |
| 130 | 132 | t.Fatal(err) | |
| 131 | 133 | } | |
| 132 | 134 | contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json")) | |
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "testing" | 12 | "testing" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) { | 18 | func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) { | ||
| @@ -126,7 +128,7 @@ func TestHarnessResolvesCanonicalRelativePathsBeforeRoleCWDChanges(t *testing.T) | |||||
| 126 | t.Fatalf("build public CLI: %v\n%s", err, output) | 128 | t.Fatalf("build public CLI: %v\n%s", err, output) | ||
| 127 | } | 129 | } | ||
| 128 | manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json") | 130 | manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json") | ||
| 129 | - | if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { | 131 | + | if err := safedir.EnsurePrivate(filepath.Dir(manifestPath)); err != nil { |
| 130 | t.Fatal(err) | 132 | t.Fatal(err) | ||
| 131 | } | 133 | } | ||
| 132 | contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json")) | 134 | contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json")) | ||
M cmd/vev-perf-harness/udp_netem.go +3 -1
| @@ -2,6 +2,7 @@ package main | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "container/heap" | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "net" | |
| @@ -85,7 +86,8 @@ func newUDPNetem(config udpNetemConfig) (udpNetem, error) { | |||
| 85 | 86 | if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" { | |
| 86 | 87 | return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath) | |
| 87 | 88 | } | |
| 88 | - | conn, err := net.ListenPacket("udp", "127.0.0.1:0") | |
| 89 | + | var lc net.ListenConfig | |
| 90 | + | conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 89 | 91 | if err != nil { | |
| 90 | 92 | return nil, err | |
| 91 | 93 | } | |
| @@ -2,6 +2,7 @@ package main | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "container/heap" | 4 | "container/heap" | ||
| 5 | + | "context" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "net" | 8 | "net" | ||
| @@ -85,7 +86,8 @@ func newUDPNetem(config udpNetemConfig) (udpNetem, error) { | |||||
| 85 | if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" { | 86 | if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" { | ||
| 86 | return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath) | 87 | return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath) | ||
| 87 | } | 88 | } | ||
| 88 | - | conn, err := net.ListenPacket("udp", "127.0.0.1:0") | 89 | + | var lc net.ListenConfig |
| 90 | + | conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |||
| 89 | if err != nil { | 91 | if err != nil { | ||
| 90 | return nil, err | 92 | return nil, err | ||
| 91 | } | 93 | } | ||
M cmd/vev-perf-harness/udp_netem_test.go +4 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "net" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| @@ -68,7 +69,8 @@ func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) { | |||
| 68 | 69 | } | |
| 69 | 70 | for _, tc := range fixtures { | |
| 70 | 71 | t.Run(tc.name, func(t *testing.T) { | |
| 71 | - | target, err := net.ListenPacket("udp", "127.0.0.1:0") | |
| 72 | + | var lc net.ListenConfig | |
| 73 | + | target, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 72 | 74 | if err != nil { | |
| 73 | 75 | t.Fatal(err) | |
| 74 | 76 | } | |
| @@ -86,7 +88,7 @@ func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) { | |||
| 86 | 88 | t.Fatal(err) | |
| 87 | 89 | } | |
| 88 | 90 | closeUDPFixture(t, netem) | |
| 89 | - | client, err := net.ListenPacket("udp", "127.0.0.1:0") | |
| 91 | + | client, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 90 | 92 | if err != nil { | |
| 91 | 93 | t.Fatal(err) | |
| 92 | 94 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package main | 1 | package main | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "net" | 5 | "net" | ||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| @@ -68,7 +69,8 @@ func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) { | |||||
| 68 | } | 69 | } | ||
| 69 | for _, tc := range fixtures { | 70 | for _, tc := range fixtures { | ||
| 70 | t.Run(tc.name, func(t *testing.T) { | 71 | t.Run(tc.name, func(t *testing.T) { | ||
| 71 | - | target, err := net.ListenPacket("udp", "127.0.0.1:0") | 72 | + | var lc net.ListenConfig |
| 73 | + | target, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |||
| 72 | if err != nil { | 74 | if err != nil { | ||
| 73 | t.Fatal(err) | 75 | t.Fatal(err) | ||
| 74 | } | 76 | } | ||
| @@ -86,7 +88,7 @@ func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) { | |||||
| 86 | t.Fatal(err) | 88 | t.Fatal(err) | ||
| 87 | } | 89 | } | ||
| 88 | closeUDPFixture(t, netem) | 90 | closeUDPFixture(t, netem) | ||
| 89 | - | client, err := net.ListenPacket("udp", "127.0.0.1:0") | 91 | + | client, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") |
| 90 | if err != nil { | 92 | if err != nil { | ||
| 91 | t.Fatal(err) | 93 | t.Fatal(err) | ||
| 92 | } | 94 | } | ||
M internal/adapters/ipc/transport.go +157 -31
| @@ -27,15 +27,25 @@ var ErrZeroLengthFrame = errors.New("ipc: zero-length frame") | |||
| 27 | 27 | // maxFrameLen, and by Send when a payload is too large to encode. | |
| 28 | 28 | var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length") | |
| 29 | 29 | ||
| 30 | + | // ErrBackpressure means the bounded IPC egress queue is full. Callers using | |
| 31 | + | // SendAsync must treat it as a failed render emission rather than spawning an | |
| 32 | + | // unbounded queue of their own. | |
| 33 | + | var ErrBackpressure = errors.New("ipc: egress queue full") | |
| 34 | + | ||
| 35 | + | var errClosed = errors.New("ipc: closed") | |
| 36 | + | ||
| 37 | + | // sendQueueCapacity bounds frames accepted while a peer is not reading. It is | |
| 38 | + | // deliberately small: render output can be large and must never turn a wedged | |
| 39 | + | // peer into an unbounded memory commitment. | |
| 40 | + | const sendQueueCapacity = 8 | |
| 41 | + | ||
| 30 | 42 | // unixTransport implements ports.Transport over a net.Conn (in practice an | |
| 31 | 43 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | |
| 32 | 44 | // tests exercise it over net.Pipe). | |
| 33 | 45 | // | |
| 34 | - | // unixTransport is safe for one concurrent caller of Send and one | |
| 35 | - | // concurrent caller of Recv at the same time (i.e. a single reader | |
| 36 | - | // goroutine and a single writer goroutine); it is not safe for concurrent | |
| 37 | - | // calls to Send from multiple goroutines, nor Recv from multiple | |
| 38 | - | // goroutines. | |
| 46 | + | // unixTransport has one writer goroutine which owns every conn.Write. Send | |
| 47 | + | // waits for that owner's wire attempt; SendAsync only performs a bounded, | |
| 48 | + | // ordered enqueue. Recv still has one caller at a time. | |
| 39 | 49 | type Option func(*unixTransport) | |
| 40 | 50 | ||
| 41 | 51 | // WithRuntimeObserver enables process-local transport marks. It accepts no | |
| @@ -45,12 +55,23 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 45 | 55 | } | |
| 46 | 56 | ||
| 47 | 57 | type unixTransport struct { | |
| 48 | - | conn net.Conn | |
| 49 | - | observer ports.SerializedRuntimeObserver | |
| 50 | - | operationMu sync.Mutex | |
| 51 | - | operationCount int | |
| 52 | - | closing bool | |
| 53 | - | operationsDone chan struct{} | |
| 58 | + | conn net.Conn | |
| 59 | + | observer ports.SerializedRuntimeObserver | |
| 60 | + | ||
| 61 | + | operationMu sync.Mutex | |
| 62 | + | operationCount int | |
| 63 | + | observedClosing bool | |
| 64 | + | operationsDone chan struct{} | |
| 65 | + | ||
| 66 | + | // egressMu synchronizes Close with nonblocking enqueue. Once closed, no | |
| 67 | + | // request can be left behind after the writer drains the queue. | |
| 68 | + | egressMu sync.Mutex | |
| 69 | + | egressClosing bool | |
| 70 | + | egress chan sendRequest | |
| 71 | + | done chan struct{} | |
| 72 | + | writerDone chan struct{} | |
| 73 | + | closeOnce sync.Once | |
| 74 | + | closeErr error | |
| 54 | 75 | ||
| 55 | 76 | // readBuf is reused across Recv calls (grow-once strategy): it grows | |
| 56 | 77 | // to fit the largest frame seen so far and is never shrunk. The Frame | |
| @@ -62,34 +83,132 @@ type unixTransport struct { | |||
| 62 | 83 | // NewTransport wraps conn as a ports.Transport speaking vev's framed | |
| 63 | 84 | // binary protocol. | |
| 64 | 85 | func NewTransport(conn net.Conn, opts ...Option) ports.Transport { | |
| 65 | - | t := &unixTransport{conn: conn} | |
| 86 | + | t := &unixTransport{ | |
| 87 | + | conn: conn, | |
| 88 | + | egress: make(chan sendRequest, sendQueueCapacity), | |
| 89 | + | done: make(chan struct{}), | |
| 90 | + | writerDone: make(chan struct{}), | |
| 91 | + | } | |
| 66 | 92 | for _, opt := range opts { | |
| 67 | 93 | if opt != nil { | |
| 68 | 94 | opt(t) | |
| 69 | 95 | } | |
| 70 | 96 | } | |
| 97 | + | go t.writeLoop() | |
| 71 | 98 | return t | |
| 72 | 99 | } | |
| 73 | 100 | ||
| 74 | - | // Send writes f as a single frame: a 4-byte big-endian length (covering | |
| 75 | - | // the type byte and payload), the type byte, then the payload — assembled | |
| 76 | - | // into one buffer and written with a single Write call. | |
| 101 | + | type sendRequest struct { | |
| 102 | + | data []byte | |
| 103 | + | done chan error // nil for SendAsync | |
| 104 | + | end func(bool) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Send queues f for the sole writer and waits for its wire attempt. | |
| 77 | 108 | func (t *unixTransport) Send(f ports.Frame) error { | |
| 109 | + | data, err := marshalFrame(f) | |
| 110 | + | if err != nil { | |
| 111 | + | return err | |
| 112 | + | } | |
| 78 | 113 | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |
| 79 | - | n := 1 + len(f.Payload) // type + payload | |
| 80 | - | if n > maxFrameLen { | |
| 114 | + | result := make(chan error, 1) | |
| 115 | + | if err := t.enqueue(sendRequest{data: data, done: result, end: end}); err != nil { | |
| 81 | 116 | end(false) | |
| 82 | - | return ErrFrameTooLarge | |
| 117 | + | return err | |
| 83 | 118 | } | |
| 119 | + | select { | |
| 120 | + | case err := <-result: | |
| 121 | + | return err | |
| 122 | + | case <-t.done: | |
| 123 | + | return errClosed | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // SendAsync accepts f for ordered background transmission without waiting for | |
| 128 | + | // a socket write. The queue is bounded and ownership includes a payload copy. | |
| 129 | + | func (t *unixTransport) SendAsync(f ports.Frame) error { | |
| 130 | + | data, err := marshalFrame(f) | |
| 131 | + | if err != nil { | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |
| 135 | + | if err := t.enqueue(sendRequest{data: data, end: end}); err != nil { | |
| 136 | + | end(false) | |
| 137 | + | return err | |
| 138 | + | } | |
| 139 | + | return nil | |
| 140 | + | } | |
| 84 | 141 | ||
| 142 | + | func marshalFrame(f ports.Frame) ([]byte, error) { | |
| 143 | + | n := 1 + len(f.Payload) // type + payload | |
| 144 | + | if n > maxFrameLen { | |
| 145 | + | return nil, ErrFrameTooLarge | |
| 146 | + | } | |
| 85 | 147 | buf := make([]byte, frameHeaderLen+n) | |
| 86 | 148 | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | |
| 87 | 149 | buf[frameHeaderLen] = byte(f.Type) | |
| 88 | 150 | copy(buf[frameHeaderLen+1:], f.Payload) | |
| 151 | + | return buf, nil | |
| 152 | + | } | |
| 89 | 153 | ||
| 90 | - | _, err := t.conn.Write(buf) | |
| 91 | - | end(err == nil) | |
| 92 | - | return err | |
| 154 | + | func (t *unixTransport) enqueue(req sendRequest) error { | |
| 155 | + | t.egressMu.Lock() | |
| 156 | + | defer t.egressMu.Unlock() | |
| 157 | + | if t.egressClosing { | |
| 158 | + | return errClosed | |
| 159 | + | } | |
| 160 | + | select { | |
| 161 | + | case t.egress <- req: | |
| 162 | + | return nil | |
| 163 | + | default: | |
| 164 | + | return ErrBackpressure | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (t *unixTransport) writeLoop() { | |
| 169 | + | defer close(t.writerDone) | |
| 170 | + | for { | |
| 171 | + | select { | |
| 172 | + | case req := <-t.egress: | |
| 173 | + | t.completeWrite(req, writeAll(t.conn, req.data)) | |
| 174 | + | case <-t.done: | |
| 175 | + | t.egressMu.Lock() | |
| 176 | + | for { | |
| 177 | + | select { | |
| 178 | + | case req := <-t.egress: | |
| 179 | + | t.completeWrite(req, errClosed) | |
| 180 | + | default: | |
| 181 | + | t.egressMu.Unlock() | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (t *unixTransport) completeWrite(req sendRequest, err error) { | |
| 190 | + | if req.end != nil { | |
| 191 | + | req.end(err == nil) | |
| 192 | + | } | |
| 193 | + | if req.done != nil { | |
| 194 | + | req.done <- err | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func writeAll(w io.Writer, data []byte) error { | |
| 199 | + | for len(data) > 0 { | |
| 200 | + | n, err := w.Write(data) | |
| 201 | + | if n > 0 { | |
| 202 | + | data = data[n:] | |
| 203 | + | } | |
| 204 | + | if err != nil { | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | if n == 0 { | |
| 208 | + | return io.ErrShortWrite | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | return nil | |
| 93 | 212 | } | |
| 94 | 213 | ||
| 95 | 214 | // Recv reads one frame: a 4-byte length, then a body of that many bytes | |
| @@ -154,7 +273,7 @@ func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64 | |||
| 154 | 273 | func (t *unixTransport) beginObservedOperation() bool { | |
| 155 | 274 | t.operationMu.Lock() | |
| 156 | 275 | defer t.operationMu.Unlock() | |
| 157 | - | if t.closing { | |
| 276 | + | if t.observedClosing { | |
| 158 | 277 | return false | |
| 159 | 278 | } | |
| 160 | 279 | if t.operationCount == 0 { | |
| @@ -176,20 +295,27 @@ func (t *unixTransport) finishObservedOperation() { | |||
| 176 | 295 | func (t *unixTransport) beginShutdown() <-chan struct{} { | |
| 177 | 296 | t.operationMu.Lock() | |
| 178 | 297 | defer t.operationMu.Unlock() | |
| 179 | - | t.closing = true | |
| 298 | + | t.observedClosing = true | |
| 180 | 299 | if t.operationCount == 0 { | |
| 181 | 300 | return nil | |
| 182 | 301 | } | |
| 183 | 302 | return t.operationsDone | |
| 184 | 303 | } | |
| 185 | 304 | ||
| 186 | - | // Close closes the underlying connection and waits for any interrupted | |
| 187 | - | // carriage operation to write its matching failed end mark. | |
| 305 | + | // Close interrupts Recv and the writer's current Write, fails queued frames, | |
| 306 | + | // and waits until the sole writer and all observed operations have stopped. | |
| 188 | 307 | func (t *unixTransport) Close() error { | |
| 189 | - | done := t.beginShutdown() | |
| 190 | - | err := t.conn.Close() | |
| 191 | - | if done != nil { | |
| 192 | - | <-done | |
| 193 | - | } | |
| 194 | - | return err | |
| 308 | + | t.closeOnce.Do(func() { | |
| 309 | + | operationsDone := t.beginShutdown() | |
| 310 | + | t.egressMu.Lock() | |
| 311 | + | t.egressClosing = true | |
| 312 | + | close(t.done) | |
| 313 | + | t.egressMu.Unlock() | |
| 314 | + | t.closeErr = t.conn.Close() | |
| 315 | + | <-t.writerDone | |
| 316 | + | if operationsDone != nil { | |
| 317 | + | <-operationsDone | |
| 318 | + | } | |
| 319 | + | }) | |
| 320 | + | return t.closeErr | |
| 195 | 321 | } | |
| @@ -27,15 +27,25 @@ var ErrZeroLengthFrame = errors.New("ipc: zero-length frame") | |||||
| 27 | // maxFrameLen, and by Send when a payload is too large to encode. | 27 | // maxFrameLen, and by Send when a payload is too large to encode. | ||
| 28 | var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length") | 28 | var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length") | ||
| 29 | 29 | ||||
| 30 | + | // ErrBackpressure means the bounded IPC egress queue is full. Callers using | |||
| 31 | + | // SendAsync must treat it as a failed render emission rather than spawning an | |||
| 32 | + | // unbounded queue of their own. | |||
| 33 | + | var ErrBackpressure = errors.New("ipc: egress queue full") | |||
| 34 | + | ||||
| 35 | + | var errClosed = errors.New("ipc: closed") | |||
| 36 | + | ||||
| 37 | + | // sendQueueCapacity bounds frames accepted while a peer is not reading. It is | |||
| 38 | + | // deliberately small: render output can be large and must never turn a wedged | |||
| 39 | + | // peer into an unbounded memory commitment. | |||
| 40 | + | const sendQueueCapacity = 8 | |||
| 41 | + | ||||
| 30 | // unixTransport implements ports.Transport over a net.Conn (in practice an | 42 | // unixTransport implements ports.Transport over a net.Conn (in practice an | ||
| 31 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | 43 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | ||
| 32 | // tests exercise it over net.Pipe). | 44 | // tests exercise it over net.Pipe). | ||
| 33 | // | 45 | // | ||
| 34 | - | // unixTransport is safe for one concurrent caller of Send and one | 46 | + | // unixTransport has one writer goroutine which owns every conn.Write. Send |
| 35 | - | // concurrent caller of Recv at the same time (i.e. a single reader | 47 | + | // waits for that owner's wire attempt; SendAsync only performs a bounded, |
| 36 | - | // goroutine and a single writer goroutine); it is not safe for concurrent | 48 | + | // ordered enqueue. Recv still has one caller at a time. |
| 37 | - | // calls to Send from multiple goroutines, nor Recv from multiple | |||
| 38 | - | // goroutines. | |||
| 39 | type Option func(*unixTransport) | 49 | type Option func(*unixTransport) | ||
| 40 | 50 | ||||
| 41 | // WithRuntimeObserver enables process-local transport marks. It accepts no | 51 | // WithRuntimeObserver enables process-local transport marks. It accepts no | ||
| @@ -45,12 +55,23 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||||
| 45 | } | 55 | } | ||
| 46 | 56 | ||||
| 47 | type unixTransport struct { | 57 | type unixTransport struct { | ||
| 48 | - | conn net.Conn | 58 | + | conn net.Conn |
| 49 | - | observer ports.SerializedRuntimeObserver | 59 | + | observer ports.SerializedRuntimeObserver |
| 50 | - | operationMu sync.Mutex | 60 | + | |
| 51 | - | operationCount int | 61 | + | operationMu sync.Mutex |
| 52 | - | closing bool | 62 | + | operationCount int |
| 53 | - | operationsDone chan struct{} | 63 | + | observedClosing bool |
| 64 | + | operationsDone chan struct{} | |||
| 65 | + | ||||
| 66 | + | // egressMu synchronizes Close with nonblocking enqueue. Once closed, no | |||
| 67 | + | // request can be left behind after the writer drains the queue. | |||
| 68 | + | egressMu sync.Mutex | |||
| 69 | + | egressClosing bool | |||
| 70 | + | egress chan sendRequest | |||
| 71 | + | done chan struct{} | |||
| 72 | + | writerDone chan struct{} | |||
| 73 | + | closeOnce sync.Once | |||
| 74 | + | closeErr error | |||
| 54 | 75 | ||||
| 55 | // readBuf is reused across Recv calls (grow-once strategy): it grows | 76 | // readBuf is reused across Recv calls (grow-once strategy): it grows | ||
| 56 | // to fit the largest frame seen so far and is never shrunk. The Frame | 77 | // to fit the largest frame seen so far and is never shrunk. The Frame | ||
| @@ -62,34 +83,132 @@ type unixTransport struct { | |||||
| 62 | // NewTransport wraps conn as a ports.Transport speaking vev's framed | 83 | // NewTransport wraps conn as a ports.Transport speaking vev's framed | ||
| 63 | // binary protocol. | 84 | // binary protocol. | ||
| 64 | func NewTransport(conn net.Conn, opts ...Option) ports.Transport { | 85 | func NewTransport(conn net.Conn, opts ...Option) ports.Transport { | ||
| 65 | - | t := &unixTransport{conn: conn} | 86 | + | t := &unixTransport{ |
| 87 | + | conn: conn, | |||
| 88 | + | egress: make(chan sendRequest, sendQueueCapacity), | |||
| 89 | + | done: make(chan struct{}), | |||
| 90 | + | writerDone: make(chan struct{}), | |||
| 91 | + | } | |||
| 66 | for _, opt := range opts { | 92 | for _, opt := range opts { | ||
| 67 | if opt != nil { | 93 | if opt != nil { | ||
| 68 | opt(t) | 94 | opt(t) | ||
| 69 | } | 95 | } | ||
| 70 | } | 96 | } | ||
| 97 | + | go t.writeLoop() | |||
| 71 | return t | 98 | return t | ||
| 72 | } | 99 | } | ||
| 73 | 100 | ||||
| 74 | - | // Send writes f as a single frame: a 4-byte big-endian length (covering | 101 | + | type sendRequest struct { |
| 75 | - | // the type byte and payload), the type byte, then the payload — assembled | 102 | + | data []byte |
| 76 | - | // into one buffer and written with a single Write call. | 103 | + | done chan error // nil for SendAsync |
| 104 | + | end func(bool) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Send queues f for the sole writer and waits for its wire attempt. | |||
| 77 | func (t *unixTransport) Send(f ports.Frame) error { | 108 | func (t *unixTransport) Send(f ports.Frame) error { | ||
| 109 | + | data, err := marshalFrame(f) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 78 | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | 113 | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | ||
| 79 | - | n := 1 + len(f.Payload) // type + payload | 114 | + | result := make(chan error, 1) |
| 80 | - | if n > maxFrameLen { | 115 | + | if err := t.enqueue(sendRequest{data: data, done: result, end: end}); err != nil { |
| 81 | end(false) | 116 | end(false) | ||
| 82 | - | return ErrFrameTooLarge | 117 | + | return err |
| 83 | } | 118 | } | ||
| 119 | + | select { | |||
| 120 | + | case err := <-result: | |||
| 121 | + | return err | |||
| 122 | + | case <-t.done: | |||
| 123 | + | return errClosed | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // SendAsync accepts f for ordered background transmission without waiting for | |||
| 128 | + | // a socket write. The queue is bounded and ownership includes a payload copy. | |||
| 129 | + | func (t *unixTransport) SendAsync(f ports.Frame) error { | |||
| 130 | + | data, err := marshalFrame(f) | |||
| 131 | + | if err != nil { | |||
| 132 | + | return err | |||
| 133 | + | } | |||
| 134 | + | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |||
| 135 | + | if err := t.enqueue(sendRequest{data: data, end: end}); err != nil { | |||
| 136 | + | end(false) | |||
| 137 | + | return err | |||
| 138 | + | } | |||
| 139 | + | return nil | |||
| 140 | + | } | |||
| 84 | 141 | ||||
| 142 | + | func marshalFrame(f ports.Frame) ([]byte, error) { | |||
| 143 | + | n := 1 + len(f.Payload) // type + payload | |||
| 144 | + | if n > maxFrameLen { | |||
| 145 | + | return nil, ErrFrameTooLarge | |||
| 146 | + | } | |||
| 85 | buf := make([]byte, frameHeaderLen+n) | 147 | buf := make([]byte, frameHeaderLen+n) | ||
| 86 | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | 148 | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | ||
| 87 | buf[frameHeaderLen] = byte(f.Type) | 149 | buf[frameHeaderLen] = byte(f.Type) | ||
| 88 | copy(buf[frameHeaderLen+1:], f.Payload) | 150 | copy(buf[frameHeaderLen+1:], f.Payload) | ||
| 151 | + | return buf, nil | |||
| 152 | + | } | |||
| 89 | 153 | ||||
| 90 | - | _, err := t.conn.Write(buf) | 154 | + | func (t *unixTransport) enqueue(req sendRequest) error { |
| 91 | - | end(err == nil) | 155 | + | t.egressMu.Lock() |
| 92 | - | return err | 156 | + | defer t.egressMu.Unlock() |
| 157 | + | if t.egressClosing { | |||
| 158 | + | return errClosed | |||
| 159 | + | } | |||
| 160 | + | select { | |||
| 161 | + | case t.egress <- req: | |||
| 162 | + | return nil | |||
| 163 | + | default: | |||
| 164 | + | return ErrBackpressure | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (t *unixTransport) writeLoop() { | |||
| 169 | + | defer close(t.writerDone) | |||
| 170 | + | for { | |||
| 171 | + | select { | |||
| 172 | + | case req := <-t.egress: | |||
| 173 | + | t.completeWrite(req, writeAll(t.conn, req.data)) | |||
| 174 | + | case <-t.done: | |||
| 175 | + | t.egressMu.Lock() | |||
| 176 | + | for { | |||
| 177 | + | select { | |||
| 178 | + | case req := <-t.egress: | |||
| 179 | + | t.completeWrite(req, errClosed) | |||
| 180 | + | default: | |||
| 181 | + | t.egressMu.Unlock() | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (t *unixTransport) completeWrite(req sendRequest, err error) { | |||
| 190 | + | if req.end != nil { | |||
| 191 | + | req.end(err == nil) | |||
| 192 | + | } | |||
| 193 | + | if req.done != nil { | |||
| 194 | + | req.done <- err | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func writeAll(w io.Writer, data []byte) error { | |||
| 199 | + | for len(data) > 0 { | |||
| 200 | + | n, err := w.Write(data) | |||
| 201 | + | if n > 0 { | |||
| 202 | + | data = data[n:] | |||
| 203 | + | } | |||
| 204 | + | if err != nil { | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | if n == 0 { | |||
| 208 | + | return io.ErrShortWrite | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | return nil | |||
| 93 | } | 212 | } | ||
| 94 | 213 | ||||
| 95 | // Recv reads one frame: a 4-byte length, then a body of that many bytes | 214 | // Recv reads one frame: a 4-byte length, then a body of that many bytes | ||
| @@ -154,7 +273,7 @@ func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64 | |||||
| 154 | func (t *unixTransport) beginObservedOperation() bool { | 273 | func (t *unixTransport) beginObservedOperation() bool { | ||
| 155 | t.operationMu.Lock() | 274 | t.operationMu.Lock() | ||
| 156 | defer t.operationMu.Unlock() | 275 | defer t.operationMu.Unlock() | ||
| 157 | - | if t.closing { | 276 | + | if t.observedClosing { |
| 158 | return false | 277 | return false | ||
| 159 | } | 278 | } | ||
| 160 | if t.operationCount == 0 { | 279 | if t.operationCount == 0 { | ||
| @@ -176,20 +295,27 @@ func (t *unixTransport) finishObservedOperation() { | |||||
| 176 | func (t *unixTransport) beginShutdown() <-chan struct{} { | 295 | func (t *unixTransport) beginShutdown() <-chan struct{} { | ||
| 177 | t.operationMu.Lock() | 296 | t.operationMu.Lock() | ||
| 178 | defer t.operationMu.Unlock() | 297 | defer t.operationMu.Unlock() | ||
| 179 | - | t.closing = true | 298 | + | t.observedClosing = true |
| 180 | if t.operationCount == 0 { | 299 | if t.operationCount == 0 { | ||
| 181 | return nil | 300 | return nil | ||
| 182 | } | 301 | } | ||
| 183 | return t.operationsDone | 302 | return t.operationsDone | ||
| 184 | } | 303 | } | ||
| 185 | 304 | ||||
| 186 | - | // Close closes the underlying connection and waits for any interrupted | 305 | + | // Close interrupts Recv and the writer's current Write, fails queued frames, |
| 187 | - | // carriage operation to write its matching failed end mark. | 306 | + | // and waits until the sole writer and all observed operations have stopped. |
| 188 | func (t *unixTransport) Close() error { | 307 | func (t *unixTransport) Close() error { | ||
| 189 | - | done := t.beginShutdown() | 308 | + | t.closeOnce.Do(func() { |
| 190 | - | err := t.conn.Close() | 309 | + | operationsDone := t.beginShutdown() |
| 191 | - | if done != nil { | 310 | + | t.egressMu.Lock() |
| 192 | - | <-done | 311 | + | t.egressClosing = true |
| 193 | - | } | 312 | + | close(t.done) |
| 194 | - | return err | 313 | + | t.egressMu.Unlock() |
| 314 | + | t.closeErr = t.conn.Close() | |||
| 315 | + | <-t.writerDone | |||
| 316 | + | if operationsDone != nil { | |||
| 317 | + | <-operationsDone | |||
| 318 | + | } | |||
| 319 | + | }) | |||
| 320 | + | return t.closeErr | |||
| 195 | } | 321 | } | ||
M internal/adapters/ipc/transport_test.go +101 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "reflect" | |
| 9 | 9 | "sync" | |
| 10 | 10 | "testing" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| @@ -231,3 +232,103 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) { | |||
| 231 | 232 | t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err) | |
| 232 | 233 | } | |
| 233 | 234 | } | |
| 235 | + | ||
| 236 | + | func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) { | |
| 237 | + | serverConn, clientConn := net.Pipe() | |
| 238 | + | defer func() { _ = serverConn.Close() }() | |
| 239 | + | defer func() { _ = clientConn.Close() }() | |
| 240 | + | ||
| 241 | + | server := NewTransport(serverConn) | |
| 242 | + | client := NewTransport(clientConn) | |
| 243 | + | async, ok := server.(ports.AsyncTransport) | |
| 244 | + | if !ok { | |
| 245 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | welcome := ports.Frame{Type: ports.MsgWelcome, Payload: []byte("welcome")} | |
| 249 | + | sent := make(chan error, 1) | |
| 250 | + | go func() { sent <- server.Send(welcome) }() | |
| 251 | + | got, err := client.Recv() | |
| 252 | + | if err != nil { | |
| 253 | + | t.Fatalf("Recv welcome: %v", err) | |
| 254 | + | } | |
| 255 | + | if !reflect.DeepEqual(got, welcome) { | |
| 256 | + | t.Fatalf("welcome = %#v, want %#v", got, welcome) | |
| 257 | + | } | |
| 258 | + | if err := <-sent; err != nil { | |
| 259 | + | t.Fatalf("Send welcome: %v", err) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | payload := []byte("first") | |
| 263 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |
| 264 | + | t.Fatalf("SendAsync first output: %v", err) | |
| 265 | + | } | |
| 266 | + | payload[0] = 'X' // async ownership must not retain caller memory. | |
| 267 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}); err != nil { | |
| 268 | + | t.Fatalf("SendAsync second output: %v", err) | |
| 269 | + | } | |
| 270 | + | for _, want := range []ports.Frame{ | |
| 271 | + | {Type: ports.MsgOutput, Payload: []byte("first")}, | |
| 272 | + | {Type: ports.MsgOutput, Payload: []byte("second")}, | |
| 273 | + | } { | |
| 274 | + | got, err := client.Recv() | |
| 275 | + | if err != nil { | |
| 276 | + | t.Fatalf("Recv output: %v", err) | |
| 277 | + | } | |
| 278 | + | if !reflect.DeepEqual(got, want) { | |
| 279 | + | t.Fatalf("output = %#v, want %#v", got, want) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) { | |
| 285 | + | serverConn, clientConn := net.Pipe() | |
| 286 | + | defer func() { _ = clientConn.Close() }() | |
| 287 | + | ||
| 288 | + | tr := NewTransport(serverConn) | |
| 289 | + | async, ok := tr.(ports.AsyncTransport) | |
| 290 | + | if !ok { | |
| 291 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // The first write blocks in net.Pipe because the peer never drains it. | |
| 295 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("blocked")}); err != nil { | |
| 296 | + | t.Fatalf("SendAsync blocked frame: %v", err) | |
| 297 | + | } | |
| 298 | + | var backpressure error | |
| 299 | + | for range sendQueueCapacity + 2 { | |
| 300 | + | err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}) | |
| 301 | + | if errors.Is(err, ErrBackpressure) { | |
| 302 | + | backpressure = err | |
| 303 | + | break | |
| 304 | + | } | |
| 305 | + | if err != nil { | |
| 306 | + | t.Fatalf("SendAsync unexpected error: %v", err) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | if !errors.Is(backpressure, ErrBackpressure) { | |
| 310 | + | t.Fatalf("SendAsync never reported bounded backpressure, got %v", backpressure) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | recvDone := make(chan error, 1) | |
| 314 | + | go func() { | |
| 315 | + | _, err := tr.Recv() | |
| 316 | + | recvDone <- err | |
| 317 | + | }() | |
| 318 | + | if err := tr.Close(); err != nil { | |
| 319 | + | t.Fatalf("Close: %v", err) | |
| 320 | + | } | |
| 321 | + | select { | |
| 322 | + | case err := <-recvDone: | |
| 323 | + | if err == nil { | |
| 324 | + | t.Fatal("Recv succeeded after Close") | |
| 325 | + | } | |
| 326 | + | case <-time.After(time.Second): | |
| 327 | + | t.Fatal("Close did not interrupt Recv") | |
| 328 | + | } | |
| 329 | + | select { | |
| 330 | + | case <-tr.(*unixTransport).writerDone: | |
| 331 | + | default: | |
| 332 | + | t.Fatal("Close returned before the IPC writer terminated") | |
| 333 | + | } | |
| 334 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "reflect" | 8 | "reflect" | ||
| 9 | "sync" | 9 | "sync" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | + | "time" | |||
| 11 | 12 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| @@ -231,3 +232,103 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) { | |||||
| 231 | t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err) | 232 | t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err) | ||
| 232 | } | 233 | } | ||
| 233 | } | 234 | } | ||
| 235 | + | ||||
| 236 | + | func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) { | |||
| 237 | + | serverConn, clientConn := net.Pipe() | |||
| 238 | + | defer func() { _ = serverConn.Close() }() | |||
| 239 | + | defer func() { _ = clientConn.Close() }() | |||
| 240 | + | ||||
| 241 | + | server := NewTransport(serverConn) | |||
| 242 | + | client := NewTransport(clientConn) | |||
| 243 | + | async, ok := server.(ports.AsyncTransport) | |||
| 244 | + | if !ok { | |||
| 245 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | welcome := ports.Frame{Type: ports.MsgWelcome, Payload: []byte("welcome")} | |||
| 249 | + | sent := make(chan error, 1) | |||
| 250 | + | go func() { sent <- server.Send(welcome) }() | |||
| 251 | + | got, err := client.Recv() | |||
| 252 | + | if err != nil { | |||
| 253 | + | t.Fatalf("Recv welcome: %v", err) | |||
| 254 | + | } | |||
| 255 | + | if !reflect.DeepEqual(got, welcome) { | |||
| 256 | + | t.Fatalf("welcome = %#v, want %#v", got, welcome) | |||
| 257 | + | } | |||
| 258 | + | if err := <-sent; err != nil { | |||
| 259 | + | t.Fatalf("Send welcome: %v", err) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | payload := []byte("first") | |||
| 263 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |||
| 264 | + | t.Fatalf("SendAsync first output: %v", err) | |||
| 265 | + | } | |||
| 266 | + | payload[0] = 'X' // async ownership must not retain caller memory. | |||
| 267 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}); err != nil { | |||
| 268 | + | t.Fatalf("SendAsync second output: %v", err) | |||
| 269 | + | } | |||
| 270 | + | for _, want := range []ports.Frame{ | |||
| 271 | + | {Type: ports.MsgOutput, Payload: []byte("first")}, | |||
| 272 | + | {Type: ports.MsgOutput, Payload: []byte("second")}, | |||
| 273 | + | } { | |||
| 274 | + | got, err := client.Recv() | |||
| 275 | + | if err != nil { | |||
| 276 | + | t.Fatalf("Recv output: %v", err) | |||
| 277 | + | } | |||
| 278 | + | if !reflect.DeepEqual(got, want) { | |||
| 279 | + | t.Fatalf("output = %#v, want %#v", got, want) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) { | |||
| 285 | + | serverConn, clientConn := net.Pipe() | |||
| 286 | + | defer func() { _ = clientConn.Close() }() | |||
| 287 | + | ||||
| 288 | + | tr := NewTransport(serverConn) | |||
| 289 | + | async, ok := tr.(ports.AsyncTransport) | |||
| 290 | + | if !ok { | |||
| 291 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // The first write blocks in net.Pipe because the peer never drains it. | |||
| 295 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("blocked")}); err != nil { | |||
| 296 | + | t.Fatalf("SendAsync blocked frame: %v", err) | |||
| 297 | + | } | |||
| 298 | + | var backpressure error | |||
| 299 | + | for range sendQueueCapacity + 2 { | |||
| 300 | + | err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}) | |||
| 301 | + | if errors.Is(err, ErrBackpressure) { | |||
| 302 | + | backpressure = err | |||
| 303 | + | break | |||
| 304 | + | } | |||
| 305 | + | if err != nil { | |||
| 306 | + | t.Fatalf("SendAsync unexpected error: %v", err) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | if !errors.Is(backpressure, ErrBackpressure) { | |||
| 310 | + | t.Fatalf("SendAsync never reported bounded backpressure, got %v", backpressure) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | recvDone := make(chan error, 1) | |||
| 314 | + | go func() { | |||
| 315 | + | _, err := tr.Recv() | |||
| 316 | + | recvDone <- err | |||
| 317 | + | }() | |||
| 318 | + | if err := tr.Close(); err != nil { | |||
| 319 | + | t.Fatalf("Close: %v", err) | |||
| 320 | + | } | |||
| 321 | + | select { | |||
| 322 | + | case err := <-recvDone: | |||
| 323 | + | if err == nil { | |||
| 324 | + | t.Fatal("Recv succeeded after Close") | |||
| 325 | + | } | |||
| 326 | + | case <-time.After(time.Second): | |||
| 327 | + | t.Fatal("Close did not interrupt Recv") | |||
| 328 | + | } | |||
| 329 | + | select { | |||
| 330 | + | case <-tr.(*unixTransport).writerDone: | |||
| 331 | + | default: | |||
| 332 | + | t.Fatal("Close returned before the IPC writer terminated") | |||
| 333 | + | } | |||
| 334 | + | } | |||
M internal/adapters/pty/pty_linux.go +24 -4
| @@ -97,6 +97,20 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||
| 97 | 97 | Setctty: true, | |
| 98 | 98 | Ctty: 0, | |
| 99 | 99 | } | |
| 100 | + | // CommandContext's default cancellation kills only the direct child. The | |
| 101 | + | // child leads a process group, so cancellation must target that group too. | |
| 102 | + | cmd.Cancel = func() error { | |
| 103 | + | if cmd.Process == nil { | |
| 104 | + | return os.ErrProcessDone | |
| 105 | + | } | |
| 106 | + | if err := signalProcessGroup(cmd.Process.Pid, syscall.SIGKILL); err != nil { | |
| 107 | + | if errors.Is(err, syscall.ESRCH) { | |
| 108 | + | return os.ErrProcessDone | |
| 109 | + | } | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | return nil | |
| 113 | + | } | |
| 100 | 114 | ||
| 101 | 115 | if err := cmd.Start(); err != nil { | |
| 102 | 116 | return nil, fmt.Errorf("pty: start %q: %w", command, err) | |
| @@ -194,9 +208,8 @@ func (p *linuxPTY) Close() error { | |||
| 194 | 208 | p.closeOnce.Do(func() { | |
| 195 | 209 | pid := p.cmd.Process.Pid | |
| 196 | 210 | ||
| 197 | - | // Negative pid targets the whole process group (the child is the group | |
| 198 | - | // leader thanks to Setsid). Ignore errors: the child may already be gone. | |
| 199 | - | _ = unix.Kill(-pid, unix.SIGHUP) | |
| 211 | + | // Ignore errors: the child may already be gone. | |
| 212 | + | _ = signalProcessGroup(pid, syscall.SIGHUP) | |
| 200 | 213 | ||
| 201 | 214 | // Reap the child (exit status is intentionally discarded; the port has no | |
| 202 | 215 | // Wait — "child gone" surfaces to callers as io.EOF on Read). | |
| @@ -209,7 +222,7 @@ func (p *linuxPTY) Close() error { | |||
| 209 | 222 | select { | |
| 210 | 223 | case <-reaped: | |
| 211 | 224 | case <-time.After(killGracePeriod): | |
| 212 | - | _ = unix.Kill(-pid, unix.SIGKILL) | |
| 225 | + | _ = signalProcessGroup(pid, syscall.SIGKILL) | |
| 213 | 226 | <-reaped | |
| 214 | 227 | } | |
| 215 | 228 | ||
| @@ -218,6 +231,13 @@ func (p *linuxPTY) Close() error { | |||
| 218 | 231 | return p.closeErr | |
| 219 | 232 | } | |
| 220 | 233 | ||
| 234 | + | // signalProcessGroup signals the process group led by pid. Open creates each | |
| 235 | + | // child as its own session and process-group leader, so a negative pid reaches | |
| 236 | + | // both the command and every descendant that remains in its process group. | |
| 237 | + | func signalProcessGroup(pid int, signal syscall.Signal) error { | |
| 238 | + | return unix.Kill(-pid, signal) | |
| 239 | + | } | |
| 240 | + | ||
| 221 | 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |
| 222 | 242 | func setWinsize(fd int, sz domain.Size) error { | |
| 223 | 243 | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | |
| @@ -97,6 +97,20 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||||
| 97 | Setctty: true, | 97 | Setctty: true, | ||
| 98 | Ctty: 0, | 98 | Ctty: 0, | ||
| 99 | } | 99 | } | ||
| 100 | + | // CommandContext's default cancellation kills only the direct child. The | |||
| 101 | + | // child leads a process group, so cancellation must target that group too. | |||
| 102 | + | cmd.Cancel = func() error { | |||
| 103 | + | if cmd.Process == nil { | |||
| 104 | + | return os.ErrProcessDone | |||
| 105 | + | } | |||
| 106 | + | if err := signalProcessGroup(cmd.Process.Pid, syscall.SIGKILL); err != nil { | |||
| 107 | + | if errors.Is(err, syscall.ESRCH) { | |||
| 108 | + | return os.ErrProcessDone | |||
| 109 | + | } | |||
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 100 | 114 | ||||
| 101 | if err := cmd.Start(); err != nil { | 115 | if err := cmd.Start(); err != nil { | ||
| 102 | return nil, fmt.Errorf("pty: start %q: %w", command, err) | 116 | return nil, fmt.Errorf("pty: start %q: %w", command, err) | ||
| @@ -194,9 +208,8 @@ func (p *linuxPTY) Close() error { | |||||
| 194 | p.closeOnce.Do(func() { | 208 | p.closeOnce.Do(func() { | ||
| 195 | pid := p.cmd.Process.Pid | 209 | pid := p.cmd.Process.Pid | ||
| 196 | 210 | ||||
| 197 | - | // Negative pid targets the whole process group (the child is the group | 211 | + | // Ignore errors: the child may already be gone. |
| 198 | - | // leader thanks to Setsid). Ignore errors: the child may already be gone. | 212 | + | _ = signalProcessGroup(pid, syscall.SIGHUP) |
| 199 | - | _ = unix.Kill(-pid, unix.SIGHUP) | |||
| 200 | 213 | ||||
| 201 | // Reap the child (exit status is intentionally discarded; the port has no | 214 | // Reap the child (exit status is intentionally discarded; the port has no | ||
| 202 | // Wait — "child gone" surfaces to callers as io.EOF on Read). | 215 | // Wait — "child gone" surfaces to callers as io.EOF on Read). | ||
| @@ -209,7 +222,7 @@ func (p *linuxPTY) Close() error { | |||||
| 209 | select { | 222 | select { | ||
| 210 | case <-reaped: | 223 | case <-reaped: | ||
| 211 | case <-time.After(killGracePeriod): | 224 | case <-time.After(killGracePeriod): | ||
| 212 | - | _ = unix.Kill(-pid, unix.SIGKILL) | 225 | + | _ = signalProcessGroup(pid, syscall.SIGKILL) |
| 213 | <-reaped | 226 | <-reaped | ||
| 214 | } | 227 | } | ||
| 215 | 228 | ||||
| @@ -218,6 +231,13 @@ func (p *linuxPTY) Close() error { | |||||
| 218 | return p.closeErr | 231 | return p.closeErr | ||
| 219 | } | 232 | } | ||
| 220 | 233 | ||||
| 234 | + | // signalProcessGroup signals the process group led by pid. Open creates each | |||
| 235 | + | // child as its own session and process-group leader, so a negative pid reaches | |||
| 236 | + | // both the command and every descendant that remains in its process group. | |||
| 237 | + | func signalProcessGroup(pid int, signal syscall.Signal) error { | |||
| 238 | + | return unix.Kill(-pid, signal) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 221 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | ||
| 222 | func setWinsize(fd int, sz domain.Size) error { | 242 | func setWinsize(fd int, sz domain.Size) error { | ||
| 223 | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | 243 | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | ||
M internal/adapters/pty/pty_linux_test.go +36 -0
| @@ -205,6 +205,42 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||
| 205 | 205 | }, 3*time.Second, 20*time.Millisecond) | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | + | func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) { | |
| 209 | + | if testing.Short() { | |
| 210 | + | t.Skip("skipping pty integration test in -short mode") | |
| 211 | + | } | |
| 212 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 213 | + | defer cancel() | |
| 214 | + | ||
| 215 | + | // The background sleep ignores SIGHUP so killing only the session leader | |
| 216 | + | // cannot accidentally pass via the terminal's hangup behavior. | |
| 217 | + | p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | t.Cleanup(func() { _ = p.Close() }) | |
| 220 | + | ||
| 221 | + | buf := make([]byte, 64) | |
| 222 | + | n, err := p.Read(buf) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | descendantPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | ||
| 227 | + | cancel() | |
| 228 | + | ||
| 229 | + | // The direct shell may remain as a zombie until Close reaps it; a zombie has | |
| 230 | + | // exited, so it and the background sleep must not remain running. | |
| 231 | + | require.Eventually(t, func() bool { | |
| 232 | + | return processTerminated(p.Pid()) && processTerminated(descendantPID) | |
| 233 | + | }, 3*time.Second, 20*time.Millisecond) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func processTerminated(pid int) bool { | |
| 237 | + | if errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) { | |
| 238 | + | return true | |
| 239 | + | } | |
| 240 | + | status, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status") | |
| 241 | + | return err != nil || strings.Contains(string(status), "State:\tZ") | |
| 242 | + | } | |
| 243 | + | ||
| 208 | 244 | func TestResize_DeliversSIGWINCH(t *testing.T) { | |
| 209 | 245 | if testing.Short() { | |
| 210 | 246 | t.Skip("skipping pty integration test in -short mode") | |
| @@ -205,6 +205,42 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||||
| 205 | }, 3*time.Second, 20*time.Millisecond) | 205 | }, 3*time.Second, 20*time.Millisecond) | ||
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| 208 | + | func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) { | |||
| 209 | + | if testing.Short() { | |||
| 210 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 211 | + | } | |||
| 212 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 213 | + | defer cancel() | |||
| 214 | + | ||||
| 215 | + | // The background sleep ignores SIGHUP so killing only the session leader | |||
| 216 | + | // cannot accidentally pass via the terminal's hangup behavior. | |||
| 217 | + | p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 220 | + | ||||
| 221 | + | buf := make([]byte, 64) | |||
| 222 | + | n, err := p.Read(buf) | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | descendantPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))) | |||
| 225 | + | require.NoError(t, err) | |||
| 226 | + | ||||
| 227 | + | cancel() | |||
| 228 | + | ||||
| 229 | + | // The direct shell may remain as a zombie until Close reaps it; a zombie has | |||
| 230 | + | // exited, so it and the background sleep must not remain running. | |||
| 231 | + | require.Eventually(t, func() bool { | |||
| 232 | + | return processTerminated(p.Pid()) && processTerminated(descendantPID) | |||
| 233 | + | }, 3*time.Second, 20*time.Millisecond) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func processTerminated(pid int) bool { | |||
| 237 | + | if errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) { | |||
| 238 | + | return true | |||
| 239 | + | } | |||
| 240 | + | status, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status") | |||
| 241 | + | return err != nil || strings.Contains(string(status), "State:\tZ") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 208 | func TestResize_DeliversSIGWINCH(t *testing.T) { | 244 | func TestResize_DeliversSIGWINCH(t *testing.T) { | ||
| 209 | if testing.Short() { | 245 | if testing.Short() { | ||
| 210 | t.Skip("skipping pty integration test in -short mode") | 246 | t.Skip("skipping pty integration test in -short mode") | ||
M internal/adapters/sshstdio/transport.go +3 -3
| @@ -44,7 +44,7 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 44 | 44 | ||
| 45 | 45 | // NewTransport wraps separate reader/writer streams as a framed Transport. | |
| 46 | 46 | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport { | |
| 47 | - | t := newTransport(r, w, closeFn, nil).(*transport) | |
| 47 | + | t := newTransport(r, w, closeFn, nil) | |
| 48 | 48 | for _, opt := range opts { | |
| 49 | 49 | if opt != nil { | |
| 50 | 50 | opt(t) | |
| @@ -53,7 +53,7 @@ func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) p | |||
| 53 | 53 | return t | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | - | func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport { | |
| 56 | + | func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) *transport { | |
| 57 | 57 | if closeFn == nil { | |
| 58 | 58 | closeFn = func() error { return nil } | |
| 59 | 59 | } | |
| @@ -361,7 +361,7 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger, | |||
| 361 | 361 | } | |
| 362 | 362 | ||
| 363 | 363 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 364 | - | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr).(*transport) | |
| 364 | + | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) | |
| 365 | 365 | for _, opt := range opts { | |
| 366 | 366 | if opt != nil { | |
| 367 | 367 | opt(transport) | |
| @@ -44,7 +44,7 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||||
| 44 | 44 | ||||
| 45 | // NewTransport wraps separate reader/writer streams as a framed Transport. | 45 | // NewTransport wraps separate reader/writer streams as a framed Transport. | ||
| 46 | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport { | 46 | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport { | ||
| 47 | - | t := newTransport(r, w, closeFn, nil).(*transport) | 47 | + | t := newTransport(r, w, closeFn, nil) |
| 48 | for _, opt := range opts { | 48 | for _, opt := range opts { | ||
| 49 | if opt != nil { | 49 | if opt != nil { | ||
| 50 | opt(t) | 50 | opt(t) | ||
| @@ -53,7 +53,7 @@ func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) p | |||||
| 53 | return t | 53 | return t | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport { | 56 | + | func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) *transport { |
| 57 | if closeFn == nil { | 57 | if closeFn == nil { | ||
| 58 | closeFn = func() error { return nil } | 58 | closeFn = func() error { return nil } | ||
| 59 | } | 59 | } | ||
| @@ -361,7 +361,7 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger, | |||||
| 361 | } | 361 | } | ||
| 362 | 362 | ||||
| 363 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | 363 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | ||
| 364 | - | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr).(*transport) | 364 | + | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) |
| 365 | for _, opt := range opts { | 365 | for _, opt := range opts { | ||
| 366 | if opt != nil { | 366 | if opt != nil { | ||
| 367 | opt(transport) | 367 | opt(transport) | ||
M internal/app/run.go +11 -4
| @@ -243,8 +243,15 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 243 | 243 | // performanceTrace creates one serialized timestamp owner for this process. | |
| 244 | 244 | // An empty trace environment leaves all production behavior and wire bytes | |
| 245 | 245 | // unchanged. | |
| 246 | - | // newPerformanceTrace is a test seam around composition-root trace setup. | |
| 247 | - | var newPerformanceTrace = performanceTrace | |
| 246 | + | // Composition-root factory seams keep observer propagation testable without | |
| 247 | + | // opening real transports. | |
| 248 | + | var ( | |
| 249 | + | newPerformanceTrace = performanceTrace | |
| 250 | + | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |
| 251 | + | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | |
| 252 | + | } | |
| 253 | + | runClientWithRuntimeObserver = client.RunWithRuntimeObserver | |
| 254 | + | ) | |
| 248 | 255 | ||
| 249 | 256 | func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |
| 250 | 257 | return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver) | |
| @@ -475,10 +482,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||
| 475 | 482 | localDialer: func() ports.Dialer { | |
| 476 | 483 | return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} | |
| 477 | 484 | }, | |
| 478 | - | remoteDialerFactory: remoteadapter.NewDialerFactoryWithRuntimeObserver(observer), | |
| 485 | + | remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer), | |
| 479 | 486 | selectedRemoteTransport: os.Getenv(envRemoteTransport), | |
| 480 | 487 | runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error { | |
| 481 | - | return client.RunWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer) | |
| 488 | + | return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer) | |
| 482 | 489 | }, | |
| 483 | 490 | createDetached: createDetachedLocalSession, | |
| 484 | 491 | clipboard: clipboard.New(), | |
| @@ -243,8 +243,15 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 243 | // performanceTrace creates one serialized timestamp owner for this process. | 243 | // performanceTrace creates one serialized timestamp owner for this process. | ||
| 244 | // An empty trace environment leaves all production behavior and wire bytes | 244 | // An empty trace environment leaves all production behavior and wire bytes | ||
| 245 | // unchanged. | 245 | // unchanged. | ||
| 246 | - | // newPerformanceTrace is a test seam around composition-root trace setup. | 246 | + | // Composition-root factory seams keep observer propagation testable without |
| 247 | - | var newPerformanceTrace = performanceTrace | 247 | + | // opening real transports. |
| 248 | + | var ( | |||
| 249 | + | newPerformanceTrace = performanceTrace | |||
| 250 | + | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |||
| 251 | + | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | |||
| 252 | + | } | |||
| 253 | + | runClientWithRuntimeObserver = client.RunWithRuntimeObserver | |||
| 254 | + | ) | |||
| 248 | 255 | ||||
| 249 | func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | 256 | func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | ||
| 250 | return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver) | 257 | return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver) | ||
| @@ -475,10 +482,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||||
| 475 | localDialer: func() ports.Dialer { | 482 | localDialer: func() ports.Dialer { | ||
| 476 | return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} | 483 | return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} | ||
| 477 | }, | 484 | }, | ||
| 478 | - | remoteDialerFactory: remoteadapter.NewDialerFactoryWithRuntimeObserver(observer), | 485 | + | remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer), |
| 479 | selectedRemoteTransport: os.Getenv(envRemoteTransport), | 486 | selectedRemoteTransport: os.Getenv(envRemoteTransport), | ||
| 480 | runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error { | 487 | runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error { | ||
| 481 | - | return client.RunWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer) | 488 | + | return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer) |
| 482 | }, | 489 | }, | ||
| 483 | createDetached: createDetachedLocalSession, | 490 | createDetached: createDetachedLocalSession, | ||
| 484 | clipboard: clipboard.New(), | 491 | clipboard: clipboard.New(), | ||
M internal/app/transport_observability_test.go +91 -17
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | + | "log/slog" | |
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 11 | 12 | "strconv" | |
| @@ -15,6 +16,7 @@ import ( | |||
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | |
| @@ -46,7 +48,11 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | |||
| 46 | 48 | if err != nil { | |
| 47 | 49 | t.Fatal(err) | |
| 48 | 50 | } | |
| 49 | - | defer f.Close() | |
| 51 | + | defer func() { | |
| 52 | + | if err := f.Close(); err != nil { | |
| 53 | + | t.Errorf("close trace file: %v", err) | |
| 54 | + | } | |
| 55 | + | }() | |
| 50 | 56 | var mark struct { | |
| 51 | 57 | ProcessID string `json:"process_id"` | |
| 52 | 58 | Scenario string `json:"scenario"` | |
| @@ -63,6 +69,48 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | |||
| 63 | 69 | } | |
| 64 | 70 | } | |
| 65 | 71 | ||
| 72 | + | func TestPerformanceTraceWithFactoriesCreatesOneConfiguredObserver(t *testing.T) { | |
| 73 | + | t.Setenv("VEV_PERF_TRACE", "trace.jsonl") | |
| 74 | + | t.Setenv("VEV_PERF_PROCESS_ID", "client-01") | |
| 75 | + | t.Setenv("VEV_PERF_SCENARIO", "lossy-remote") | |
| 76 | + | t.Setenv("VEV_PERF_RUN", "7") | |
| 77 | + | ||
| 78 | + | clock := portsmocks.NewMockClock(t) | |
| 79 | + | sink := portsmocks.NewMockRuntimeObserver(t) | |
| 80 | + | sink.EXPECT().ObserveRuntime(mock.MatchedBy(func(mark ports.RuntimeMark) bool { | |
| 81 | + | return mark.Component == "client" && mark.Scenario == "lossy-remote" && mark.Run == 7 && | |
| 82 | + | mark.Sequence != 0 && mark.RequestID != 0 && mark.Epoch != 0 && mark.Kind == ports.RuntimeEmitEnd | |
| 83 | + | })).Once() | |
| 84 | + | ||
| 85 | + | var sinkCalls, correlationCalls int | |
| 86 | + | observer, closer, err := performanceTraceWithFactories(clock, | |
| 87 | + | func(path string, gotClock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) { | |
| 88 | + | sinkCalls++ | |
| 89 | + | if path != "trace.jsonl" || gotClock != clock || processID != "client-01" { | |
| 90 | + | t.Fatalf("trace sink config = (%q, %v, %q)", path, gotClock, processID) | |
| 91 | + | } | |
| 92 | + | return sink, errorCloser{}, nil | |
| 93 | + | }, | |
| 94 | + | func(got ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) { | |
| 95 | + | correlationCalls++ | |
| 96 | + | if got != sink || inputs != (ports.RuntimeCorrelationInputs{Scenario: "lossy-remote", Run: 7}) { | |
| 97 | + | t.Fatalf("trace correlation config = (%v, %+v)", got, inputs) | |
| 98 | + | } | |
| 99 | + | return ports.NewRuntimeCorrelationObserver(got, inputs) | |
| 100 | + | }, | |
| 101 | + | ) | |
| 102 | + | if err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | if sinkCalls != 1 || correlationCalls != 1 || observer == nil || closer == nil { | |
| 106 | + | t.Fatalf("trace setup = sink calls %d, correlation calls %d, observer %v, closer %v; want one configured observer", sinkCalls, correlationCalls, observer, closer) | |
| 107 | + | } | |
| 108 | + | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitEnd, 0, true)) | |
| 109 | + | if err := closer.Close(); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 66 | 114 | func TestPerformanceTraceSetupRollbackJoinsCloseError(t *testing.T) { | |
| 67 | 115 | setupErr := errors.New("correlation setup failed") | |
| 68 | 116 | closeErr := errors.New("rollback close failed") | |
| @@ -154,23 +202,49 @@ type runtimeObserverFunc func(ports.RuntimeMark) | |||
| 154 | 202 | ||
| 155 | 203 | func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) } | |
| 156 | 204 | ||
| 157 | - | func TestPerformanceTraceAppWiresOneObserverPerProcess(t *testing.T) { | |
| 158 | - | source, err := os.ReadFile("run.go") | |
| 159 | - | if err != nil { | |
| 160 | - | t.Fatalf("ReadFile(run.go): %v", err) | |
| 161 | - | } | |
| 162 | - | text := string(source) | |
| 163 | - | for _, required := range []string{ | |
| 164 | - | "VEV_PERF_TRACE", | |
| 165 | - | "VEV_PERF_PROCESS_ID", | |
| 166 | - | "observability.NewJSONL", | |
| 167 | - | "WithRuntimeObserver", | |
| 168 | - | } { | |
| 169 | - | if !strings.Contains(text, required) { | |
| 170 | - | t.Errorf("runtime trace wiring missing %q", required) | |
| 205 | + | func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) { | |
| 206 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 207 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 208 | + | t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio)) | |
| 209 | + | ||
| 210 | + | observer := ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1) | |
| 211 | + | originalTrace := newPerformanceTrace | |
| 212 | + | traceCalls := 0 | |
| 213 | + | newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |
| 214 | + | traceCalls++ | |
| 215 | + | return observer, &runtimeTraceCloser{reporter: observer}, nil | |
| 216 | + | } | |
| 217 | + | t.Cleanup(func() { newPerformanceTrace = originalTrace }) | |
| 218 | + | ||
| 219 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 220 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 221 | + | originalFactory := newRemoteDialerFactoryWithRuntimeObserver | |
| 222 | + | factoryCalls := 0 | |
| 223 | + | newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |
| 224 | + | factoryCalls++ | |
| 225 | + | if got != observer { | |
| 226 | + | t.Fatalf("remote transport observer = %v, want process observer %v", got, observer) | |
| 171 | 227 | } | |
| 228 | + | return factory | |
| 229 | + | } | |
| 230 | + | t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory }) | |
| 231 | + | ||
| 232 | + | originalRunClient := runClientWithRuntimeObserver | |
| 233 | + | runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error { | |
| 234 | + | if got != observer { | |
| 235 | + | t.Fatalf("client transport observer = %v, want process observer %v", got, observer) | |
| 236 | + | } | |
| 237 | + | if gotDialer != (namedDialer{name: "remote"}) { | |
| 238 | + | t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer) | |
| 239 | + | } | |
| 240 | + | return nil | |
| 241 | + | } | |
| 242 | + | t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient }) | |
| 243 | + | ||
| 244 | + | if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil { | |
| 245 | + | t.Fatal(err) | |
| 172 | 246 | } | |
| 173 | - | if strings.Contains(text, "WithRuntimeObserver(clock") || strings.Contains(text, "WithRuntimeObserver(clk") { | |
| 174 | - | t.Error("consumer observer configuration must not accept a clock") | |
| 247 | + | if traceCalls != 1 || factoryCalls != 1 { | |
| 248 | + | t.Fatalf("trace/factory calls = %d/%d, want exactly one observer and one transport factory", traceCalls, factoryCalls) | |
| 175 | 249 | } | |
| 176 | 250 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | + | "log/slog" | |||
| 9 | "os" | 10 | "os" | ||
| 10 | "path/filepath" | 11 | "path/filepath" | ||
| 11 | "strconv" | 12 | "strconv" | ||
| @@ -15,6 +16,7 @@ import ( | |||||
| 15 | 16 | ||||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 20 | func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | 22 | func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | ||
| @@ -46,7 +48,11 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | |||||
| 46 | if err != nil { | 48 | if err != nil { | ||
| 47 | t.Fatal(err) | 49 | t.Fatal(err) | ||
| 48 | } | 50 | } | ||
| 49 | - | defer f.Close() | 51 | + | defer func() { |
| 52 | + | if err := f.Close(); err != nil { | |||
| 53 | + | t.Errorf("close trace file: %v", err) | |||
| 54 | + | } | |||
| 55 | + | }() | |||
| 50 | var mark struct { | 56 | var mark struct { | ||
| 51 | ProcessID string `json:"process_id"` | 57 | ProcessID string `json:"process_id"` | ||
| 52 | Scenario string `json:"scenario"` | 58 | Scenario string `json:"scenario"` | ||
| @@ -63,6 +69,48 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | |||||
| 63 | } | 69 | } | ||
| 64 | } | 70 | } | ||
| 65 | 71 | ||||
| 72 | + | func TestPerformanceTraceWithFactoriesCreatesOneConfiguredObserver(t *testing.T) { | |||
| 73 | + | t.Setenv("VEV_PERF_TRACE", "trace.jsonl") | |||
| 74 | + | t.Setenv("VEV_PERF_PROCESS_ID", "client-01") | |||
| 75 | + | t.Setenv("VEV_PERF_SCENARIO", "lossy-remote") | |||
| 76 | + | t.Setenv("VEV_PERF_RUN", "7") | |||
| 77 | + | ||||
| 78 | + | clock := portsmocks.NewMockClock(t) | |||
| 79 | + | sink := portsmocks.NewMockRuntimeObserver(t) | |||
| 80 | + | sink.EXPECT().ObserveRuntime(mock.MatchedBy(func(mark ports.RuntimeMark) bool { | |||
| 81 | + | return mark.Component == "client" && mark.Scenario == "lossy-remote" && mark.Run == 7 && | |||
| 82 | + | mark.Sequence != 0 && mark.RequestID != 0 && mark.Epoch != 0 && mark.Kind == ports.RuntimeEmitEnd | |||
| 83 | + | })).Once() | |||
| 84 | + | ||||
| 85 | + | var sinkCalls, correlationCalls int | |||
| 86 | + | observer, closer, err := performanceTraceWithFactories(clock, | |||
| 87 | + | func(path string, gotClock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) { | |||
| 88 | + | sinkCalls++ | |||
| 89 | + | if path != "trace.jsonl" || gotClock != clock || processID != "client-01" { | |||
| 90 | + | t.Fatalf("trace sink config = (%q, %v, %q)", path, gotClock, processID) | |||
| 91 | + | } | |||
| 92 | + | return sink, errorCloser{}, nil | |||
| 93 | + | }, | |||
| 94 | + | func(got ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) { | |||
| 95 | + | correlationCalls++ | |||
| 96 | + | if got != sink || inputs != (ports.RuntimeCorrelationInputs{Scenario: "lossy-remote", Run: 7}) { | |||
| 97 | + | t.Fatalf("trace correlation config = (%v, %+v)", got, inputs) | |||
| 98 | + | } | |||
| 99 | + | return ports.NewRuntimeCorrelationObserver(got, inputs) | |||
| 100 | + | }, | |||
| 101 | + | ) | |||
| 102 | + | if err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | if sinkCalls != 1 || correlationCalls != 1 || observer == nil || closer == nil { | |||
| 106 | + | t.Fatalf("trace setup = sink calls %d, correlation calls %d, observer %v, closer %v; want one configured observer", sinkCalls, correlationCalls, observer, closer) | |||
| 107 | + | } | |||
| 108 | + | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitEnd, 0, true)) | |||
| 109 | + | if err := closer.Close(); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 66 | func TestPerformanceTraceSetupRollbackJoinsCloseError(t *testing.T) { | 114 | func TestPerformanceTraceSetupRollbackJoinsCloseError(t *testing.T) { | ||
| 67 | setupErr := errors.New("correlation setup failed") | 115 | setupErr := errors.New("correlation setup failed") | ||
| 68 | closeErr := errors.New("rollback close failed") | 116 | closeErr := errors.New("rollback close failed") | ||
| @@ -154,23 +202,49 @@ type runtimeObserverFunc func(ports.RuntimeMark) | |||||
| 154 | 202 | ||||
| 155 | func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) } | 203 | func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) } | ||
| 156 | 204 | ||||
| 157 | - | func TestPerformanceTraceAppWiresOneObserverPerProcess(t *testing.T) { | 205 | + | func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) { |
| 158 | - | source, err := os.ReadFile("run.go") | 206 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) |
| 159 | - | if err != nil { | 207 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) |
| 160 | - | t.Fatalf("ReadFile(run.go): %v", err) | 208 | + | t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio)) |
| 161 | - | } | 209 | + | |
| 162 | - | text := string(source) | 210 | + | observer := ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1) |
| 163 | - | for _, required := range []string{ | 211 | + | originalTrace := newPerformanceTrace |
| 164 | - | "VEV_PERF_TRACE", | 212 | + | traceCalls := 0 |
| 165 | - | "VEV_PERF_PROCESS_ID", | 213 | + | newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { |
| 166 | - | "observability.NewJSONL", | 214 | + | traceCalls++ |
| 167 | - | "WithRuntimeObserver", | 215 | + | return observer, &runtimeTraceCloser{reporter: observer}, nil |
| 168 | - | } { | 216 | + | } |
| 169 | - | if !strings.Contains(text, required) { | 217 | + | t.Cleanup(func() { newPerformanceTrace = originalTrace }) |
| 170 | - | t.Errorf("runtime trace wiring missing %q", required) | 218 | + | |
| 219 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 220 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |||
| 221 | + | originalFactory := newRemoteDialerFactoryWithRuntimeObserver | |||
| 222 | + | factoryCalls := 0 | |||
| 223 | + | newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |||
| 224 | + | factoryCalls++ | |||
| 225 | + | if got != observer { | |||
| 226 | + | t.Fatalf("remote transport observer = %v, want process observer %v", got, observer) | |||
| 171 | } | 227 | } | ||
| 228 | + | return factory | |||
| 229 | + | } | |||
| 230 | + | t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory }) | |||
| 231 | + | ||||
| 232 | + | originalRunClient := runClientWithRuntimeObserver | |||
| 233 | + | runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error { | |||
| 234 | + | if got != observer { | |||
| 235 | + | t.Fatalf("client transport observer = %v, want process observer %v", got, observer) | |||
| 236 | + | } | |||
| 237 | + | if gotDialer != (namedDialer{name: "remote"}) { | |||
| 238 | + | t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer) | |||
| 239 | + | } | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient }) | |||
| 243 | + | ||||
| 244 | + | if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 172 | } | 246 | } | ||
| 173 | - | if strings.Contains(text, "WithRuntimeObserver(clock") || strings.Contains(text, "WithRuntimeObserver(clk") { | 247 | + | if traceCalls != 1 || factoryCalls != 1 { |
| 174 | - | t.Error("consumer observer configuration must not accept a clock") | 248 | + | t.Fatalf("trace/factory calls = %d/%d, want exactly one observer and one transport factory", traceCalls, factoryCalls) |
| 175 | } | 249 | } | ||
| 176 | } | 250 | } | ||
M internal/ports/ports.go +3 -1
| @@ -40,7 +40,9 @@ type Terminal interface { | |||
| 40 | 40 | Flush() error | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | // Transport is a framed message channel over a single connection. | |
| 43 | + | // Transport is a framed message channel over a single connection. Close must | |
| 44 | + | // be safe to call concurrently with Send and Recv, and must unblock active | |
| 45 | + | // Send and Recv calls. | |
| 44 | 46 | type Transport interface { | |
| 45 | 47 | Send(Frame) error | |
| 46 | 48 | Recv() (Frame, error) // blocking; io.EOF on close | |
| @@ -40,7 +40,9 @@ type Terminal interface { | |||||
| 40 | Flush() error | 40 | Flush() error | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | - | // Transport is a framed message channel over a single connection. | 43 | + | // Transport is a framed message channel over a single connection. Close must |
| 44 | + | // be safe to call concurrently with Send and Recv, and must unblock active | |||
| 45 | + | // Send and Recv calls. | |||
| 44 | type Transport interface { | 46 | type Transport interface { | ||
| 45 | Send(Frame) error | 47 | Send(Frame) error | ||
| 46 | Recv() (Frame, error) // blocking; io.EOF on close | 48 | Recv() (Frame, error) // blocking; io.EOF on close | ||
M internal/usecase/client/client.go +64 -9
| @@ -102,6 +102,10 @@ const stdinBufSize = 4096 | |||
| 102 | 102 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | |
| 103 | 103 | const sendQueueDepth = 64 | |
| 104 | 104 | ||
| 105 | + | // preWelcomeTimeout bounds each blocking operation before the daemon has | |
| 106 | + | // accepted the attach. Mosh uses the same 15-second startup budget. | |
| 107 | + | const preWelcomeTimeout = 15 * time.Second | |
| 108 | + | ||
| 105 | 109 | var reconnectSleep = sleepReconnect | |
| 106 | 110 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | |
| 107 | 111 | ||
| @@ -270,7 +274,9 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 270 | 274 | if result.welcomed { | |
| 271 | 275 | backoff = defaultReconnectBackoff.initial | |
| 272 | 276 | } | |
| 273 | - | _ = transport.Close() | |
| 277 | + | if !result.transportClosed { | |
| 278 | + | _ = transport.Close() | |
| 279 | + | } | |
| 274 | 280 | if result.resumeToken != 0 { | |
| 275 | 281 | resumeToken = result.resumeToken | |
| 276 | 282 | } | |
| @@ -350,10 +356,52 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 350 | 356 | } | |
| 351 | 357 | ||
| 352 | 358 | type attachResult struct { | |
| 353 | - | resumeToken uint64 | |
| 354 | - | sessionName string | |
| 355 | - | welcomed bool | |
| 356 | - | err error | |
| 359 | + | resumeToken uint64 | |
| 360 | + | sessionName string | |
| 361 | + | welcomed bool | |
| 362 | + | transportClosed bool | |
| 363 | + | err error | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // boundedPreWelcome runs one blocking handshake operation. Transport methods | |
| 367 | + | // do not accept a context, so cancellation and expiry close this attempt's | |
| 368 | + | // transport to interrupt the call, then wait for its goroutine to exit. | |
| 369 | + | func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Transport, operation func() error) (error, bool) { | |
| 370 | + | if err := ctx.Err(); err != nil { | |
| 371 | + | _ = transport.Close() | |
| 372 | + | return err, true | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | timer := clk.NewTimer(preWelcomeTimeout) | |
| 376 | + | defer timer.Stop() | |
| 377 | + | completed := make(chan error, 1) | |
| 378 | + | go func() { completed <- operation() }() | |
| 379 | + | ||
| 380 | + | select { | |
| 381 | + | case err := <-completed: | |
| 382 | + | // Prefer a cancellation/expiry observed concurrently with completion. | |
| 383 | + | // That event owns the transport lifetime even though the operation has | |
| 384 | + | // already returned by the time we observe it here. | |
| 385 | + | if ctx.Err() != nil { | |
| 386 | + | _ = transport.Close() | |
| 387 | + | return ctx.Err(), true | |
| 388 | + | } | |
| 389 | + | select { | |
| 390 | + | case <-timer.C(): | |
| 391 | + | _ = transport.Close() | |
| 392 | + | return context.DeadlineExceeded, true | |
| 393 | + | default: | |
| 394 | + | return err, false | |
| 395 | + | } | |
| 396 | + | case <-ctx.Done(): | |
| 397 | + | _ = transport.Close() | |
| 398 | + | <-completed | |
| 399 | + | return ctx.Err(), true | |
| 400 | + | case <-timer.C(): | |
| 401 | + | _ = transport.Close() | |
| 402 | + | <-completed | |
| 403 | + | return context.DeadlineExceeded, true | |
| 404 | + | } | |
| 357 | 405 | } | |
| 358 | 406 | ||
| 359 | 407 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | |
| @@ -403,15 +451,22 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 403 | 451 | TrueColor: trueColor, | |
| 404 | 452 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 405 | 453 | } | |
| 406 | - | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 407 | - | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | |
| 454 | + | if err, closed := boundedPreWelcome(ctx, clk, transport, func() error { | |
| 455 | + | return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 456 | + | }); err != nil { | |
| 457 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)} | |
| 408 | 458 | } | |
| 409 | 459 | ms.helloSent = true | |
| 410 | 460 | ||
| 411 | 461 | // 2. Await Welcome or a typed rejection. | |
| 412 | - | reply, err := transport.Recv() | |
| 462 | + | var reply ports.Frame | |
| 463 | + | err, closed := boundedPreWelcome(ctx, clk, transport, func() error { | |
| 464 | + | var recvErr error | |
| 465 | + | reply, recvErr = transport.Recv() | |
| 466 | + | return recvErr | |
| 467 | + | }) | |
| 413 | 468 | if err != nil { | |
| 414 | - | return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)} | |
| 469 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: awaiting welcome: %w", err)} | |
| 415 | 470 | } | |
| 416 | 471 | result := func(welcomed bool, err error) attachResult { | |
| 417 | 472 | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | |
| @@ -102,6 +102,10 @@ const stdinBufSize = 4096 | |||||
| 102 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | 102 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | ||
| 103 | const sendQueueDepth = 64 | 103 | const sendQueueDepth = 64 | ||
| 104 | 104 | ||||
| 105 | + | // preWelcomeTimeout bounds each blocking operation before the daemon has | |||
| 106 | + | // accepted the attach. Mosh uses the same 15-second startup budget. | |||
| 107 | + | const preWelcomeTimeout = 15 * time.Second | |||
| 108 | + | ||||
| 105 | var reconnectSleep = sleepReconnect | 109 | var reconnectSleep = sleepReconnect | ||
| 106 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | 110 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | ||
| 107 | 111 | ||||
| @@ -270,7 +274,9 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 270 | if result.welcomed { | 274 | if result.welcomed { | ||
| 271 | backoff = defaultReconnectBackoff.initial | 275 | backoff = defaultReconnectBackoff.initial | ||
| 272 | } | 276 | } | ||
| 273 | - | _ = transport.Close() | 277 | + | if !result.transportClosed { |
| 278 | + | _ = transport.Close() | |||
| 279 | + | } | |||
| 274 | if result.resumeToken != 0 { | 280 | if result.resumeToken != 0 { | ||
| 275 | resumeToken = result.resumeToken | 281 | resumeToken = result.resumeToken | ||
| 276 | } | 282 | } | ||
| @@ -350,10 +356,52 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |||||
| 350 | } | 356 | } | ||
| 351 | 357 | ||||
| 352 | type attachResult struct { | 358 | type attachResult struct { | ||
| 353 | - | resumeToken uint64 | 359 | + | resumeToken uint64 |
| 354 | - | sessionName string | 360 | + | sessionName string |
| 355 | - | welcomed bool | 361 | + | welcomed bool |
| 356 | - | err error | 362 | + | transportClosed bool |
| 363 | + | err error | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | // boundedPreWelcome runs one blocking handshake operation. Transport methods | |||
| 367 | + | // do not accept a context, so cancellation and expiry close this attempt's | |||
| 368 | + | // transport to interrupt the call, then wait for its goroutine to exit. | |||
| 369 | + | func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Transport, operation func() error) (error, bool) { | |||
| 370 | + | if err := ctx.Err(); err != nil { | |||
| 371 | + | _ = transport.Close() | |||
| 372 | + | return err, true | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | timer := clk.NewTimer(preWelcomeTimeout) | |||
| 376 | + | defer timer.Stop() | |||
| 377 | + | completed := make(chan error, 1) | |||
| 378 | + | go func() { completed <- operation() }() | |||
| 379 | + | ||||
| 380 | + | select { | |||
| 381 | + | case err := <-completed: | |||
| 382 | + | // Prefer a cancellation/expiry observed concurrently with completion. | |||
| 383 | + | // That event owns the transport lifetime even though the operation has | |||
| 384 | + | // already returned by the time we observe it here. | |||
| 385 | + | if ctx.Err() != nil { | |||
| 386 | + | _ = transport.Close() | |||
| 387 | + | return ctx.Err(), true | |||
| 388 | + | } | |||
| 389 | + | select { | |||
| 390 | + | case <-timer.C(): | |||
| 391 | + | _ = transport.Close() | |||
| 392 | + | return context.DeadlineExceeded, true | |||
| 393 | + | default: | |||
| 394 | + | return err, false | |||
| 395 | + | } | |||
| 396 | + | case <-ctx.Done(): | |||
| 397 | + | _ = transport.Close() | |||
| 398 | + | <-completed | |||
| 399 | + | return ctx.Err(), true | |||
| 400 | + | case <-timer.C(): | |||
| 401 | + | _ = transport.Close() | |||
| 402 | + | <-completed | |||
| 403 | + | return context.DeadlineExceeded, true | |||
| 404 | + | } | |||
| 357 | } | 405 | } | ||
| 358 | 406 | ||||
| 359 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | 407 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | ||
| @@ -403,15 +451,22 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 403 | TrueColor: trueColor, | 451 | TrueColor: trueColor, | ||
| 404 | MaxOutputInFlight: requestedOutputWindow(transport), | 452 | MaxOutputInFlight: requestedOutputWindow(transport), | ||
| 405 | } | 453 | } | ||
| 406 | - | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | 454 | + | if err, closed := boundedPreWelcome(ctx, clk, transport, func() error { |
| 407 | - | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | 455 | + | return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) |
| 456 | + | }); err != nil { | |||
| 457 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)} | |||
| 408 | } | 458 | } | ||
| 409 | ms.helloSent = true | 459 | ms.helloSent = true | ||
| 410 | 460 | ||||
| 411 | // 2. Await Welcome or a typed rejection. | 461 | // 2. Await Welcome or a typed rejection. | ||
| 412 | - | reply, err := transport.Recv() | 462 | + | var reply ports.Frame |
| 463 | + | err, closed := boundedPreWelcome(ctx, clk, transport, func() error { | |||
| 464 | + | var recvErr error | |||
| 465 | + | reply, recvErr = transport.Recv() | |||
| 466 | + | return recvErr | |||
| 467 | + | }) | |||
| 413 | if err != nil { | 468 | if err != nil { | ||
| 414 | - | return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)} | 469 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: awaiting welcome: %w", err)} |
| 415 | } | 470 | } | ||
| 416 | result := func(welcomed bool, err error) attachResult { | 471 | result := func(welcomed bool, err error) attachResult { | ||
| 417 | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | 472 | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | ||
M internal/usecase/client/client_test.go +129 -0
| @@ -155,6 +155,135 @@ func isType(typ ports.MsgType) any { | |||
| 155 | 155 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | + | // handshakeClock exposes its timer so the pre-Welcome deadline can be tested | |
| 159 | + | // without waiting on wall time. | |
| 160 | + | type handshakeClock struct { | |
| 161 | + | mu sync.Mutex | |
| 162 | + | duration time.Duration | |
| 163 | + | created chan *handshakeTimer | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | type handshakeTimer struct{ ch chan time.Time } | |
| 167 | + | ||
| 168 | + | func newHandshakeClock() *handshakeClock { | |
| 169 | + | return &handshakeClock{created: make(chan *handshakeTimer, 2)} | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (c *handshakeClock) Now() time.Time { return time.Time{} } | |
| 173 | + | ||
| 174 | + | func (c *handshakeClock) NewTimer(d time.Duration) ports.Timer { | |
| 175 | + | c.mu.Lock() | |
| 176 | + | c.duration = d | |
| 177 | + | c.mu.Unlock() | |
| 178 | + | t := &handshakeTimer{ch: make(chan time.Time, 1)} | |
| 179 | + | c.created <- t | |
| 180 | + | return t | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (c *handshakeClock) Duration() time.Duration { | |
| 184 | + | c.mu.Lock() | |
| 185 | + | defer c.mu.Unlock() | |
| 186 | + | return c.duration | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (t *handshakeTimer) C() <-chan time.Time { return t.ch } | |
| 190 | + | func (t *handshakeTimer) Reset(time.Duration) bool { return false } | |
| 191 | + | func (t *handshakeTimer) Stop() bool { return true } | |
| 192 | + | ||
| 193 | + | func TestRunBoundsPreWelcomeOperations(t *testing.T) { | |
| 194 | + | tests := []struct { | |
| 195 | + | name string | |
| 196 | + | phase string | |
| 197 | + | end string | |
| 198 | + | want error | |
| 199 | + | }{ | |
| 200 | + | {name: "cancel while sending hello", phase: "send", end: "cancel", want: context.Canceled}, | |
| 201 | + | {name: "timeout while sending hello", phase: "send", end: "timeout", want: context.DeadlineExceeded}, | |
| 202 | + | {name: "cancel while receiving welcome", phase: "recv", end: "cancel", want: context.Canceled}, | |
| 203 | + | {name: "timeout while receiving welcome", phase: "recv", end: "timeout", want: context.DeadlineExceeded}, | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | for _, tt := range tests { | |
| 207 | + | t.Run(tt.name, func(t *testing.T) { | |
| 208 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 209 | + | defer cancel() | |
| 210 | + | clk := newHandshakeClock() | |
| 211 | + | ||
| 212 | + | term := portsmocks.NewMockTerminal(t) | |
| 213 | + | term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 214 | + | // No EnterRaw expectation: it must not run before Welcome. | |
| 215 | + | ||
| 216 | + | started := make(chan struct{}) | |
| 217 | + | closed := make(chan struct{}) | |
| 218 | + | operationExited := make(chan struct{}) | |
| 219 | + | tr := portsmocks.NewMockTransport(t) | |
| 220 | + | tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once() | |
| 221 | + | ||
| 222 | + | block := func() error { | |
| 223 | + | close(started) | |
| 224 | + | <-closed | |
| 225 | + | close(operationExited) | |
| 226 | + | return io.ErrClosedPipe | |
| 227 | + | } | |
| 228 | + | if tt.phase == "send" { | |
| 229 | + | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(ports.Frame) error { | |
| 230 | + | return block() | |
| 231 | + | }).Once() | |
| 232 | + | } else { | |
| 233 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 234 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 235 | + | return ports.Frame{}, block() | |
| 236 | + | }).Once() | |
| 237 | + | } | |
| 238 | + | dialer := portsmocks.NewMockDialer(t) | |
| 239 | + | dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 240 | + | ||
| 241 | + | errCh := make(chan error, 1) | |
| 242 | + | go func() { | |
| 243 | + | errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) | |
| 244 | + | }() | |
| 245 | + | ||
| 246 | + | var timer *handshakeTimer | |
| 247 | + | select { | |
| 248 | + | case timer = <-clk.created: | |
| 249 | + | case <-time.After(time.Second): | |
| 250 | + | t.Fatal("pre-Welcome timer was not created") | |
| 251 | + | } | |
| 252 | + | require.Equal(t, 15*time.Second, clk.Duration()) | |
| 253 | + | select { | |
| 254 | + | case <-started: | |
| 255 | + | case <-time.After(time.Second): | |
| 256 | + | t.Fatal("pre-Welcome operation did not start") | |
| 257 | + | } | |
| 258 | + | if tt.phase == "recv" { | |
| 259 | + | select { | |
| 260 | + | case timer = <-clk.created: | |
| 261 | + | case <-time.After(time.Second): | |
| 262 | + | t.Fatal("Welcome timer was not created") | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | if tt.end == "cancel" { | |
| 267 | + | cancel() | |
| 268 | + | } else { | |
| 269 | + | timer.ch <- time.Time{} | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | select { | |
| 273 | + | case err := <-errCh: | |
| 274 | + | require.ErrorIs(t, err, tt.want) | |
| 275 | + | case <-time.After(time.Second): | |
| 276 | + | t.Fatal("Run did not return after pre-Welcome cancellation") | |
| 277 | + | } | |
| 278 | + | select { | |
| 279 | + | case <-operationExited: | |
| 280 | + | case <-time.After(time.Second): | |
| 281 | + | t.Fatal("pre-Welcome operation leaked after Run returned") | |
| 282 | + | } | |
| 283 | + | }) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 158 | 287 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | |
| 159 | 288 | t.Setenv("TERM", "xterm-256color") | |
| 160 | 289 | t.Setenv("COLORTERM", "truecolor") | |
| @@ -155,6 +155,135 @@ func isType(typ ports.MsgType) any { | |||||
| 155 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | 155 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | ||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | + | // handshakeClock exposes its timer so the pre-Welcome deadline can be tested | |||
| 159 | + | // without waiting on wall time. | |||
| 160 | + | type handshakeClock struct { | |||
| 161 | + | mu sync.Mutex | |||
| 162 | + | duration time.Duration | |||
| 163 | + | created chan *handshakeTimer | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | type handshakeTimer struct{ ch chan time.Time } | |||
| 167 | + | ||||
| 168 | + | func newHandshakeClock() *handshakeClock { | |||
| 169 | + | return &handshakeClock{created: make(chan *handshakeTimer, 2)} | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (c *handshakeClock) Now() time.Time { return time.Time{} } | |||
| 173 | + | ||||
| 174 | + | func (c *handshakeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 175 | + | c.mu.Lock() | |||
| 176 | + | c.duration = d | |||
| 177 | + | c.mu.Unlock() | |||
| 178 | + | t := &handshakeTimer{ch: make(chan time.Time, 1)} | |||
| 179 | + | c.created <- t | |||
| 180 | + | return t | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (c *handshakeClock) Duration() time.Duration { | |||
| 184 | + | c.mu.Lock() | |||
| 185 | + | defer c.mu.Unlock() | |||
| 186 | + | return c.duration | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (t *handshakeTimer) C() <-chan time.Time { return t.ch } | |||
| 190 | + | func (t *handshakeTimer) Reset(time.Duration) bool { return false } | |||
| 191 | + | func (t *handshakeTimer) Stop() bool { return true } | |||
| 192 | + | ||||
| 193 | + | func TestRunBoundsPreWelcomeOperations(t *testing.T) { | |||
| 194 | + | tests := []struct { | |||
| 195 | + | name string | |||
| 196 | + | phase string | |||
| 197 | + | end string | |||
| 198 | + | want error | |||
| 199 | + | }{ | |||
| 200 | + | {name: "cancel while sending hello", phase: "send", end: "cancel", want: context.Canceled}, | |||
| 201 | + | {name: "timeout while sending hello", phase: "send", end: "timeout", want: context.DeadlineExceeded}, | |||
| 202 | + | {name: "cancel while receiving welcome", phase: "recv", end: "cancel", want: context.Canceled}, | |||
| 203 | + | {name: "timeout while receiving welcome", phase: "recv", end: "timeout", want: context.DeadlineExceeded}, | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | for _, tt := range tests { | |||
| 207 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 208 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 209 | + | defer cancel() | |||
| 210 | + | clk := newHandshakeClock() | |||
| 211 | + | ||||
| 212 | + | term := portsmocks.NewMockTerminal(t) | |||
| 213 | + | term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 214 | + | // No EnterRaw expectation: it must not run before Welcome. | |||
| 215 | + | ||||
| 216 | + | started := make(chan struct{}) | |||
| 217 | + | closed := make(chan struct{}) | |||
| 218 | + | operationExited := make(chan struct{}) | |||
| 219 | + | tr := portsmocks.NewMockTransport(t) | |||
| 220 | + | tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once() | |||
| 221 | + | ||||
| 222 | + | block := func() error { | |||
| 223 | + | close(started) | |||
| 224 | + | <-closed | |||
| 225 | + | close(operationExited) | |||
| 226 | + | return io.ErrClosedPipe | |||
| 227 | + | } | |||
| 228 | + | if tt.phase == "send" { | |||
| 229 | + | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(ports.Frame) error { | |||
| 230 | + | return block() | |||
| 231 | + | }).Once() | |||
| 232 | + | } else { | |||
| 233 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 234 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 235 | + | return ports.Frame{}, block() | |||
| 236 | + | }).Once() | |||
| 237 | + | } | |||
| 238 | + | dialer := portsmocks.NewMockDialer(t) | |||
| 239 | + | dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |||
| 240 | + | ||||
| 241 | + | errCh := make(chan error, 1) | |||
| 242 | + | go func() { | |||
| 243 | + | errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) | |||
| 244 | + | }() | |||
| 245 | + | ||||
| 246 | + | var timer *handshakeTimer | |||
| 247 | + | select { | |||
| 248 | + | case timer = <-clk.created: | |||
| 249 | + | case <-time.After(time.Second): | |||
| 250 | + | t.Fatal("pre-Welcome timer was not created") | |||
| 251 | + | } | |||
| 252 | + | require.Equal(t, 15*time.Second, clk.Duration()) | |||
| 253 | + | select { | |||
| 254 | + | case <-started: | |||
| 255 | + | case <-time.After(time.Second): | |||
| 256 | + | t.Fatal("pre-Welcome operation did not start") | |||
| 257 | + | } | |||
| 258 | + | if tt.phase == "recv" { | |||
| 259 | + | select { | |||
| 260 | + | case timer = <-clk.created: | |||
| 261 | + | case <-time.After(time.Second): | |||
| 262 | + | t.Fatal("Welcome timer was not created") | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | if tt.end == "cancel" { | |||
| 267 | + | cancel() | |||
| 268 | + | } else { | |||
| 269 | + | timer.ch <- time.Time{} | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | select { | |||
| 273 | + | case err := <-errCh: | |||
| 274 | + | require.ErrorIs(t, err, tt.want) | |||
| 275 | + | case <-time.After(time.Second): | |||
| 276 | + | t.Fatal("Run did not return after pre-Welcome cancellation") | |||
| 277 | + | } | |||
| 278 | + | select { | |||
| 279 | + | case <-operationExited: | |||
| 280 | + | case <-time.After(time.Second): | |||
| 281 | + | t.Fatal("pre-Welcome operation leaked after Run returned") | |||
| 282 | + | } | |||
| 283 | + | }) | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 158 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | 287 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | ||
| 159 | t.Setenv("TERM", "xterm-256color") | 288 | t.Setenv("TERM", "xterm-256color") | ||
| 160 | t.Setenv("COLORTERM", "truecolor") | 289 | t.Setenv("COLORTERM", "truecolor") | ||
M internal/usecase/client/reconnect_toast_test.go +18 -6
| @@ -179,6 +179,18 @@ func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransp | |||
| 179 | 179 | return hello | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | + | // newReconnectHandshakeClock supplies the non-firing timer needed by the | |
| 183 | + | // bounded pre-Welcome handshake; reconnect sleeps are stubbed by these tests. | |
| 184 | + | func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock { | |
| 185 | + | t.Helper() | |
| 186 | + | clk := portsmocks.NewMockClock(t) | |
| 187 | + | timer := portsmocks.NewMockTimer(t) | |
| 188 | + | timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe() | |
| 189 | + | timer.EXPECT().Stop().Return(true).Maybe() | |
| 190 | + | clk.EXPECT().NewTimer(15 * time.Second).Return(timer).Maybe() | |
| 191 | + | return clk | |
| 192 | + | } | |
| 193 | + | ||
| 182 | 194 | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |
| 183 | 195 | term := newReconnectToastTerminalHarness(t) | |
| 184 | 196 | defer term.closeInput() | |
| @@ -190,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||
| 190 | 202 | resultCh := make(chan attachResult, 1) | |
| 191 | 203 | ms := milestones{} | |
| 192 | 204 | go func() { | |
| 193 | - | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 205 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 194 | 206 | _, err := term.term.EnterRaw() | |
| 195 | 207 | return err | |
| 196 | 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | |
| @@ -229,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||
| 229 | 241 | resultCh := make(chan attachResult, 1) | |
| 230 | 242 | ms := milestones{} | |
| 231 | 243 | go func() { | |
| 232 | - | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 244 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 233 | 245 | _, err := term.term.EnterRaw() | |
| 234 | 246 | return err | |
| 235 | 247 | }, func() {}, func(stage reconnectStage) { | |
| @@ -400,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |||
| 400 | 412 | }} | |
| 401 | 413 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | |
| 402 | 414 | ||
| 403 | - | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 415 | + | err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 404 | 416 | require.ErrorIs(t, err, context.Canceled) | |
| 405 | 417 | require.True(t, out.failed) | |
| 406 | 418 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | |
| @@ -435,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 435 | 447 | }} | |
| 436 | 448 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |
| 437 | 449 | ||
| 438 | - | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 450 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 439 | 451 | require.NoError(t, err) | |
| 440 | 452 | require.True(t, tr1.closed) | |
| 441 | 453 | require.True(t, tr2.closed) | |
| @@ -479,7 +491,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||
| 479 | 491 | }} | |
| 480 | 492 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |
| 481 | 493 | ||
| 482 | - | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler)) | |
| 494 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler)) | |
| 483 | 495 | require.NoError(t, err) | |
| 484 | 496 | require.Equal(t, 2, dialer.calls) | |
| 485 | 497 | ||
| @@ -564,7 +576,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 564 | 576 | dialer := portsmocks.NewMockDialer(t) | |
| 565 | 577 | tt.configure(t, dialer) | |
| 566 | 578 | ||
| 567 | - | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 579 | + | err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 568 | 580 | tt.wantErr(t, err) | |
| 569 | 581 | out := term.out.String() | |
| 570 | 582 | require.Contains(t, out, reconnectToastMessage) | |
| @@ -179,6 +179,18 @@ func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransp | |||||
| 179 | return hello | 179 | return hello | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | + | // newReconnectHandshakeClock supplies the non-firing timer needed by the | |||
| 183 | + | // bounded pre-Welcome handshake; reconnect sleeps are stubbed by these tests. | |||
| 184 | + | func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock { | |||
| 185 | + | t.Helper() | |||
| 186 | + | clk := portsmocks.NewMockClock(t) | |||
| 187 | + | timer := portsmocks.NewMockTimer(t) | |||
| 188 | + | timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe() | |||
| 189 | + | timer.EXPECT().Stop().Return(true).Maybe() | |||
| 190 | + | clk.EXPECT().NewTimer(15 * time.Second).Return(timer).Maybe() | |||
| 191 | + | return clk | |||
| 192 | + | } | |||
| 193 | + | ||||
| 182 | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | 194 | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | ||
| 183 | term := newReconnectToastTerminalHarness(t) | 195 | term := newReconnectToastTerminalHarness(t) | ||
| 184 | defer term.closeInput() | 196 | defer term.closeInput() | ||
| @@ -190,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||||
| 190 | resultCh := make(chan attachResult, 1) | 202 | resultCh := make(chan attachResult, 1) | ||
| 191 | ms := milestones{} | 203 | ms := milestones{} | ||
| 192 | go func() { | 204 | go func() { | ||
| 193 | - | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | 205 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { |
| 194 | _, err := term.term.EnterRaw() | 206 | _, err := term.term.EnterRaw() | ||
| 195 | return err | 207 | return err | ||
| 196 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | ||
| @@ -229,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||||
| 229 | resultCh := make(chan attachResult, 1) | 241 | resultCh := make(chan attachResult, 1) | ||
| 230 | ms := milestones{} | 242 | ms := milestones{} | ||
| 231 | go func() { | 243 | go func() { | ||
| 232 | - | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | 244 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { |
| 233 | _, err := term.term.EnterRaw() | 245 | _, err := term.term.EnterRaw() | ||
| 234 | return err | 246 | return err | ||
| 235 | }, func() {}, func(stage reconnectStage) { | 247 | }, func() {}, func(stage reconnectStage) { | ||
| @@ -400,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |||||
| 400 | }} | 412 | }} | ||
| 401 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | 413 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | ||
| 402 | 414 | ||||
| 403 | - | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | 415 | + | err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) |
| 404 | require.ErrorIs(t, err, context.Canceled) | 416 | require.ErrorIs(t, err, context.Canceled) | ||
| 405 | require.True(t, out.failed) | 417 | require.True(t, out.failed) | ||
| 406 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | 418 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | ||
| @@ -435,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||||
| 435 | }} | 447 | }} | ||
| 436 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | 448 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | ||
| 437 | 449 | ||||
| 438 | - | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | 450 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) |
| 439 | require.NoError(t, err) | 451 | require.NoError(t, err) | ||
| 440 | require.True(t, tr1.closed) | 452 | require.True(t, tr1.closed) | ||
| 441 | require.True(t, tr2.closed) | 453 | require.True(t, tr2.closed) | ||
| @@ -479,7 +491,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||||
| 479 | }} | 491 | }} | ||
| 480 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | 492 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | ||
| 481 | 493 | ||||
| 482 | - | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler)) | 494 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler)) |
| 483 | require.NoError(t, err) | 495 | require.NoError(t, err) | ||
| 484 | require.Equal(t, 2, dialer.calls) | 496 | require.Equal(t, 2, dialer.calls) | ||
| 485 | 497 | ||||
| @@ -564,7 +576,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||||
| 564 | dialer := portsmocks.NewMockDialer(t) | 576 | dialer := portsmocks.NewMockDialer(t) | ||
| 565 | tt.configure(t, dialer) | 577 | tt.configure(t, dialer) | ||
| 566 | 578 | ||||
| 567 | - | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | 579 | + | err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) |
| 568 | tt.wantErr(t, err) | 580 | tt.wantErr(t, err) | ||
| 569 | out := term.out.String() | 581 | out := term.out.String() | ||
| 570 | require.Contains(t, out, reconnectToastMessage) | 582 | require.Contains(t, out, reconnectToastMessage) | ||
M internal/usecase/daemon/client.go +44 -8
| @@ -174,6 +174,23 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |||
| 174 | 174 | ac.linkMu.Unlock() | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | + | // revokeTransport removes tr only if it is still this attachment's current | |
| 178 | + | // link. It returns the revoked transport, so asynchronous retirement can | |
| 179 | + | // close exactly that link without touching a later resume/replacement. | |
| 180 | + | func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |
| 181 | + | if tr == nil { | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ac.linkMu.Lock() | |
| 185 | + | defer ac.linkMu.Unlock() | |
| 186 | + | if ac.tr != tr { | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | ac.tr = nil | |
| 190 | + | ac.transportIncarnation++ | |
| 191 | + | return tr | |
| 192 | + | } | |
| 193 | + | ||
| 177 | 194 | // transportSnapshot binds a send to one concrete link lifetime. Callers must | |
| 178 | 195 | // revalidate it after acquiring sendMu before writing to that link. | |
| 179 | 196 | func (ac *attachedClient) transportSnapshot() transportSnapshot { | |
| @@ -509,17 +526,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 509 | 526 | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | |
| 510 | 527 | } | |
| 511 | 528 | ||
| 512 | - | func (d *Daemon) detachReplacedClient(old *attachedClient) { | |
| 529 | + | // retireReplacedClient owns old-link retirement and obsolete render-worker | |
| 530 | + | // joins outside the replacement handshake. If a render already owns sendMu, | |
| 531 | + | // close immediately interrupts it; no Detached frame can safely overtake that | |
| 532 | + | // render. Otherwise the old link gets its bounded protocol notice first. | |
| 533 | + | func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) { | |
| 513 | 534 | if old == nil { | |
| 535 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 514 | 536 | return | |
| 515 | 537 | } | |
| 516 | - | d.unregisterPreview(old) | |
| 517 | - | old.clearPreviousSession() | |
| 518 | - | old.setSession(nil) | |
| 519 | - | d.log.Info("client displaced") | |
| 520 | - | // Async + bounded: a dead or wedged old client must not stall the new | |
| 521 | - | // client's handshake. | |
| 522 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 538 | + | oldTransport := old.transportSnapshot().transport | |
| 539 | + | blockedRender := !old.sendMu.TryLock() | |
| 540 | + | if !blockedRender { | |
| 541 | + | old.sendMu.Unlock() | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | d.attachmentCleanupWg.Go(func() { | |
| 545 | + | if !blockedRender { | |
| 546 | + | // A healthy idle client observes the required replacement notice. Its | |
| 547 | + | // watchdog still bounds a peer that stops draining after this check. | |
| 548 | + | d.boundedSend(old, frameDetached(ports.ReasonDetach)) | |
| 549 | + | } | |
| 550 | + | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | |
| 551 | + | d.unregisterPreview(old) | |
| 552 | + | old.clearPreviousSession() | |
| 553 | + | old.setSession(nil) | |
| 554 | + | d.log.Info("client displaced") | |
| 555 | + | }) | |
| 556 | + | // Join independently: the close above releases a blocked render while the | |
| 557 | + | // new attachment is already fully published. | |
| 558 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 523 | 559 | } | |
| 524 | 560 | ||
| 525 | 561 | // firstPaint guarantees the freshly attached client sees the full screen: if | |
| @@ -174,6 +174,23 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |||||
| 174 | ac.linkMu.Unlock() | 174 | ac.linkMu.Unlock() | ||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | + | // revokeTransport removes tr only if it is still this attachment's current | |||
| 178 | + | // link. It returns the revoked transport, so asynchronous retirement can | |||
| 179 | + | // close exactly that link without touching a later resume/replacement. | |||
| 180 | + | func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |||
| 181 | + | if tr == nil { | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ac.linkMu.Lock() | |||
| 185 | + | defer ac.linkMu.Unlock() | |||
| 186 | + | if ac.tr != tr { | |||
| 187 | + | return nil | |||
| 188 | + | } | |||
| 189 | + | ac.tr = nil | |||
| 190 | + | ac.transportIncarnation++ | |||
| 191 | + | return tr | |||
| 192 | + | } | |||
| 193 | + | ||||
| 177 | // transportSnapshot binds a send to one concrete link lifetime. Callers must | 194 | // transportSnapshot binds a send to one concrete link lifetime. Callers must | ||
| 178 | // revalidate it after acquiring sendMu before writing to that link. | 195 | // revalidate it after acquiring sendMu before writing to that link. | ||
| 179 | func (ac *attachedClient) transportSnapshot() transportSnapshot { | 196 | func (ac *attachedClient) transportSnapshot() transportSnapshot { | ||
| @@ -509,17 +526,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||||
| 509 | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | 526 | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | ||
| 510 | } | 527 | } | ||
| 511 | 528 | ||||
| 512 | - | func (d *Daemon) detachReplacedClient(old *attachedClient) { | 529 | + | // retireReplacedClient owns old-link retirement and obsolete render-worker |
| 530 | + | // joins outside the replacement handshake. If a render already owns sendMu, | |||
| 531 | + | // close immediately interrupts it; no Detached frame can safely overtake that | |||
| 532 | + | // render. Otherwise the old link gets its bounded protocol notice first. | |||
| 533 | + | func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) { | |||
| 513 | if old == nil { | 534 | if old == nil { | ||
| 535 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |||
| 514 | return | 536 | return | ||
| 515 | } | 537 | } | ||
| 516 | - | d.unregisterPreview(old) | 538 | + | oldTransport := old.transportSnapshot().transport |
| 517 | - | old.clearPreviousSession() | 539 | + | blockedRender := !old.sendMu.TryLock() |
| 518 | - | old.setSession(nil) | 540 | + | if !blockedRender { |
| 519 | - | d.log.Info("client displaced") | 541 | + | old.sendMu.Unlock() |
| 520 | - | // Async + bounded: a dead or wedged old client must not stall the new | 542 | + | } |
| 521 | - | // client's handshake. | 543 | + | |
| 522 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | 544 | + | d.attachmentCleanupWg.Go(func() { |
| 545 | + | if !blockedRender { | |||
| 546 | + | // A healthy idle client observes the required replacement notice. Its | |||
| 547 | + | // watchdog still bounds a peer that stops draining after this check. | |||
| 548 | + | d.boundedSend(old, frameDetached(ports.ReasonDetach)) | |||
| 549 | + | } | |||
| 550 | + | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | |||
| 551 | + | d.unregisterPreview(old) | |||
| 552 | + | old.clearPreviousSession() | |||
| 553 | + | old.setSession(nil) | |||
| 554 | + | d.log.Info("client displaced") | |||
| 555 | + | }) | |||
| 556 | + | // Join independently: the close above releases a blocked render while the | |||
| 557 | + | // new attachment is already fully published. | |||
| 558 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |||
| 523 | } | 559 | } | ||
| 524 | 560 | ||||
| 525 | // firstPaint guarantees the freshly attached client sees the full screen: if | 561 | // firstPaint guarantees the freshly attached client sees the full screen: if | ||
M internal/usecase/daemon/copymode_test.go +2 -20
| @@ -401,24 +401,6 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 401 | 401 | t.Fatal("coordinator did not arm a controllable render timer") | |
| 402 | 402 | } | |
| 403 | 403 | } | |
| 404 | - | awaitControlledOutput := func() ports.Frame { | |
| 405 | - | deadline := time.NewTimer(2 * time.Second) | |
| 406 | - | defer deadline.Stop() | |
| 407 | - | for { | |
| 408 | - | select { | |
| 409 | - | case frame := <-sends: | |
| 410 | - | if frame.Type == ports.MsgOutput { | |
| 411 | - | return frame | |
| 412 | - | } | |
| 413 | - | t.Fatalf("unexpected frame type %d while advancing render clock", frame.Type) | |
| 414 | - | case timer := <-clk.timers: | |
| 415 | - | timer.ch <- time.Time{} | |
| 416 | - | case <-deadline.C: | |
| 417 | - | t.Fatal("controllable timers did not produce an output frame") | |
| 418 | - | return ports.Frame{} | |
| 419 | - | } | |
| 420 | - | } | |
| 421 | - | } | |
| 422 | 404 | var hg sync.WaitGroup | |
| 423 | 405 | hg.Go(func() { d.handleConn(tr) }) | |
| 424 | 406 | awaitFrame(t, sends, ports.MsgWelcome) | |
| @@ -453,9 +435,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 453 | 435 | ac := sess.client | |
| 454 | 436 | require.NotNil(t, ac) | |
| 455 | 437 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 456 | - | awaitControlledOutput() | |
| 438 | + | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") | |
| 457 | 439 | d.handleInput(sess, ac, []byte("VIS\r")) | |
| 458 | - | awaitControlledOutput() | |
| 440 | + | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") | |
| 459 | 441 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | |
| 460 | 442 | ||
| 461 | 443 | var payload string | |
| @@ -401,24 +401,6 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 401 | t.Fatal("coordinator did not arm a controllable render timer") | 401 | t.Fatal("coordinator did not arm a controllable render timer") | ||
| 402 | } | 402 | } | ||
| 403 | } | 403 | } | ||
| 404 | - | awaitControlledOutput := func() ports.Frame { | |||
| 405 | - | deadline := time.NewTimer(2 * time.Second) | |||
| 406 | - | defer deadline.Stop() | |||
| 407 | - | for { | |||
| 408 | - | select { | |||
| 409 | - | case frame := <-sends: | |||
| 410 | - | if frame.Type == ports.MsgOutput { | |||
| 411 | - | return frame | |||
| 412 | - | } | |||
| 413 | - | t.Fatalf("unexpected frame type %d while advancing render clock", frame.Type) | |||
| 414 | - | case timer := <-clk.timers: | |||
| 415 | - | timer.ch <- time.Time{} | |||
| 416 | - | case <-deadline.C: | |||
| 417 | - | t.Fatal("controllable timers did not produce an output frame") | |||
| 418 | - | return ports.Frame{} | |||
| 419 | - | } | |||
| 420 | - | } | |||
| 421 | - | } | |||
| 422 | var hg sync.WaitGroup | 404 | var hg sync.WaitGroup | ||
| 423 | hg.Go(func() { d.handleConn(tr) }) | 405 | hg.Go(func() { d.handleConn(tr) }) | ||
| 424 | awaitFrame(t, sends, ports.MsgWelcome) | 406 | awaitFrame(t, sends, ports.MsgWelcome) | ||
| @@ -453,9 +435,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 453 | ac := sess.client | 435 | ac := sess.client | ||
| 454 | require.NotNil(t, ac) | 436 | require.NotNil(t, ac) | ||
| 455 | d.handleInput(sess, ac, []byte("\x1b ")) | 437 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 456 | - | awaitControlledOutput() | 438 | + | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") |
| 457 | d.handleInput(sess, ac, []byte("VIS\r")) | 439 | d.handleInput(sess, ac, []byte("VIS\r")) | ||
| 458 | - | awaitControlledOutput() | 440 | + | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") |
| 459 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | 441 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | ||
| 460 | 442 | ||||
| 461 | var payload string | 443 | var payload string | ||
M internal/usecase/daemon/daemon.go +11 -5
| @@ -150,8 +150,14 @@ type Daemon struct { | |||
| 150 | 150 | done chan struct{} | |
| 151 | 151 | doneOnce sync.Once | |
| 152 | 152 | ||
| 153 | - | sessWg sync.WaitGroup // PTY reader goroutines | |
| 153 | + | // sessWg owns attention animation, bar-script polling, CWD sampling, | |
| 154 | + | // snapshot save/restore, and floating-session launch goroutines. | |
| 155 | + | sessWg sync.WaitGroup | |
| 154 | 156 | connWg sync.WaitGroup // per-connection handler goroutines | |
| 157 | + | // attachmentCleanupWg owns replacement transport closes and retired render | |
| 158 | + | // worker joins. Only connection handlers add work; Serve waits for those | |
| 159 | + | // handlers before joining this group, so no Add races its terminal Wait. | |
| 160 | + | attachmentCleanupWg sync.WaitGroup | |
| 155 | 161 | } | |
| 156 | 162 | ||
| 157 | 163 | type parkedAttachment struct { | |
| @@ -434,6 +440,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 434 | 440 | // entered killSession may still submit its terminal capture after the | |
| 435 | 441 | // registry snapshot has been removed. | |
| 436 | 442 | d.connWg.Wait() | |
| 443 | + | d.attachmentCleanupWg.Wait() | |
| 437 | 444 | d.stopSnapshotEncodeWorker() | |
| 438 | 445 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 439 | 446 | d.sessWg.Wait() | |
| @@ -660,8 +667,8 @@ type protoErr struct { | |||
| 660 | 667 | func (e *protoErr) Error() string { return e.text } | |
| 661 | 668 | ||
| 662 | 669 | // finishAttach completes an attachment prepared while d.mu is held. It | |
| 663 | - | // publishes the terminal state before releasing d.mu, then performs worker | |
| 664 | - | // joins and replacement teardown without the daemon lock. | |
| 670 | + | // publishes the terminal state before releasing d.mu, then queues replacement | |
| 671 | + | // teardown so obsolete worker joins never delay the new handshake. | |
| 665 | 672 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | |
| 666 | 673 | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | |
| 667 | 674 | clientID: h.ClientID, | |
| @@ -672,8 +679,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 672 | 679 | sess.terminal = term | |
| 673 | 680 | sess.mu.Unlock() | |
| 674 | 681 | d.mu.Unlock() | |
| 675 | - | cleanup.finish() | |
| 676 | - | d.detachReplacedClient(old) | |
| 682 | + | d.retireReplacedClient(old, cleanup) | |
| 677 | 683 | return ac | |
| 678 | 684 | } | |
| 679 | 685 | ||
| @@ -150,8 +150,14 @@ type Daemon struct { | |||||
| 150 | done chan struct{} | 150 | done chan struct{} | ||
| 151 | doneOnce sync.Once | 151 | doneOnce sync.Once | ||
| 152 | 152 | ||||
| 153 | - | sessWg sync.WaitGroup // PTY reader goroutines | 153 | + | // sessWg owns attention animation, bar-script polling, CWD sampling, |
| 154 | + | // snapshot save/restore, and floating-session launch goroutines. | |||
| 155 | + | sessWg sync.WaitGroup | |||
| 154 | connWg sync.WaitGroup // per-connection handler goroutines | 156 | connWg sync.WaitGroup // per-connection handler goroutines | ||
| 157 | + | // attachmentCleanupWg owns replacement transport closes and retired render | |||
| 158 | + | // worker joins. Only connection handlers add work; Serve waits for those | |||
| 159 | + | // handlers before joining this group, so no Add races its terminal Wait. | |||
| 160 | + | attachmentCleanupWg sync.WaitGroup | |||
| 155 | } | 161 | } | ||
| 156 | 162 | ||||
| 157 | type parkedAttachment struct { | 163 | type parkedAttachment struct { | ||
| @@ -434,6 +440,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 434 | // entered killSession may still submit its terminal capture after the | 440 | // entered killSession may still submit its terminal capture after the | ||
| 435 | // registry snapshot has been removed. | 441 | // registry snapshot has been removed. | ||
| 436 | d.connWg.Wait() | 442 | d.connWg.Wait() | ||
| 443 | + | d.attachmentCleanupWg.Wait() | |||
| 437 | d.stopSnapshotEncodeWorker() | 444 | d.stopSnapshotEncodeWorker() | ||
| 438 | d.shutdownAll(ports.ReasonServerShutdown) | 445 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 439 | d.sessWg.Wait() | 446 | d.sessWg.Wait() | ||
| @@ -660,8 +667,8 @@ type protoErr struct { | |||||
| 660 | func (e *protoErr) Error() string { return e.text } | 667 | func (e *protoErr) Error() string { return e.text } | ||
| 661 | 668 | ||||
| 662 | // finishAttach completes an attachment prepared while d.mu is held. It | 669 | // finishAttach completes an attachment prepared while d.mu is held. It | ||
| 663 | - | // publishes the terminal state before releasing d.mu, then performs worker | 670 | + | // publishes the terminal state before releasing d.mu, then queues replacement |
| 664 | - | // joins and replacement teardown without the daemon lock. | 671 | + | // teardown so obsolete worker joins never delay the new handshake. |
| 665 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | 672 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | ||
| 666 | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | 673 | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | ||
| 667 | clientID: h.ClientID, | 674 | clientID: h.ClientID, | ||
| @@ -672,8 +679,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 672 | sess.terminal = term | 679 | sess.terminal = term | ||
| 673 | sess.mu.Unlock() | 680 | sess.mu.Unlock() | ||
| 674 | d.mu.Unlock() | 681 | d.mu.Unlock() | ||
| 675 | - | cleanup.finish() | 682 | + | d.retireReplacedClient(old, cleanup) |
| 676 | - | d.detachReplacedClient(old) | |||
| 677 | return ac | 683 | return ac | ||
| 678 | } | 684 | } | ||
| 679 | 685 | ||||
M internal/usecase/daemon/daemon_test.go +12 -5
| @@ -295,6 +295,7 @@ type welcomeBlockingTransport struct { | |||
| 295 | 295 | welcomeEntered chan struct{} | |
| 296 | 296 | releaseWelcome chan struct{} | |
| 297 | 297 | recvDone chan struct{} | |
| 298 | + | releaseOnce sync.Once | |
| 298 | 299 | closeOnce sync.Once | |
| 299 | 300 | } | |
| 300 | 301 | ||
| @@ -320,10 +321,16 @@ func newWelcomeBlockingTransport(t *testing.T) *welcomeBlockingTransport { | |||
| 320 | 321 | return ports.Frame{}, io.EOF | |
| 321 | 322 | }).Maybe() | |
| 322 | 323 | b.tr.EXPECT().Close().Return(nil).Maybe() | |
| 324 | + | t.Cleanup(b.finish) | |
| 323 | 325 | return b | |
| 324 | 326 | } | |
| 325 | 327 | ||
| 328 | + | func (b *welcomeBlockingTransport) release() { | |
| 329 | + | b.releaseOnce.Do(func() { close(b.releaseWelcome) }) | |
| 330 | + | } | |
| 331 | + | ||
| 326 | 332 | func (b *welcomeBlockingTransport) finish() { | |
| 333 | + | b.release() | |
| 327 | 334 | b.closeOnce.Do(func() { close(b.recvDone) }) | |
| 328 | 335 | } | |
| 329 | 336 | ||
| @@ -448,8 +455,8 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 448 | 455 | close(done) | |
| 449 | 456 | }() | |
| 450 | 457 | ||
| 451 | - | <-tr.welcomeEntered | |
| 452 | - | welcome := <-tr.sends | |
| 458 | + | awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send") | |
| 459 | + | welcome := awaitFrame(t, tr.sends, ports.MsgWelcome) | |
| 453 | 460 | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 454 | 461 | sess := firstSession(d) | |
| 455 | 462 | sess.mu.Lock() | |
| @@ -469,16 +476,16 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 469 | 476 | rc.mu.Unlock() | |
| 470 | 477 | require.NotNil(t, workerDone) | |
| 471 | 478 | timer.ch <- time.Time{} | |
| 472 | - | awaitHandshakeWorker(t, workerDone) | |
| 479 | + | awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete") | |
| 473 | 480 | requireNoCoordinatorOutputFrame(t, tr.sends) | |
| 474 | 481 | ||
| 475 | - | close(tr.releaseWelcome) | |
| 482 | + | tr.release() | |
| 476 | 483 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 477 | 484 | first, err := ports.UnmarshalOutput(output.Payload) | |
| 478 | 485 | require.NoError(t, err) | |
| 479 | 486 | require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full") | |
| 480 | 487 | tr.finish() | |
| 481 | - | <-done | |
| 488 | + | awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion") | |
| 482 | 489 | requireNoCoordinatorOutputFrame(t, tr.sends) | |
| 483 | 490 | } | |
| 484 | 491 | ||
| @@ -295,6 +295,7 @@ type welcomeBlockingTransport struct { | |||||
| 295 | welcomeEntered chan struct{} | 295 | welcomeEntered chan struct{} | ||
| 296 | releaseWelcome chan struct{} | 296 | releaseWelcome chan struct{} | ||
| 297 | recvDone chan struct{} | 297 | recvDone chan struct{} | ||
| 298 | + | releaseOnce sync.Once | |||
| 298 | closeOnce sync.Once | 299 | closeOnce sync.Once | ||
| 299 | } | 300 | } | ||
| 300 | 301 | ||||
| @@ -320,10 +321,16 @@ func newWelcomeBlockingTransport(t *testing.T) *welcomeBlockingTransport { | |||||
| 320 | return ports.Frame{}, io.EOF | 321 | return ports.Frame{}, io.EOF | ||
| 321 | }).Maybe() | 322 | }).Maybe() | ||
| 322 | b.tr.EXPECT().Close().Return(nil).Maybe() | 323 | b.tr.EXPECT().Close().Return(nil).Maybe() | ||
| 324 | + | t.Cleanup(b.finish) | |||
| 323 | return b | 325 | return b | ||
| 324 | } | 326 | } | ||
| 325 | 327 | ||||
| 328 | + | func (b *welcomeBlockingTransport) release() { | |||
| 329 | + | b.releaseOnce.Do(func() { close(b.releaseWelcome) }) | |||
| 330 | + | } | |||
| 331 | + | ||||
| 326 | func (b *welcomeBlockingTransport) finish() { | 332 | func (b *welcomeBlockingTransport) finish() { | ||
| 333 | + | b.release() | |||
| 327 | b.closeOnce.Do(func() { close(b.recvDone) }) | 334 | b.closeOnce.Do(func() { close(b.recvDone) }) | ||
| 328 | } | 335 | } | ||
| 329 | 336 | ||||
| @@ -448,8 +455,8 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 448 | close(done) | 455 | close(done) | ||
| 449 | }() | 456 | }() | ||
| 450 | 457 | ||||
| 451 | - | <-tr.welcomeEntered | 458 | + | awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send") |
| 452 | - | welcome := <-tr.sends | 459 | + | welcome := awaitFrame(t, tr.sends, ports.MsgWelcome) |
| 453 | require.Equal(t, ports.MsgWelcome, welcome.Type) | 460 | require.Equal(t, ports.MsgWelcome, welcome.Type) | ||
| 454 | sess := firstSession(d) | 461 | sess := firstSession(d) | ||
| 455 | sess.mu.Lock() | 462 | sess.mu.Lock() | ||
| @@ -469,16 +476,16 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 469 | rc.mu.Unlock() | 476 | rc.mu.Unlock() | ||
| 470 | require.NotNil(t, workerDone) | 477 | require.NotNil(t, workerDone) | ||
| 471 | timer.ch <- time.Time{} | 478 | timer.ch <- time.Time{} | ||
| 472 | - | awaitHandshakeWorker(t, workerDone) | 479 | + | awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete") |
| 473 | requireNoCoordinatorOutputFrame(t, tr.sends) | 480 | requireNoCoordinatorOutputFrame(t, tr.sends) | ||
| 474 | 481 | ||||
| 475 | - | close(tr.releaseWelcome) | 482 | + | tr.release() |
| 476 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | 483 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 477 | first, err := ports.UnmarshalOutput(output.Payload) | 484 | first, err := ports.UnmarshalOutput(output.Payload) | ||
| 478 | require.NoError(t, err) | 485 | require.NoError(t, err) | ||
| 479 | require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full") | 486 | require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full") | ||
| 480 | tr.finish() | 487 | tr.finish() | ||
| 481 | - | <-done | 488 | + | awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion") |
| 482 | requireNoCoordinatorOutputFrame(t, tr.sends) | 489 | requireNoCoordinatorOutputFrame(t, tr.sends) | ||
| 483 | } | 490 | } | ||
| 484 | 491 | ||||
M internal/usecase/daemon/input_test.go +12 -12
| @@ -58,11 +58,11 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 58 | 58 | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | |
| 59 | 59 | }, 2*time.Second, 5*time.Millisecond) | |
| 60 | 60 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 61 | - | require.Equal(t, []byte("A"), <-writes1) | |
| 61 | + | requirePTYWrite(t, writes1, []byte("A")) | |
| 62 | 62 | require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 63 | - | require.Equal(t, []byte("B"), <-writes2) | |
| 63 | + | requirePTYWrite(t, writes2, []byte("B")) | |
| 64 | 64 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 65 | - | require.Equal(t, []byte("C"), <-writes3) | |
| 65 | + | requirePTYWrite(t, writes3, []byte("C")) | |
| 66 | 66 | ||
| 67 | 67 | d.mu.Lock() | |
| 68 | 68 | var sess *session | |
| @@ -98,7 +98,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 98 | 98 | source.screen.Write([]byte("source")) | |
| 99 | 99 | source.mu.Unlock() | |
| 100 | 100 | d.paint(sess, ac, true, nil) | |
| 101 | - | first := <-sends | |
| 101 | + | first := awaitFrame(t, sends, ports.MsgOutput) | |
| 102 | 102 | firstOutput, err := ports.UnmarshalOutput(first.Payload) | |
| 103 | 103 | require.NoError(t, err) | |
| 104 | 104 | terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows) | |
| @@ -114,7 +114,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 114 | 114 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | |
| 115 | 115 | require.Equal(t, 1, activeTabIndex(sess)) | |
| 116 | 116 | ||
| 117 | - | second := <-sends | |
| 117 | + | second := awaitFrame(t, sends, ports.MsgOutput) | |
| 118 | 118 | secondOutput, err := ports.UnmarshalOutput(second.Payload) | |
| 119 | 119 | require.NoError(t, err) | |
| 120 | 120 | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | |
| @@ -166,7 +166,7 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||
| 166 | 166 | ||
| 167 | 167 | d.handleInput(sess, ac, []byte("\x1bc")) | |
| 168 | 168 | ||
| 169 | - | require.Equal(t, []byte("\x1bc"), <-writes) | |
| 169 | + | requirePTYWrite(t, writes, []byte("\x1bc")) | |
| 170 | 170 | releasePTY() | |
| 171 | 171 | } | |
| 172 | 172 | ||
| @@ -396,7 +396,7 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |||
| 396 | 396 | paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~") | |
| 397 | 397 | d.handleInput(sess, ac, paste) | |
| 398 | 398 | ||
| 399 | - | require.Equal(t, paste, <-writes) | |
| 399 | + | requirePTYWrite(t, writes, paste) | |
| 400 | 400 | releasePTY() | |
| 401 | 401 | } | |
| 402 | 402 | ||
| @@ -478,7 +478,7 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||
| 478 | 478 | input: []byte("Z"), | |
| 479 | 479 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | |
| 480 | 480 | t.Helper() | |
| 481 | - | require.Equal(t, []byte("Z"), <-writes) | |
| 481 | + | requirePTYWrite(t, writes, []byte("Z")) | |
| 482 | 482 | }, | |
| 483 | 483 | }, | |
| 484 | 484 | } | |
| @@ -767,7 +767,7 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||
| 767 | 767 | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | |
| 768 | 768 | d.handleInput(sess, ac, []byte("Z")) | |
| 769 | 769 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 770 | - | require.Equal(t, []byte("Z"), <-writes) | |
| 770 | + | requirePTYWrite(t, writes, []byte("Z")) | |
| 771 | 771 | ||
| 772 | 772 | releasePTY1() | |
| 773 | 773 | releasePTY2() | |
| @@ -871,8 +871,8 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 871 | 871 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 872 | 872 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 873 | 873 | ||
| 874 | - | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | |
| 875 | - | require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes) | |
| 874 | + | requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A")) | |
| 875 | + | requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B")) | |
| 876 | 876 | require.Nil(t, ac.overlays.copyMode) | |
| 877 | 877 | } | |
| 878 | 878 | ||
| @@ -927,7 +927,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 927 | 927 | ||
| 928 | 928 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h")) | |
| 929 | 929 | d.handleInput(sess, ac, raw) | |
| 930 | - | require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes) | |
| 930 | + | requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M")) | |
| 931 | 931 | ||
| 932 | 932 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 933 | 933 | select { | |
| @@ -58,11 +58,11 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 58 | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | 58 | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | ||
| 59 | }, 2*time.Second, 5*time.Millisecond) | 59 | }, 2*time.Second, 5*time.Millisecond) | ||
| 60 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | 60 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 61 | - | require.Equal(t, []byte("A"), <-writes1) | 61 | + | requirePTYWrite(t, writes1, []byte("A")) |
| 62 | require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond) | 62 | require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 63 | - | require.Equal(t, []byte("B"), <-writes2) | 63 | + | requirePTYWrite(t, writes2, []byte("B")) |
| 64 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | 64 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 65 | - | require.Equal(t, []byte("C"), <-writes3) | 65 | + | requirePTYWrite(t, writes3, []byte("C")) |
| 66 | 66 | ||||
| 67 | d.mu.Lock() | 67 | d.mu.Lock() | ||
| 68 | var sess *session | 68 | var sess *session | ||
| @@ -98,7 +98,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 98 | source.screen.Write([]byte("source")) | 98 | source.screen.Write([]byte("source")) | ||
| 99 | source.mu.Unlock() | 99 | source.mu.Unlock() | ||
| 100 | d.paint(sess, ac, true, nil) | 100 | d.paint(sess, ac, true, nil) | ||
| 101 | - | first := <-sends | 101 | + | first := awaitFrame(t, sends, ports.MsgOutput) |
| 102 | firstOutput, err := ports.UnmarshalOutput(first.Payload) | 102 | firstOutput, err := ports.UnmarshalOutput(first.Payload) | ||
| 103 | require.NoError(t, err) | 103 | require.NoError(t, err) | ||
| 104 | terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows) | 104 | terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows) | ||
| @@ -114,7 +114,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 114 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | 114 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | ||
| 115 | require.Equal(t, 1, activeTabIndex(sess)) | 115 | require.Equal(t, 1, activeTabIndex(sess)) | ||
| 116 | 116 | ||||
| 117 | - | second := <-sends | 117 | + | second := awaitFrame(t, sends, ports.MsgOutput) |
| 118 | secondOutput, err := ports.UnmarshalOutput(second.Payload) | 118 | secondOutput, err := ports.UnmarshalOutput(second.Payload) | ||
| 119 | require.NoError(t, err) | 119 | require.NoError(t, err) | ||
| 120 | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | 120 | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | ||
| @@ -166,7 +166,7 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||||
| 166 | 166 | ||||
| 167 | d.handleInput(sess, ac, []byte("\x1bc")) | 167 | d.handleInput(sess, ac, []byte("\x1bc")) | ||
| 168 | 168 | ||||
| 169 | - | require.Equal(t, []byte("\x1bc"), <-writes) | 169 | + | requirePTYWrite(t, writes, []byte("\x1bc")) |
| 170 | releasePTY() | 170 | releasePTY() | ||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| @@ -396,7 +396,7 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |||||
| 396 | paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~") | 396 | paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~") | ||
| 397 | d.handleInput(sess, ac, paste) | 397 | d.handleInput(sess, ac, paste) | ||
| 398 | 398 | ||||
| 399 | - | require.Equal(t, paste, <-writes) | 399 | + | requirePTYWrite(t, writes, paste) |
| 400 | releasePTY() | 400 | releasePTY() | ||
| 401 | } | 401 | } | ||
| 402 | 402 | ||||
| @@ -478,7 +478,7 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||||
| 478 | input: []byte("Z"), | 478 | input: []byte("Z"), | ||
| 479 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | 479 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | ||
| 480 | t.Helper() | 480 | t.Helper() | ||
| 481 | - | require.Equal(t, []byte("Z"), <-writes) | 481 | + | requirePTYWrite(t, writes, []byte("Z")) |
| 482 | }, | 482 | }, | ||
| 483 | }, | 483 | }, | ||
| 484 | } | 484 | } | ||
| @@ -767,7 +767,7 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||||
| 767 | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | 767 | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | ||
| 768 | d.handleInput(sess, ac, []byte("Z")) | 768 | d.handleInput(sess, ac, []byte("Z")) | ||
| 769 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | 769 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 770 | - | require.Equal(t, []byte("Z"), <-writes) | 770 | + | requirePTYWrite(t, writes, []byte("Z")) |
| 771 | 771 | ||||
| 772 | releasePTY1() | 772 | releasePTY1() | ||
| 773 | releasePTY2() | 773 | releasePTY2() | ||
| @@ -871,8 +871,8 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||||
| 871 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | 871 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | ||
| 872 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | 872 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | ||
| 873 | 873 | ||||
| 874 | - | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | 874 | + | requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A")) |
| 875 | - | require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes) | 875 | + | requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B")) |
| 876 | require.Nil(t, ac.overlays.copyMode) | 876 | require.Nil(t, ac.overlays.copyMode) | ||
| 877 | } | 877 | } | ||
| 878 | 878 | ||||
| @@ -927,7 +927,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 927 | 927 | ||||
| 928 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h")) | 928 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h")) | ||
| 929 | d.handleInput(sess, ac, raw) | 929 | d.handleInput(sess, ac, raw) | ||
| 930 | - | require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes) | 930 | + | requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M")) |
| 931 | 931 | ||||
| 932 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 932 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | ||
| 933 | select { | 933 | select { | ||
M internal/usecase/daemon/overlay_runtime.go +7 -6
| @@ -13,12 +13,13 @@ import ( | |||
| 13 | 13 | type overlayRuntime struct { | |
| 14 | 14 | ac *attachedClient | |
| 15 | 15 | ||
| 16 | - | pickerMu sync.Mutex | |
| 17 | - | picker *picker.Model | |
| 18 | - | pickerPreview *tab | |
| 19 | - | pickerPreviewSession *session | |
| 20 | - | pickerPending []byte | |
| 21 | - | pickerESC pendingByteTimer | |
| 16 | + | pickerMu sync.Mutex | |
| 17 | + | picker *picker.Model | |
| 18 | + | pickerPreview *tab | |
| 19 | + | pickerPreviewSession *session | |
| 20 | + | pickerPreviewGeneration uint64 | |
| 21 | + | pickerPending []byte | |
| 22 | + | pickerESC pendingByteTimer | |
| 22 | 23 | ||
| 23 | 24 | paletteMu sync.Mutex | |
| 24 | 25 | palette *palette.Model | |
| @@ -13,12 +13,13 @@ import ( | |||||
| 13 | type overlayRuntime struct { | 13 | type overlayRuntime struct { | ||
| 14 | ac *attachedClient | 14 | ac *attachedClient | ||
| 15 | 15 | ||||
| 16 | - | pickerMu sync.Mutex | 16 | + | pickerMu sync.Mutex |
| 17 | - | picker *picker.Model | 17 | + | picker *picker.Model |
| 18 | - | pickerPreview *tab | 18 | + | pickerPreview *tab |
| 19 | - | pickerPreviewSession *session | 19 | + | pickerPreviewSession *session |
| 20 | - | pickerPending []byte | 20 | + | pickerPreviewGeneration uint64 |
| 21 | - | pickerESC pendingByteTimer | 21 | + | pickerPending []byte |
| 22 | + | pickerESC pendingByteTimer | |||
| 22 | 23 | ||||
| 23 | paletteMu sync.Mutex | 24 | paletteMu sync.Mutex | ||
| 24 | palette *palette.Model | 25 | palette *palette.Model | ||
M internal/usecase/daemon/performance_benchmark_test.go +5 -18
| @@ -3,7 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "io" | |
| 6 | - | "runtime" | |
| 7 | 6 | "sync" | |
| 8 | 7 | "testing" | |
| 9 | 8 | "time" | |
| @@ -76,7 +75,7 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 76 | 75 | func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) { | |
| 77 | 76 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 78 | 77 | d.paint(sess, ac, true, nil) | |
| 79 | - | <-sends | |
| 78 | + | _ = awaitFrame(t, sends, ports.MsgOutput) | |
| 80 | 79 | var captures, compositions, emissions int | |
| 81 | 80 | ac.renderStages = renderStageHooks{ | |
| 82 | 81 | capture: func() { captures++ }, | |
| @@ -728,7 +727,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 728 | 727 | sess: sess, | |
| 729 | 728 | ac: ac, | |
| 730 | 729 | output: output, | |
| 731 | - | snaps: &countingSnapshotStore{done: make(chan struct{}, 1)}, | |
| 730 | + | snaps: &countingSnapshotStore{}, | |
| 732 | 731 | pty: pty, | |
| 733 | 732 | clock: clock, | |
| 734 | 733 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | |
| @@ -823,17 +822,9 @@ func (f *performanceFixture) paintLive() { | |||
| 823 | 822 | func (f *performanceFixture) captureSnapshot() { | |
| 824 | 823 | f.t.Helper() | |
| 825 | 824 | require.True(f.t, f.d.captureSession(f.sess)) | |
| 826 | - | <-f.snaps.done | |
| 827 | - | for range 4096 { | |
| 828 | - | f.sess.snapshotMu.Lock() | |
| 829 | - | pending := f.sess.snapshotPending | |
| 830 | - | f.sess.snapshotMu.Unlock() | |
| 831 | - | if !pending { | |
| 832 | - | return | |
| 833 | - | } | |
| 834 | - | runtime.Gosched() | |
| 835 | - | } | |
| 836 | - | f.t.Fatal("snapshot worker did not finish performance capture") | |
| 825 | + | // Worker finalization clears snapshotPending and signals snapshotChanged on | |
| 826 | + | // every success and failure path after persistence has returned. | |
| 827 | + | awaitSnapshotIdle(f.t, f.sess) | |
| 837 | 828 | } | |
| 838 | 829 | ||
| 839 | 830 | func (f *performanceFixture) enterCopySearch() { | |
| @@ -1024,7 +1015,6 @@ type countingSnapshotStore struct { | |||
| 1024 | 1015 | mu sync.Mutex | |
| 1025 | 1016 | countingSnapshotMetrics | |
| 1026 | 1017 | last []byte | |
| 1027 | - | done chan struct{} | |
| 1028 | 1018 | } | |
| 1029 | 1019 | ||
| 1030 | 1020 | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | |
| @@ -1033,9 +1023,6 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error { | |||
| 1033 | 1023 | s.bytes += uint64(len(data)) | |
| 1034 | 1024 | s.last = data | |
| 1035 | 1025 | s.mu.Unlock() | |
| 1036 | - | if s.done != nil { | |
| 1037 | - | s.done <- struct{}{} | |
| 1038 | - | } | |
| 1039 | 1026 | return nil | |
| 1040 | 1027 | } | |
| 1041 | 1028 | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| @@ -3,7 +3,6 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | - | "runtime" | |||
| 7 | "sync" | 6 | "sync" | ||
| 8 | "testing" | 7 | "testing" | ||
| 9 | "time" | 8 | "time" | ||
| @@ -76,7 +75,7 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||||
| 76 | func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) { | 75 | func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) { | ||
| 77 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | 76 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | ||
| 78 | d.paint(sess, ac, true, nil) | 77 | d.paint(sess, ac, true, nil) | ||
| 79 | - | <-sends | 78 | + | _ = awaitFrame(t, sends, ports.MsgOutput) |
| 80 | var captures, compositions, emissions int | 79 | var captures, compositions, emissions int | ||
| 81 | ac.renderStages = renderStageHooks{ | 80 | ac.renderStages = renderStageHooks{ | ||
| 82 | capture: func() { captures++ }, | 81 | capture: func() { captures++ }, | ||
| @@ -728,7 +727,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 728 | sess: sess, | 727 | sess: sess, | ||
| 729 | ac: ac, | 728 | ac: ac, | ||
| 730 | output: output, | 729 | output: output, | ||
| 731 | - | snaps: &countingSnapshotStore{done: make(chan struct{}, 1)}, | 730 | + | snaps: &countingSnapshotStore{}, |
| 732 | pty: pty, | 731 | pty: pty, | ||
| 733 | clock: clock, | 732 | clock: clock, | ||
| 734 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | 733 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | ||
| @@ -823,17 +822,9 @@ func (f *performanceFixture) paintLive() { | |||||
| 823 | func (f *performanceFixture) captureSnapshot() { | 822 | func (f *performanceFixture) captureSnapshot() { | ||
| 824 | f.t.Helper() | 823 | f.t.Helper() | ||
| 825 | require.True(f.t, f.d.captureSession(f.sess)) | 824 | require.True(f.t, f.d.captureSession(f.sess)) | ||
| 826 | - | <-f.snaps.done | 825 | + | // Worker finalization clears snapshotPending and signals snapshotChanged on |
| 827 | - | for range 4096 { | 826 | + | // every success and failure path after persistence has returned. |
| 828 | - | f.sess.snapshotMu.Lock() | 827 | + | awaitSnapshotIdle(f.t, f.sess) |
| 829 | - | pending := f.sess.snapshotPending | |||
| 830 | - | f.sess.snapshotMu.Unlock() | |||
| 831 | - | if !pending { | |||
| 832 | - | return | |||
| 833 | - | } | |||
| 834 | - | runtime.Gosched() | |||
| 835 | - | } | |||
| 836 | - | f.t.Fatal("snapshot worker did not finish performance capture") | |||
| 837 | } | 828 | } | ||
| 838 | 829 | ||||
| 839 | func (f *performanceFixture) enterCopySearch() { | 830 | func (f *performanceFixture) enterCopySearch() { | ||
| @@ -1024,7 +1015,6 @@ type countingSnapshotStore struct { | |||||
| 1024 | mu sync.Mutex | 1015 | mu sync.Mutex | ||
| 1025 | countingSnapshotMetrics | 1016 | countingSnapshotMetrics | ||
| 1026 | last []byte | 1017 | last []byte | ||
| 1027 | - | done chan struct{} | |||
| 1028 | } | 1018 | } | ||
| 1029 | 1019 | ||||
| 1030 | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | 1020 | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | ||
| @@ -1033,9 +1023,6 @@ func (s *countingSnapshotStore) Write(_ string, data []byte) error { | |||||
| 1033 | s.bytes += uint64(len(data)) | 1023 | s.bytes += uint64(len(data)) | ||
| 1034 | s.last = data | 1024 | s.last = data | ||
| 1035 | s.mu.Unlock() | 1025 | s.mu.Unlock() | ||
| 1036 | - | if s.done != nil { | |||
| 1037 | - | s.done <- struct{}{} | |||
| 1038 | - | } | |||
| 1039 | return nil | 1026 | return nil | ||
| 1040 | } | 1027 | } | ||
| 1041 | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | 1028 | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | ||
M internal/usecase/daemon/picker.go +65 -26
| @@ -183,9 +183,10 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) { | |||
| 183 | 183 | ac.overlays.pickerESC.timer = nil | |
| 184 | 184 | ac.overlays.pickerESC.done = nil | |
| 185 | 185 | ac.overlays.picker = nil | |
| 186 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 186 | 187 | ac.overlays.pickerMu.Unlock() | |
| 187 | 188 | ||
| 188 | - | d.unregisterPreview(ac) | |
| 189 | + | d.clearPreviewGeneration(ac, generation) | |
| 189 | 190 | if sess := ac.currentSession(); sess != nil { | |
| 190 | 191 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 191 | 192 | } | |
| @@ -215,21 +216,22 @@ func isPickerEscapePrefix(data []byte) bool { | |||
| 215 | 216 | } | |
| 216 | 217 | ||
| 217 | 218 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |
| 219 | + | if ac == nil || ac.overlays == nil { | |
| 220 | + | return | |
| 221 | + | } | |
| 218 | 222 | ac.overlays.pickerMu.Lock() | |
| 219 | 223 | var target picker.Target | |
| 220 | 224 | var ok bool | |
| 221 | 225 | if ac.overlays.picker != nil { | |
| 222 | 226 | target, ok = ac.overlays.picker.Selected() | |
| 223 | 227 | } | |
| 224 | - | previous := ac.overlays.pickerPreviewSession | |
| 228 | + | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |
| 229 | + | ac.overlays.pickerPreviewGeneration++ | |
| 230 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 225 | 231 | ac.overlays.pickerPreviewSession = nil | |
| 226 | 232 | ac.overlays.pickerPreview = nil | |
| 227 | 233 | ac.overlays.pickerMu.Unlock() | |
| 228 | - | if previous != nil { | |
| 229 | - | if rc := previous.renderCoordinator(); rc != nil { | |
| 230 | - | rc.teardownPreviewFor(ac) | |
| 231 | - | } | |
| 232 | - | } | |
| 234 | + | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 233 | 235 | if !ok { | |
| 234 | 236 | return | |
| 235 | 237 | } | |
| @@ -240,13 +242,16 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 240 | 242 | } | |
| 241 | 243 | ac.overlays.pickerMu.Lock() | |
| 242 | 244 | // Selection may have changed while the target was resolved. | |
| 243 | - | if ac.overlays.picker == nil { | |
| 244 | - | ac.overlays.pickerMu.Unlock() | |
| 245 | - | return | |
| 245 | + | selected, stillSelected := ac.overlays.picker.Selected() | |
| 246 | + | valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target | |
| 247 | + | if valid { | |
| 248 | + | ac.overlays.pickerPreview = next | |
| 249 | + | ac.overlays.pickerPreviewSession = targetSess | |
| 246 | 250 | } | |
| 247 | - | ac.overlays.pickerPreview = next | |
| 248 | - | ac.overlays.pickerPreviewSession = targetSess | |
| 249 | 251 | ac.overlays.pickerMu.Unlock() | |
| 252 | + | if !valid { | |
| 253 | + | return | |
| 254 | + | } | |
| 250 | 255 | // A same-session preview is already invalidated by its own coordinator. | |
| 251 | 256 | // Cross-session previews subscribe to the target's producer wake instead | |
| 252 | 257 | // of starting a direct paint/timer path. | |
| @@ -254,16 +259,38 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 254 | 259 | return | |
| 255 | 260 | } | |
| 256 | 261 | rc := d.attachCoordinator(targetSess, nil, nil, false) | |
| 257 | - | rc.subscribePreviewFor(ac, func(renderWake) { | |
| 258 | - | ac.overlays.pickerMu.Lock() | |
| 259 | - | valid := ac.overlays.pickerPreviewSession == targetSess | |
| 260 | - | ac.overlays.pickerMu.Unlock() | |
| 261 | - | if valid { | |
| 262 | + | rc.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 263 | + | if pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 262 | 264 | if owner := ac.currentSession(); owner != nil { | |
| 263 | 265 | d.invalidateRender(owner, ac, false, "picker preview") | |
| 264 | 266 | } | |
| 265 | 267 | } | |
| 266 | 268 | }) | |
| 269 | + | // subscribePreviewFor is deliberately outside pickerMu. Revalidate after it | |
| 270 | + | // returns and remove only this generation if selection changed meanwhile. | |
| 271 | + | d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool { | |
| 275 | + | ac.overlays.pickerMu.Lock() | |
| 276 | + | defer ac.overlays.pickerMu.Unlock() | |
| 277 | + | return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // revalidatePreviewSubscription removes a subscription that lost the picker | |
| 281 | + | // selection race after it was installed. | |
| 282 | + | func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) { | |
| 283 | + | if !pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 284 | + | rc.teardownPreviewFor(ac, generation) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) { | |
| 289 | + | if previous != nil { | |
| 290 | + | if rc := previous.renderCoordinator(); rc != nil { | |
| 291 | + | rc.teardownPreviewFor(ac, generation) | |
| 292 | + | } | |
| 293 | + | } | |
| 267 | 294 | } | |
| 268 | 295 | ||
| 269 | 296 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |
| @@ -271,15 +298,26 @@ func (d *Daemon) unregisterPreview(ac *attachedClient) { | |||
| 271 | 298 | return | |
| 272 | 299 | } | |
| 273 | 300 | ac.overlays.pickerMu.Lock() | |
| 301 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 302 | + | ac.overlays.pickerMu.Unlock() | |
| 303 | + | d.clearPreviewGeneration(ac, generation) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // clearPreviewGeneration removes the preview only if it is still the observed | |
| 307 | + | // selection. Teardown paths may race a new picker selection. | |
| 308 | + | func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool { | |
| 309 | + | ac.overlays.pickerMu.Lock() | |
| 310 | + | if ac.overlays.pickerPreviewGeneration != generation { | |
| 311 | + | ac.overlays.pickerMu.Unlock() | |
| 312 | + | return false | |
| 313 | + | } | |
| 274 | 314 | previous := ac.overlays.pickerPreviewSession | |
| 315 | + | ac.overlays.pickerPreviewGeneration++ | |
| 275 | 316 | ac.overlays.pickerPreviewSession = nil | |
| 276 | 317 | ac.overlays.pickerPreview = nil | |
| 277 | 318 | ac.overlays.pickerMu.Unlock() | |
| 278 | - | if previous != nil { | |
| 279 | - | if rc := previous.renderCoordinator(); rc != nil { | |
| 280 | - | rc.teardownPreviewFor(ac) | |
| 281 | - | } | |
| 282 | - | } | |
| 319 | + | d.teardownPreviewSubscription(ac, previous, generation) | |
| 320 | + | return true | |
| 283 | 321 | } | |
| 284 | 322 | ||
| 285 | 323 | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | |
| @@ -303,10 +341,10 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 303 | 341 | continue | |
| 304 | 342 | } | |
| 305 | 343 | ac.overlays.pickerMu.Lock() | |
| 306 | - | cleared := ac.overlays.pickerPreview == tb | |
| 344 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 345 | + | observed := ac.overlays.pickerPreview == tb | |
| 307 | 346 | ac.overlays.pickerMu.Unlock() | |
| 308 | - | if cleared { | |
| 309 | - | d.unregisterPreview(ac) | |
| 347 | + | if observed && d.clearPreviewGeneration(ac, generation) { | |
| 310 | 348 | if sess := ac.currentSession(); sess != nil { | |
| 311 | 349 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 312 | 350 | } | |
| @@ -319,8 +357,9 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 319 | 357 | ac.overlays.picker = nil | |
| 320 | 358 | ac.overlays.pickerPending = nil | |
| 321 | 359 | d.stopPickerPendingTimerLocked(ac) | |
| 360 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 322 | 361 | ac.overlays.pickerMu.Unlock() | |
| 323 | - | d.unregisterPreview(ac) | |
| 362 | + | d.clearPreviewGeneration(ac, generation) | |
| 324 | 363 | } | |
| 325 | 364 | ||
| 326 | 365 | func (d *Daemon) tabByTarget(target picker.Target) *tab { | |
| @@ -183,9 +183,10 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) { | |||||
| 183 | ac.overlays.pickerESC.timer = nil | 183 | ac.overlays.pickerESC.timer = nil | ||
| 184 | ac.overlays.pickerESC.done = nil | 184 | ac.overlays.pickerESC.done = nil | ||
| 185 | ac.overlays.picker = nil | 185 | ac.overlays.picker = nil | ||
| 186 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 186 | ac.overlays.pickerMu.Unlock() | 187 | ac.overlays.pickerMu.Unlock() | ||
| 187 | 188 | ||||
| 188 | - | d.unregisterPreview(ac) | 189 | + | d.clearPreviewGeneration(ac, generation) |
| 189 | if sess := ac.currentSession(); sess != nil { | 190 | if sess := ac.currentSession(); sess != nil { | ||
| 190 | d.invalidateRender(sess, ac, true, "picker.go") | 191 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 191 | } | 192 | } | ||
| @@ -215,21 +216,22 @@ func isPickerEscapePrefix(data []byte) bool { | |||||
| 215 | } | 216 | } | ||
| 216 | 217 | ||||
| 217 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | 218 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | ||
| 219 | + | if ac == nil || ac.overlays == nil { | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 218 | ac.overlays.pickerMu.Lock() | 222 | ac.overlays.pickerMu.Lock() | ||
| 219 | var target picker.Target | 223 | var target picker.Target | ||
| 220 | var ok bool | 224 | var ok bool | ||
| 221 | if ac.overlays.picker != nil { | 225 | if ac.overlays.picker != nil { | ||
| 222 | target, ok = ac.overlays.picker.Selected() | 226 | target, ok = ac.overlays.picker.Selected() | ||
| 223 | } | 227 | } | ||
| 224 | - | previous := ac.overlays.pickerPreviewSession | 228 | + | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration |
| 229 | + | ac.overlays.pickerPreviewGeneration++ | |||
| 230 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 225 | ac.overlays.pickerPreviewSession = nil | 231 | ac.overlays.pickerPreviewSession = nil | ||
| 226 | ac.overlays.pickerPreview = nil | 232 | ac.overlays.pickerPreview = nil | ||
| 227 | ac.overlays.pickerMu.Unlock() | 233 | ac.overlays.pickerMu.Unlock() | ||
| 228 | - | if previous != nil { | 234 | + | d.teardownPreviewSubscription(ac, previous, previousGeneration) |
| 229 | - | if rc := previous.renderCoordinator(); rc != nil { | |||
| 230 | - | rc.teardownPreviewFor(ac) | |||
| 231 | - | } | |||
| 232 | - | } | |||
| 233 | if !ok { | 235 | if !ok { | ||
| 234 | return | 236 | return | ||
| 235 | } | 237 | } | ||
| @@ -240,13 +242,16 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 240 | } | 242 | } | ||
| 241 | ac.overlays.pickerMu.Lock() | 243 | ac.overlays.pickerMu.Lock() | ||
| 242 | // Selection may have changed while the target was resolved. | 244 | // Selection may have changed while the target was resolved. | ||
| 243 | - | if ac.overlays.picker == nil { | 245 | + | selected, stillSelected := ac.overlays.picker.Selected() |
| 244 | - | ac.overlays.pickerMu.Unlock() | 246 | + | valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target |
| 245 | - | return | 247 | + | if valid { |
| 248 | + | ac.overlays.pickerPreview = next | |||
| 249 | + | ac.overlays.pickerPreviewSession = targetSess | |||
| 246 | } | 250 | } | ||
| 247 | - | ac.overlays.pickerPreview = next | |||
| 248 | - | ac.overlays.pickerPreviewSession = targetSess | |||
| 249 | ac.overlays.pickerMu.Unlock() | 251 | ac.overlays.pickerMu.Unlock() | ||
| 252 | + | if !valid { | |||
| 253 | + | return | |||
| 254 | + | } | |||
| 250 | // A same-session preview is already invalidated by its own coordinator. | 255 | // A same-session preview is already invalidated by its own coordinator. | ||
| 251 | // Cross-session previews subscribe to the target's producer wake instead | 256 | // Cross-session previews subscribe to the target's producer wake instead | ||
| 252 | // of starting a direct paint/timer path. | 257 | // of starting a direct paint/timer path. | ||
| @@ -254,16 +259,38 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 254 | return | 259 | return | ||
| 255 | } | 260 | } | ||
| 256 | rc := d.attachCoordinator(targetSess, nil, nil, false) | 261 | rc := d.attachCoordinator(targetSess, nil, nil, false) | ||
| 257 | - | rc.subscribePreviewFor(ac, func(renderWake) { | 262 | + | rc.subscribePreviewFor(ac, generation, func(renderWake) { |
| 258 | - | ac.overlays.pickerMu.Lock() | 263 | + | if pickerPreviewCurrent(ac, targetSess, next, generation) { |
| 259 | - | valid := ac.overlays.pickerPreviewSession == targetSess | |||
| 260 | - | ac.overlays.pickerMu.Unlock() | |||
| 261 | - | if valid { | |||
| 262 | if owner := ac.currentSession(); owner != nil { | 264 | if owner := ac.currentSession(); owner != nil { | ||
| 263 | d.invalidateRender(owner, ac, false, "picker preview") | 265 | d.invalidateRender(owner, ac, false, "picker preview") | ||
| 264 | } | 266 | } | ||
| 265 | } | 267 | } | ||
| 266 | }) | 268 | }) | ||
| 269 | + | // subscribePreviewFor is deliberately outside pickerMu. Revalidate after it | |||
| 270 | + | // returns and remove only this generation if selection changed meanwhile. | |||
| 271 | + | d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool { | |||
| 275 | + | ac.overlays.pickerMu.Lock() | |||
| 276 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 277 | + | return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // revalidatePreviewSubscription removes a subscription that lost the picker | |||
| 281 | + | // selection race after it was installed. | |||
| 282 | + | func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) { | |||
| 283 | + | if !pickerPreviewCurrent(ac, targetSess, next, generation) { | |||
| 284 | + | rc.teardownPreviewFor(ac, generation) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) { | |||
| 289 | + | if previous != nil { | |||
| 290 | + | if rc := previous.renderCoordinator(); rc != nil { | |||
| 291 | + | rc.teardownPreviewFor(ac, generation) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 267 | } | 294 | } | ||
| 268 | 295 | ||||
| 269 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | 296 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | ||
| @@ -271,15 +298,26 @@ func (d *Daemon) unregisterPreview(ac *attachedClient) { | |||||
| 271 | return | 298 | return | ||
| 272 | } | 299 | } | ||
| 273 | ac.overlays.pickerMu.Lock() | 300 | ac.overlays.pickerMu.Lock() | ||
| 301 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 302 | + | ac.overlays.pickerMu.Unlock() | |||
| 303 | + | d.clearPreviewGeneration(ac, generation) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // clearPreviewGeneration removes the preview only if it is still the observed | |||
| 307 | + | // selection. Teardown paths may race a new picker selection. | |||
| 308 | + | func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool { | |||
| 309 | + | ac.overlays.pickerMu.Lock() | |||
| 310 | + | if ac.overlays.pickerPreviewGeneration != generation { | |||
| 311 | + | ac.overlays.pickerMu.Unlock() | |||
| 312 | + | return false | |||
| 313 | + | } | |||
| 274 | previous := ac.overlays.pickerPreviewSession | 314 | previous := ac.overlays.pickerPreviewSession | ||
| 315 | + | ac.overlays.pickerPreviewGeneration++ | |||
| 275 | ac.overlays.pickerPreviewSession = nil | 316 | ac.overlays.pickerPreviewSession = nil | ||
| 276 | ac.overlays.pickerPreview = nil | 317 | ac.overlays.pickerPreview = nil | ||
| 277 | ac.overlays.pickerMu.Unlock() | 318 | ac.overlays.pickerMu.Unlock() | ||
| 278 | - | if previous != nil { | 319 | + | d.teardownPreviewSubscription(ac, previous, generation) |
| 279 | - | if rc := previous.renderCoordinator(); rc != nil { | 320 | + | return true |
| 280 | - | rc.teardownPreviewFor(ac) | |||
| 281 | - | } | |||
| 282 | - | } | |||
| 283 | } | 321 | } | ||
| 284 | 322 | ||||
| 285 | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | 323 | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | ||
| @@ -303,10 +341,10 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 303 | continue | 341 | continue | ||
| 304 | } | 342 | } | ||
| 305 | ac.overlays.pickerMu.Lock() | 343 | ac.overlays.pickerMu.Lock() | ||
| 306 | - | cleared := ac.overlays.pickerPreview == tb | 344 | + | generation := ac.overlays.pickerPreviewGeneration |
| 345 | + | observed := ac.overlays.pickerPreview == tb | |||
| 307 | ac.overlays.pickerMu.Unlock() | 346 | ac.overlays.pickerMu.Unlock() | ||
| 308 | - | if cleared { | 347 | + | if observed && d.clearPreviewGeneration(ac, generation) { |
| 309 | - | d.unregisterPreview(ac) | |||
| 310 | if sess := ac.currentSession(); sess != nil { | 348 | if sess := ac.currentSession(); sess != nil { | ||
| 311 | d.invalidateRender(sess, ac, true, "picker.go") | 349 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 312 | } | 350 | } | ||
| @@ -319,8 +357,9 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||||
| 319 | ac.overlays.picker = nil | 357 | ac.overlays.picker = nil | ||
| 320 | ac.overlays.pickerPending = nil | 358 | ac.overlays.pickerPending = nil | ||
| 321 | d.stopPickerPendingTimerLocked(ac) | 359 | d.stopPickerPendingTimerLocked(ac) | ||
| 360 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 322 | ac.overlays.pickerMu.Unlock() | 361 | ac.overlays.pickerMu.Unlock() | ||
| 323 | - | d.unregisterPreview(ac) | 362 | + | d.clearPreviewGeneration(ac, generation) |
| 324 | } | 363 | } | ||
| 325 | 364 | ||||
| 326 | func (d *Daemon) tabByTarget(target picker.Target) *tab { | 365 | func (d *Daemon) tabByTarget(target picker.Target) *tab { | ||
M internal/usecase/daemon/picker_test.go +66 -0
| @@ -477,6 +477,72 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 477 | 477 | require.NotContains(t, openedEnv, "TERM=xterm-direct") | |
| 478 | 478 | } | |
| 479 | 479 | ||
| 480 | + | func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) { | |
| 481 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 482 | + | viewer := &attachedClient{} | |
| 483 | + | newer := func(renderWake) {} | |
| 484 | + | ||
| 485 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, newer)) | |
| 486 | + | require.False(t, rc.subscribePreviewFor(viewer, 1, func(renderWake) {})) | |
| 487 | + | rc.teardownPreviewFor(viewer, 1) | |
| 488 | + | ||
| 489 | + | rc.mu.Lock() | |
| 490 | + | subscription, ok := rc.previewWakes[viewer] | |
| 491 | + | rc.mu.Unlock() | |
| 492 | + | require.True(t, ok) | |
| 493 | + | require.Equal(t, uint64(2), subscription.generation) | |
| 494 | + | require.NotNil(t, subscription.fn) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func TestPickerPreviewPostSubscribeRevalidationKeepsNewerSubscription(t *testing.T) { | |
| 498 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 499 | + | viewer := &attachedClient{} | |
| 500 | + | viewer.initOverlays() | |
| 501 | + | target := &session{} | |
| 502 | + | tab := &tab{} | |
| 503 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 504 | + | ||
| 505 | + | viewer.overlays.pickerMu.Lock() | |
| 506 | + | viewer.overlays.pickerPreviewGeneration = 2 | |
| 507 | + | viewer.overlays.pickerPreviewSession = target | |
| 508 | + | viewer.overlays.pickerPreview = tab | |
| 509 | + | viewer.overlays.pickerMu.Unlock() | |
| 510 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {})) | |
| 511 | + | ||
| 512 | + | // This is the exact post-subscribe check from registerPreviewForSelection. | |
| 513 | + | // It represents a generation-1 subscriber resuming after generation 2 won. | |
| 514 | + | d.revalidatePreviewSubscription(viewer, rc, target, tab, 1) | |
| 515 | + | ||
| 516 | + | rc.mu.Lock() | |
| 517 | + | subscription, ok := rc.previewWakes[viewer] | |
| 518 | + | rc.mu.Unlock() | |
| 519 | + | require.True(t, ok) | |
| 520 | + | require.Equal(t, uint64(2), subscription.generation) | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |
| 524 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 525 | + | viewer := &attachedClient{} | |
| 526 | + | viewer.initOverlays() | |
| 527 | + | newerSession := &session{} | |
| 528 | + | newerTab := &tab{} | |
| 529 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 530 | + | ||
| 531 | + | viewer.overlays.pickerMu.Lock() | |
| 532 | + | viewer.overlays.pickerPreviewGeneration = 2 | |
| 533 | + | viewer.overlays.pickerPreviewSession = newerSession | |
| 534 | + | viewer.overlays.pickerPreview = newerTab | |
| 535 | + | viewer.overlays.pickerMu.Unlock() | |
| 536 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {})) | |
| 537 | + | ||
| 538 | + | require.False(t, d.clearPreviewGeneration(viewer, 1)) | |
| 539 | + | require.True(t, pickerPreviewCurrent(viewer, newerSession, newerTab, 2)) | |
| 540 | + | rc.mu.Lock() | |
| 541 | + | _, subscribed := rc.previewWakes[viewer] | |
| 542 | + | rc.mu.Unlock() | |
| 543 | + | require.True(t, subscribed) | |
| 544 | + | } | |
| 545 | + | ||
| 480 | 546 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | |
| 481 | 547 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 482 | 548 | p := tb.focusedPane() | |
| @@ -477,6 +477,72 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 477 | require.NotContains(t, openedEnv, "TERM=xterm-direct") | 477 | require.NotContains(t, openedEnv, "TERM=xterm-direct") | ||
| 478 | } | 478 | } | ||
| 479 | 479 | ||||
| 480 | + | func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) { | |||
| 481 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 482 | + | viewer := &attachedClient{} | |||
| 483 | + | newer := func(renderWake) {} | |||
| 484 | + | ||||
| 485 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, newer)) | |||
| 486 | + | require.False(t, rc.subscribePreviewFor(viewer, 1, func(renderWake) {})) | |||
| 487 | + | rc.teardownPreviewFor(viewer, 1) | |||
| 488 | + | ||||
| 489 | + | rc.mu.Lock() | |||
| 490 | + | subscription, ok := rc.previewWakes[viewer] | |||
| 491 | + | rc.mu.Unlock() | |||
| 492 | + | require.True(t, ok) | |||
| 493 | + | require.Equal(t, uint64(2), subscription.generation) | |||
| 494 | + | require.NotNil(t, subscription.fn) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func TestPickerPreviewPostSubscribeRevalidationKeepsNewerSubscription(t *testing.T) { | |||
| 498 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 499 | + | viewer := &attachedClient{} | |||
| 500 | + | viewer.initOverlays() | |||
| 501 | + | target := &session{} | |||
| 502 | + | tab := &tab{} | |||
| 503 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 504 | + | ||||
| 505 | + | viewer.overlays.pickerMu.Lock() | |||
| 506 | + | viewer.overlays.pickerPreviewGeneration = 2 | |||
| 507 | + | viewer.overlays.pickerPreviewSession = target | |||
| 508 | + | viewer.overlays.pickerPreview = tab | |||
| 509 | + | viewer.overlays.pickerMu.Unlock() | |||
| 510 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {})) | |||
| 511 | + | ||||
| 512 | + | // This is the exact post-subscribe check from registerPreviewForSelection. | |||
| 513 | + | // It represents a generation-1 subscriber resuming after generation 2 won. | |||
| 514 | + | d.revalidatePreviewSubscription(viewer, rc, target, tab, 1) | |||
| 515 | + | ||||
| 516 | + | rc.mu.Lock() | |||
| 517 | + | subscription, ok := rc.previewWakes[viewer] | |||
| 518 | + | rc.mu.Unlock() | |||
| 519 | + | require.True(t, ok) | |||
| 520 | + | require.Equal(t, uint64(2), subscription.generation) | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||
| 524 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 525 | + | viewer := &attachedClient{} | |||
| 526 | + | viewer.initOverlays() | |||
| 527 | + | newerSession := &session{} | |||
| 528 | + | newerTab := &tab{} | |||
| 529 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 530 | + | ||||
| 531 | + | viewer.overlays.pickerMu.Lock() | |||
| 532 | + | viewer.overlays.pickerPreviewGeneration = 2 | |||
| 533 | + | viewer.overlays.pickerPreviewSession = newerSession | |||
| 534 | + | viewer.overlays.pickerPreview = newerTab | |||
| 535 | + | viewer.overlays.pickerMu.Unlock() | |||
| 536 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {})) | |||
| 537 | + | ||||
| 538 | + | require.False(t, d.clearPreviewGeneration(viewer, 1)) | |||
| 539 | + | require.True(t, pickerPreviewCurrent(viewer, newerSession, newerTab, 2)) | |||
| 540 | + | rc.mu.Lock() | |||
| 541 | + | _, subscribed := rc.previewWakes[viewer] | |||
| 542 | + | rc.mu.Unlock() | |||
| 543 | + | require.True(t, subscribed) | |||
| 544 | + | } | |||
| 545 | + | ||||
| 480 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | 546 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | ||
| 481 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 547 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 482 | p := tb.focusedPane() | 548 | p := tb.focusedPane() | ||
M internal/usecase/daemon/render.go +10 -4
| @@ -25,19 +25,25 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||
| 25 | 25 | if sess == nil || tb == nil || p == nil { | |
| 26 | 26 | return false | |
| 27 | 27 | } | |
| 28 | - | preview := d.tabIsPickerPreview(tb) | |
| 29 | 28 | sess.mu.Lock() | |
| 30 | 29 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 31 | 30 | attached := sess.client != nil | |
| 32 | 31 | sess.mu.Unlock() | |
| 32 | + | ||
| 33 | + | // The normal attached render path needs no cross-session picker lookup. | |
| 34 | + | // Only inactive or headless tabs can be renderable as a picker preview. | |
| 35 | + | preview := false | |
| 36 | + | if !active || !attached { | |
| 37 | + | preview = d.tabIsPickerPreview(tb) | |
| 38 | + | if !preview { | |
| 39 | + | return false | |
| 40 | + | } | |
| 41 | + | } | |
| 33 | 42 | tb.mu.Lock() | |
| 34 | 43 | defer tb.mu.Unlock() | |
| 35 | 44 | if tb.floating.pane == p { | |
| 36 | 45 | return active && attached && tb.floating.state == floatingVisible | |
| 37 | 46 | } | |
| 38 | - | if (!active || !attached) && !preview { | |
| 39 | - | return false | |
| 40 | - | } | |
| 41 | 47 | placements, ok := solvedPlacementsLocked(tb) | |
| 42 | 48 | if !ok { | |
| 43 | 49 | return false | |
| @@ -25,19 +25,25 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||||
| 25 | if sess == nil || tb == nil || p == nil { | 25 | if sess == nil || tb == nil || p == nil { | ||
| 26 | return false | 26 | return false | ||
| 27 | } | 27 | } | ||
| 28 | - | preview := d.tabIsPickerPreview(tb) | |||
| 29 | sess.mu.Lock() | 28 | sess.mu.Lock() | ||
| 30 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | 29 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | ||
| 31 | attached := sess.client != nil | 30 | attached := sess.client != nil | ||
| 32 | sess.mu.Unlock() | 31 | sess.mu.Unlock() | ||
| 32 | + | ||||
| 33 | + | // The normal attached render path needs no cross-session picker lookup. | |||
| 34 | + | // Only inactive or headless tabs can be renderable as a picker preview. | |||
| 35 | + | preview := false | |||
| 36 | + | if !active || !attached { | |||
| 37 | + | preview = d.tabIsPickerPreview(tb) | |||
| 38 | + | if !preview { | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 33 | tb.mu.Lock() | 42 | tb.mu.Lock() | ||
| 34 | defer tb.mu.Unlock() | 43 | defer tb.mu.Unlock() | ||
| 35 | if tb.floating.pane == p { | 44 | if tb.floating.pane == p { | ||
| 36 | return active && attached && tb.floating.state == floatingVisible | 45 | return active && attached && tb.floating.state == floatingVisible | ||
| 37 | } | 46 | } | ||
| 38 | - | if (!active || !attached) && !preview { | |||
| 39 | - | return false | |||
| 40 | - | } | |||
| 41 | placements, ok := solvedPlacementsLocked(tb) | 47 | placements, ok := solvedPlacementsLocked(tb) | ||
| 42 | if !ok { | 48 | if !ok { | ||
| 43 | return false | 49 | return false | ||
M internal/usecase/daemon/render_coordinator.go +35 -17
| @@ -44,7 +44,7 @@ type renderCoordinator struct { | |||
| 44 | 44 | // subscriber. previewWakes tracks picker subscriptions by viewer, so one | |
| 45 | 45 | // inactive session cannot replace another viewer's live preview. | |
| 46 | 46 | previewWake func(renderWake) | |
| 47 | - | previewWakes map[*attachedClient]func(renderWake) | |
| 47 | + | previewWakes map[*attachedClient]previewSubscription | |
| 48 | 48 | ||
| 49 | 49 | // lease is the sole primary attachment lifecycle state. A revoked lease is | |
| 50 | 50 | // retained after detach so headless preview delivery remains distinguishable | |
| @@ -457,26 +457,39 @@ func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | |||
| 457 | 457 | // teardownPreview removes the legacy preview subscription. | |
| 458 | 458 | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | |
| 459 | 459 | ||
| 460 | + | type previewSubscription struct { | |
| 461 | + | generation uint64 | |
| 462 | + | fn func(renderWake) | |
| 463 | + | } | |
| 464 | + | ||
| 460 | 465 | // subscribePreviewFor installs the dynamic picker observer owned by viewer. | |
| 461 | - | // The owner key makes target changes, detach, and session teardown precise. | |
| 462 | - | func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) { | |
| 466 | + | // A newer picker generation wins, so a delayed subscription cannot replace it. | |
| 467 | + | func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generation uint64, fn func(renderWake)) bool { | |
| 463 | 468 | if viewer == nil || fn == nil { | |
| 464 | - | return | |
| 469 | + | return false | |
| 465 | 470 | } | |
| 466 | 471 | c.mu.Lock() | |
| 467 | - | if !c.torndown { | |
| 468 | - | if c.previewWakes == nil { | |
| 469 | - | c.previewWakes = make(map[*attachedClient]func(renderWake)) | |
| 470 | - | } | |
| 471 | - | c.previewWakes[viewer] = fn | |
| 472 | + | defer c.mu.Unlock() | |
| 473 | + | if c.torndown { | |
| 474 | + | return false | |
| 472 | 475 | } | |
| 473 | - | c.mu.Unlock() | |
| 476 | + | if current, ok := c.previewWakes[viewer]; ok && current.generation > generation { | |
| 477 | + | return false | |
| 478 | + | } | |
| 479 | + | if c.previewWakes == nil { | |
| 480 | + | c.previewWakes = make(map[*attachedClient]previewSubscription) | |
| 481 | + | } | |
| 482 | + | c.previewWakes[viewer] = previewSubscription{generation: generation, fn: fn} | |
| 483 | + | return true | |
| 474 | 484 | } | |
| 475 | 485 | ||
| 476 | - | // teardownPreviewFor removes only viewer's dynamic picker observer. | |
| 477 | - | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) { | |
| 486 | + | // teardownPreviewFor removes viewer's observer only when it still belongs to | |
| 487 | + | // generation. A delayed teardown cannot clear a replacement subscription. | |
| 488 | + | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) { | |
| 478 | 489 | c.mu.Lock() | |
| 479 | - | delete(c.previewWakes, viewer) | |
| 490 | + | if current, ok := c.previewWakes[viewer]; ok && current.generation == generation { | |
| 491 | + | delete(c.previewWakes, viewer) | |
| 492 | + | } | |
| 480 | 493 | c.mu.Unlock() | |
| 481 | 494 | } | |
| 482 | 495 | ||
| @@ -758,11 +771,16 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b | |||
| 758 | 771 | c.mu.Unlock() | |
| 759 | 772 | return | |
| 760 | 773 | } | |
| 761 | - | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: c.lease} | |
| 762 | 774 | // A detached target has no primary transport or ACK window. Its pending | |
| 763 | 775 | // state belongs exclusively to picker previews and must be consumed after | |
| 764 | - | // notification rather than accumulating ackDeferred forever. | |
| 776 | + | // notification rather than accumulating ackDeferred forever. Do not pass | |
| 777 | + | // its revoked primary lease to preview consumers. | |
| 765 | 778 | headlessPreviewOnly := c.primaryDetachedLocked() | |
| 779 | + | lease := c.lease | |
| 780 | + | if headlessPreviewOnly { | |
| 781 | + | lease = nil | |
| 782 | + | } | |
| 783 | + | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease} | |
| 766 | 784 | if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) { | |
| 767 | 785 | var ackStart *ackBlockedSpan | |
| 768 | 786 | if !ready && deadline { | |
| @@ -860,8 +878,8 @@ func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []fun | |||
| 860 | 878 | } | |
| 861 | 879 | c.pendingPreview = false | |
| 862 | 880 | previews := make([]func(renderWake), 0, len(c.previewWakes)) | |
| 863 | - | for _, fn := range c.previewWakes { | |
| 864 | - | previews = append(previews, fn) | |
| 881 | + | for _, subscription := range c.previewWakes { | |
| 882 | + | previews = append(previews, subscription.fn) | |
| 865 | 883 | } | |
| 866 | 884 | return c.previewWake, previews | |
| 867 | 885 | } | |
| @@ -44,7 +44,7 @@ type renderCoordinator struct { | |||||
| 44 | // subscriber. previewWakes tracks picker subscriptions by viewer, so one | 44 | // subscriber. previewWakes tracks picker subscriptions by viewer, so one | ||
| 45 | // inactive session cannot replace another viewer's live preview. | 45 | // inactive session cannot replace another viewer's live preview. | ||
| 46 | previewWake func(renderWake) | 46 | previewWake func(renderWake) | ||
| 47 | - | previewWakes map[*attachedClient]func(renderWake) | 47 | + | previewWakes map[*attachedClient]previewSubscription |
| 48 | 48 | ||||
| 49 | // lease is the sole primary attachment lifecycle state. A revoked lease is | 49 | // lease is the sole primary attachment lifecycle state. A revoked lease is | ||
| 50 | // retained after detach so headless preview delivery remains distinguishable | 50 | // retained after detach so headless preview delivery remains distinguishable | ||
| @@ -457,26 +457,39 @@ func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | |||||
| 457 | // teardownPreview removes the legacy preview subscription. | 457 | // teardownPreview removes the legacy preview subscription. | ||
| 458 | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | 458 | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | ||
| 459 | 459 | ||||
| 460 | + | type previewSubscription struct { | |||
| 461 | + | generation uint64 | |||
| 462 | + | fn func(renderWake) | |||
| 463 | + | } | |||
| 464 | + | ||||
| 460 | // subscribePreviewFor installs the dynamic picker observer owned by viewer. | 465 | // subscribePreviewFor installs the dynamic picker observer owned by viewer. | ||
| 461 | - | // The owner key makes target changes, detach, and session teardown precise. | 466 | + | // A newer picker generation wins, so a delayed subscription cannot replace it. |
| 462 | - | func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) { | 467 | + | func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generation uint64, fn func(renderWake)) bool { |
| 463 | if viewer == nil || fn == nil { | 468 | if viewer == nil || fn == nil { | ||
| 464 | - | return | 469 | + | return false |
| 465 | } | 470 | } | ||
| 466 | c.mu.Lock() | 471 | c.mu.Lock() | ||
| 467 | - | if !c.torndown { | 472 | + | defer c.mu.Unlock() |
| 468 | - | if c.previewWakes == nil { | 473 | + | if c.torndown { |
| 469 | - | c.previewWakes = make(map[*attachedClient]func(renderWake)) | 474 | + | return false |
| 470 | - | } | |||
| 471 | - | c.previewWakes[viewer] = fn | |||
| 472 | } | 475 | } | ||
| 473 | - | c.mu.Unlock() | 476 | + | if current, ok := c.previewWakes[viewer]; ok && current.generation > generation { |
| 477 | + | return false | |||
| 478 | + | } | |||
| 479 | + | if c.previewWakes == nil { | |||
| 480 | + | c.previewWakes = make(map[*attachedClient]previewSubscription) | |||
| 481 | + | } | |||
| 482 | + | c.previewWakes[viewer] = previewSubscription{generation: generation, fn: fn} | |||
| 483 | + | return true | |||
| 474 | } | 484 | } | ||
| 475 | 485 | ||||
| 476 | - | // teardownPreviewFor removes only viewer's dynamic picker observer. | 486 | + | // teardownPreviewFor removes viewer's observer only when it still belongs to |
| 477 | - | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) { | 487 | + | // generation. A delayed teardown cannot clear a replacement subscription. |
| 488 | + | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) { | |||
| 478 | c.mu.Lock() | 489 | c.mu.Lock() | ||
| 479 | - | delete(c.previewWakes, viewer) | 490 | + | if current, ok := c.previewWakes[viewer]; ok && current.generation == generation { |
| 491 | + | delete(c.previewWakes, viewer) | |||
| 492 | + | } | |||
| 480 | c.mu.Unlock() | 493 | c.mu.Unlock() | ||
| 481 | } | 494 | } | ||
| 482 | 495 | ||||
| @@ -758,11 +771,16 @@ func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline b | |||||
| 758 | c.mu.Unlock() | 771 | c.mu.Unlock() | ||
| 759 | return | 772 | return | ||
| 760 | } | 773 | } | ||
| 761 | - | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: c.lease} | |||
| 762 | // A detached target has no primary transport or ACK window. Its pending | 774 | // A detached target has no primary transport or ACK window. Its pending | ||
| 763 | // state belongs exclusively to picker previews and must be consumed after | 775 | // state belongs exclusively to picker previews and must be consumed after | ||
| 764 | - | // notification rather than accumulating ackDeferred forever. | 776 | + | // notification rather than accumulating ackDeferred forever. Do not pass |
| 777 | + | // its revoked primary lease to preview consumers. | |||
| 765 | headlessPreviewOnly := c.primaryDetachedLocked() | 778 | headlessPreviewOnly := c.primaryDetachedLocked() | ||
| 779 | + | lease := c.lease | |||
| 780 | + | if headlessPreviewOnly { | |||
| 781 | + | lease = nil | |||
| 782 | + | } | |||
| 783 | + | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease} | |||
| 766 | if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) { | 784 | if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) { | ||
| 767 | var ackStart *ackBlockedSpan | 785 | var ackStart *ackBlockedSpan | ||
| 768 | if !ready && deadline { | 786 | if !ready && deadline { | ||
| @@ -860,8 +878,8 @@ func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []fun | |||||
| 860 | } | 878 | } | ||
| 861 | c.pendingPreview = false | 879 | c.pendingPreview = false | ||
| 862 | previews := make([]func(renderWake), 0, len(c.previewWakes)) | 880 | previews := make([]func(renderWake), 0, len(c.previewWakes)) | ||
| 863 | - | for _, fn := range c.previewWakes { | 881 | + | for _, subscription := range c.previewWakes { |
| 864 | - | previews = append(previews, fn) | 882 | + | previews = append(previews, subscription.fn) |
| 865 | } | 883 | } | ||
| 866 | return c.previewWake, previews | 884 | return c.previewWake, previews | ||
| 867 | } | 885 | } | ||
M internal/usecase/daemon/render_coordinator_handshake_test.go +0 -17
| @@ -1,29 +1,12 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "runtime" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/stretchr/testify/require" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | - | // awaitHandshakeWorker observes the worker completion channel rather than a | |
| 12 | - | // scheduler-dependent no-wake window. The generated mock timer's channel is | |
| 13 | - | // the only event that advances it. | |
| 14 | - | func awaitHandshakeWorker(t *testing.T, done <-chan struct{}) { | |
| 15 | - | t.Helper() | |
| 16 | - | for range 1_000_000 { | |
| 17 | - | select { | |
| 18 | - | case <-done: | |
| 19 | - | return | |
| 20 | - | default: | |
| 21 | - | runtime.Gosched() | |
| 22 | - | } | |
| 23 | - | } | |
| 24 | - | t.Fatal("coordinator deadline worker did not complete") | |
| 25 | - | } | |
| 26 | - | ||
| 27 | 10 | // Fresh producer state may arrive after route has published an attachment but | |
| 28 | 11 | // before the transport has accepted Welcome. It must remain pending until the | |
| 29 | 12 | // exact attachment incarnation completes that handshake. | |
| @@ -1,29 +1,12 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "runtime" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | "time" | 5 | "time" | ||
| 7 | 6 | ||||
| 8 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 8 | ) | ||
| 10 | 9 | ||||
| 11 | - | // awaitHandshakeWorker observes the worker completion channel rather than a | |||
| 12 | - | // scheduler-dependent no-wake window. The generated mock timer's channel is | |||
| 13 | - | // the only event that advances it. | |||
| 14 | - | func awaitHandshakeWorker(t *testing.T, done <-chan struct{}) { | |||
| 15 | - | t.Helper() | |||
| 16 | - | for range 1_000_000 { | |||
| 17 | - | select { | |||
| 18 | - | case <-done: | |||
| 19 | - | return | |||
| 20 | - | default: | |||
| 21 | - | runtime.Gosched() | |||
| 22 | - | } | |||
| 23 | - | } | |||
| 24 | - | t.Fatal("coordinator deadline worker did not complete") | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | // Fresh producer state may arrive after route has published an attachment but | 10 | // Fresh producer state may arrive after route has published an attachment but | ||
| 28 | // before the transport has accepted Welcome. It must remain pending until the | 11 | // before the transport has accepted Welcome. It must remain pending until the | ||
| 29 | // exact attachment incarnation completes that handshake. | 12 | // exact attachment incarnation completes that handshake. | ||
M internal/usecase/daemon/render_coordinator_test.go +46 -89
| @@ -3,7 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | - | "runtime" | |
| 7 | 6 | "sync" | |
| 8 | 7 | "sync/atomic" | |
| 9 | 8 | "testing" | |
| @@ -120,15 +119,7 @@ func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) { | |||
| 120 | 119 | } | |
| 121 | 120 | ||
| 122 | 121 | close(release) | |
| 123 | - | for range 4096 { | |
| 124 | - | select { | |
| 125 | - | case <-detached: | |
| 126 | - | return | |
| 127 | - | default: | |
| 128 | - | runtime.Gosched() | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | t.Fatal("detach did not complete after the deadline worker was released") | |
| 122 | + | awaitTestCompletion(t, detached, "detach did not complete after the deadline worker was released") | |
| 132 | 123 | } | |
| 133 | 124 | ||
| 134 | 125 | func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) { | |
| @@ -207,16 +198,7 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer { | |||
| 207 | 198 | // deterministic contract failure, not a slow behavior to poll for. | |
| 208 | 199 | func awaitWake(t *testing.T, ch chan renderWake) renderWake { | |
| 209 | 200 | t.Helper() | |
| 210 | - | for range 4096 { | |
| 211 | - | select { | |
| 212 | - | case w := <-ch: | |
| 213 | - | return w | |
| 214 | - | default: | |
| 215 | - | runtime.Gosched() | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | t.Fatal("coordinator did not publish a wake after fake-clock advancement") | |
| 219 | - | return renderWake{} | |
| 201 | + | return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement") | |
| 220 | 202 | } | |
| 221 | 203 | ||
| 222 | 204 | func requireNoWake(t *testing.T, ch chan renderWake) { | |
| @@ -230,16 +212,7 @@ func requireNoWake(t *testing.T, ch chan renderWake) { | |||
| 230 | 212 | ||
| 231 | 213 | func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation { | |
| 232 | 214 | t.Helper() | |
| 233 | - | for range 4096 { | |
| 234 | - | select { | |
| 235 | - | case inv := <-ch: | |
| 236 | - | return inv | |
| 237 | - | default: | |
| 238 | - | runtime.Gosched() | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | t.Fatal("producer did not publish a coordinator invalidation") | |
| 242 | - | return renderInvalidation{} | |
| 215 | + | return awaitTestValue(t, ch, "producer did not publish a coordinator invalidation") | |
| 243 | 216 | } | |
| 244 | 217 | ||
| 245 | 218 | func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) { | |
| @@ -444,40 +417,16 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T | |||
| 444 | 417 | rc.fire(1, false, true) | |
| 445 | 418 | close(fireDone) | |
| 446 | 419 | }() | |
| 447 | - | for range 4096 { | |
| 448 | - | select { | |
| 449 | - | case <-ackEntered: | |
| 450 | - | goto resize | |
| 451 | - | default: | |
| 452 | - | runtime.Gosched() | |
| 453 | - | } | |
| 454 | - | } | |
| 455 | - | t.Fatal("ack readiness was not evaluated") | |
| 420 | + | awaitTestCompletion(t, ackEntered, "ack readiness was not evaluated") | |
| 456 | 421 | ||
| 457 | - | resize: | |
| 458 | 422 | go func() { resizeDone <- rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 40}, owner) }() | |
| 459 | - | for range 4096 { | |
| 460 | - | select { | |
| 461 | - | case epoch := <-resizeDone: | |
| 462 | - | require.Equal(t, uint64(1), epoch) | |
| 463 | - | wake := awaitWake(t, wakes) | |
| 464 | - | require.Same(t, owner, wake.lease.attachment) | |
| 465 | - | wake.lease = nil | |
| 466 | - | require.Equal(t, renderWake{coalesced: 1}, wake) | |
| 467 | - | for range 4096 { | |
| 468 | - | select { | |
| 469 | - | case <-fireDone: | |
| 470 | - | return | |
| 471 | - | default: | |
| 472 | - | runtime.Gosched() | |
| 473 | - | } | |
| 474 | - | } | |
| 475 | - | t.Fatal("fire did not return after wake") | |
| 476 | - | default: | |
| 477 | - | runtime.Gosched() | |
| 478 | - | } | |
| 479 | - | } | |
| 480 | - | t.Fatal("ack readiness re-entry blocked resize metadata") | |
| 423 | + | epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata") | |
| 424 | + | require.Equal(t, uint64(1), epoch) | |
| 425 | + | wake := awaitWake(t, wakes) | |
| 426 | + | require.Same(t, owner, wake.lease.attachment) | |
| 427 | + | wake.lease = nil | |
| 428 | + | require.Equal(t, renderWake{coalesced: 1}, wake) | |
| 429 | + | awaitTestCompletion(t, fireDone, "fire did not return after wake") | |
| 481 | 430 | } | |
| 482 | 431 | ||
| 483 | 432 | func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) { | |
| @@ -704,17 +653,8 @@ func TestRenderCoordinatorSyncGateRetriesWhenBatchPublishesDuringPredicate(t *te | |||
| 704 | 653 | // pending consumption. A stale gate result must be rejected and retried. | |
| 705 | 654 | h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true }) | |
| 706 | 655 | close(releaseGate) | |
| 707 | - | for range 4096 { | |
| 708 | - | select { | |
| 709 | - | case <-fireDone: | |
| 710 | - | goto fired | |
| 711 | - | default: | |
| 712 | - | runtime.Gosched() | |
| 713 | - | } | |
| 714 | - | } | |
| 715 | - | t.Fatal("fire did not finish after retrying the registry snapshot") | |
| 656 | + | awaitTestCompletion(t, fireDone, "fire did not finish after retrying the registry snapshot") | |
| 716 | 657 | ||
| 717 | - | fired: | |
| 718 | 658 | requireNoWake(t, h.wakes) | |
| 719 | 659 | h.rc.noteSyncEnd(second, 2) | |
| 720 | 660 | require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes)) | |
| @@ -844,7 +784,7 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) { | |||
| 844 | 784 | h.rc.attach(&attachedClient{}) | |
| 845 | 785 | } | |
| 846 | 786 | viewer := &attachedClient{} | |
| 847 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |
| 787 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) | |
| 848 | 788 | ||
| 849 | 789 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"}) | |
| 850 | 790 | timers := h.armedTimers(t) | |
| @@ -880,7 +820,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||
| 880 | 820 | target, viewer := &attachedClient{}, &attachedClient{} | |
| 881 | 821 | h.ackReady.Store(false) | |
| 882 | 822 | h.rc.attach(target) | |
| 883 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |
| 823 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) | |
| 884 | 824 | h.rc.noteDetach(target) | |
| 885 | 825 | ||
| 886 | 826 | // A detached target only accepts PTY/session-owned invalidations. The old | |
| @@ -894,7 +834,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||
| 894 | 834 | require.Len(t, timers, 1) | |
| 895 | 835 | timers[0].ch <- time.Time{} | |
| 896 | 836 | preview := awaitWake(t, h.previews) | |
| 897 | - | preview.lease = nil | |
| 837 | + | require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease") | |
| 898 | 838 | require.Equal(t, renderWake{coalesced: 1}, preview) | |
| 899 | 839 | requireNoWake(t, h.wakes) | |
| 900 | 840 | ||
| @@ -937,7 +877,7 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |||
| 937 | 877 | h := newCoordinatorHarness(t) | |
| 938 | 878 | target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{} | |
| 939 | 879 | h.rc.attach(target) | |
| 940 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |
| 880 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) | |
| 941 | 881 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"}) | |
| 942 | 882 | stale := h.armedTimers(t) | |
| 943 | 883 | require.Len(t, stale, 1) | |
| @@ -954,8 +894,8 @@ func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) { | |||
| 954 | 894 | h := newCoordinatorHarness(t) | |
| 955 | 895 | one, two := &attachedClient{}, &attachedClient{} | |
| 956 | 896 | first, second := make(chan renderWake, 1), make(chan renderWake, 1) | |
| 957 | - | h.rc.subscribePreviewFor(one, func(w renderWake) { first <- w }) | |
| 958 | - | h.rc.subscribePreviewFor(two, func(w renderWake) { second <- w }) | |
| 897 | + | h.rc.subscribePreviewFor(one, 1, func(w renderWake) { first <- w }) | |
| 898 | + | h.rc.subscribePreviewFor(two, 1, func(w renderWake) { second <- w }) | |
| 959 | 899 | h.rc.attach(&attachedClient{}) | |
| 960 | 900 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"}) | |
| 961 | 901 | last := h.armedTimers(t) | |
| @@ -963,7 +903,7 @@ func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) { | |||
| 963 | 903 | awaitWake(t, first) | |
| 964 | 904 | awaitWake(t, second) | |
| 965 | 905 | ||
| 966 | - | h.rc.teardownPreviewFor(one) | |
| 906 | + | h.rc.teardownPreviewFor(one, 1) | |
| 967 | 907 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"}) | |
| 968 | 908 | last = h.armedTimers(t) | |
| 969 | 909 | last[len(last)-1].ch <- time.Time{} | |
| @@ -1336,15 +1276,7 @@ func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) { | |||
| 1336 | 1276 | ||
| 1337 | 1277 | func requireWorkerExit(t *testing.T, done <-chan struct{}) { | |
| 1338 | 1278 | t.Helper() | |
| 1339 | - | for range 4096 { | |
| 1340 | - | select { | |
| 1341 | - | case <-done: | |
| 1342 | - | return | |
| 1343 | - | default: | |
| 1344 | - | runtime.Gosched() | |
| 1345 | - | } | |
| 1346 | - | } | |
| 1347 | - | t.Fatal("cancelled coordinator timer worker did not exit") | |
| 1279 | + | awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit") | |
| 1348 | 1280 | } | |
| 1349 | 1281 | ||
| 1350 | 1282 | func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) { | |
| @@ -1405,6 +1337,31 @@ func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) { | |||
| 1405 | 1337 | rc.mu.Unlock() | |
| 1406 | 1338 | } | |
| 1407 | 1339 | ||
| 1340 | + | func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) { | |
| 1341 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 1342 | + | owner := &attachedClient{} | |
| 1343 | + | rc.attach(owner) | |
| 1344 | + | lease := rc.attachmentLease(owner) | |
| 1345 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 40}, owner, lease) | |
| 1346 | + | require.NotZero(t, epoch) | |
| 1347 | + | require.True(t, rc.resizeCurrentForLease(epoch, owner, lease, true)) | |
| 1348 | + | ||
| 1349 | + | called := false | |
| 1350 | + | rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true }) | |
| 1351 | + | require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry") | |
| 1352 | + | rc.mu.Lock() | |
| 1353 | + | retryTimer, retryCancel, retryDone := rc.retryTimer, rc.retryCancel, rc.retryDone | |
| 1354 | + | rc.mu.Unlock() | |
| 1355 | + | require.Nil(t, retryTimer) | |
| 1356 | + | require.Nil(t, retryCancel) | |
| 1357 | + | require.NotNil(t, retryDone) | |
| 1358 | + | select { | |
| 1359 | + | case <-retryDone: | |
| 1360 | + | default: | |
| 1361 | + | t.Fatal("nil-clock retry did not publish its completion edge") | |
| 1362 | + | } | |
| 1363 | + | } | |
| 1364 | + | ||
| 1408 | 1365 | func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) { | |
| 1409 | 1366 | clock := portsmocks.NewMockClock(t) | |
| 1410 | 1367 | timer := portsmocks.NewMockTimer(t) | |
| @@ -1442,7 +1399,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||
| 1442 | 1399 | h := newCoordinatorHarness(t) | |
| 1443 | 1400 | target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{} | |
| 1444 | 1401 | h.rc.attach(target) | |
| 1445 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |
| 1402 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) | |
| 1446 | 1403 | h.rc.noteSyncBegin(p, 1) | |
| 1447 | 1404 | watchdog := h.armedTimers(t)[0] | |
| 1448 | 1405 | transition.run(h.rc, target) | |
| @@ -3,7 +3,6 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | - | "runtime" | |||
| 7 | "sync" | 6 | "sync" | ||
| 8 | "sync/atomic" | 7 | "sync/atomic" | ||
| 9 | "testing" | 8 | "testing" | ||
| @@ -120,15 +119,7 @@ func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) { | |||||
| 120 | } | 119 | } | ||
| 121 | 120 | ||||
| 122 | close(release) | 121 | close(release) | ||
| 123 | - | for range 4096 { | 122 | + | awaitTestCompletion(t, detached, "detach did not complete after the deadline worker was released") |
| 124 | - | select { | |||
| 125 | - | case <-detached: | |||
| 126 | - | return | |||
| 127 | - | default: | |||
| 128 | - | runtime.Gosched() | |||
| 129 | - | } | |||
| 130 | - | } | |||
| 131 | - | t.Fatal("detach did not complete after the deadline worker was released") | |||
| 132 | } | 123 | } | ||
| 133 | 124 | ||||
| 134 | func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) { | 125 | func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) { | ||
| @@ -207,16 +198,7 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer { | |||||
| 207 | // deterministic contract failure, not a slow behavior to poll for. | 198 | // deterministic contract failure, not a slow behavior to poll for. | ||
| 208 | func awaitWake(t *testing.T, ch chan renderWake) renderWake { | 199 | func awaitWake(t *testing.T, ch chan renderWake) renderWake { | ||
| 209 | t.Helper() | 200 | t.Helper() | ||
| 210 | - | for range 4096 { | 201 | + | return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement") |
| 211 | - | select { | |||
| 212 | - | case w := <-ch: | |||
| 213 | - | return w | |||
| 214 | - | default: | |||
| 215 | - | runtime.Gosched() | |||
| 216 | - | } | |||
| 217 | - | } | |||
| 218 | - | t.Fatal("coordinator did not publish a wake after fake-clock advancement") | |||
| 219 | - | return renderWake{} | |||
| 220 | } | 202 | } | ||
| 221 | 203 | ||||
| 222 | func requireNoWake(t *testing.T, ch chan renderWake) { | 204 | func requireNoWake(t *testing.T, ch chan renderWake) { | ||
| @@ -230,16 +212,7 @@ func requireNoWake(t *testing.T, ch chan renderWake) { | |||||
| 230 | 212 | ||||
| 231 | func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation { | 213 | func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation { | ||
| 232 | t.Helper() | 214 | t.Helper() | ||
| 233 | - | for range 4096 { | 215 | + | return awaitTestValue(t, ch, "producer did not publish a coordinator invalidation") |
| 234 | - | select { | |||
| 235 | - | case inv := <-ch: | |||
| 236 | - | return inv | |||
| 237 | - | default: | |||
| 238 | - | runtime.Gosched() | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | t.Fatal("producer did not publish a coordinator invalidation") | |||
| 242 | - | return renderInvalidation{} | |||
| 243 | } | 216 | } | ||
| 244 | 217 | ||||
| 245 | func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) { | 218 | func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) { | ||
| @@ -444,40 +417,16 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T | |||||
| 444 | rc.fire(1, false, true) | 417 | rc.fire(1, false, true) | ||
| 445 | close(fireDone) | 418 | close(fireDone) | ||
| 446 | }() | 419 | }() | ||
| 447 | - | for range 4096 { | 420 | + | awaitTestCompletion(t, ackEntered, "ack readiness was not evaluated") |
| 448 | - | select { | |||
| 449 | - | case <-ackEntered: | |||
| 450 | - | goto resize | |||
| 451 | - | default: | |||
| 452 | - | runtime.Gosched() | |||
| 453 | - | } | |||
| 454 | - | } | |||
| 455 | - | t.Fatal("ack readiness was not evaluated") | |||
| 456 | 421 | ||||
| 457 | - | resize: | |||
| 458 | go func() { resizeDone <- rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 40}, owner) }() | 422 | go func() { resizeDone <- rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 40}, owner) }() | ||
| 459 | - | for range 4096 { | 423 | + | epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata") |
| 460 | - | select { | 424 | + | require.Equal(t, uint64(1), epoch) |
| 461 | - | case epoch := <-resizeDone: | 425 | + | wake := awaitWake(t, wakes) |
| 462 | - | require.Equal(t, uint64(1), epoch) | 426 | + | require.Same(t, owner, wake.lease.attachment) |
| 463 | - | wake := awaitWake(t, wakes) | 427 | + | wake.lease = nil |
| 464 | - | require.Same(t, owner, wake.lease.attachment) | 428 | + | require.Equal(t, renderWake{coalesced: 1}, wake) |
| 465 | - | wake.lease = nil | 429 | + | awaitTestCompletion(t, fireDone, "fire did not return after wake") |
| 466 | - | require.Equal(t, renderWake{coalesced: 1}, wake) | |||
| 467 | - | for range 4096 { | |||
| 468 | - | select { | |||
| 469 | - | case <-fireDone: | |||
| 470 | - | return | |||
| 471 | - | default: | |||
| 472 | - | runtime.Gosched() | |||
| 473 | - | } | |||
| 474 | - | } | |||
| 475 | - | t.Fatal("fire did not return after wake") | |||
| 476 | - | default: | |||
| 477 | - | runtime.Gosched() | |||
| 478 | - | } | |||
| 479 | - | } | |||
| 480 | - | t.Fatal("ack readiness re-entry blocked resize metadata") | |||
| 481 | } | 430 | } | ||
| 482 | 431 | ||||
| 483 | func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) { | 432 | func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) { | ||
| @@ -704,17 +653,8 @@ func TestRenderCoordinatorSyncGateRetriesWhenBatchPublishesDuringPredicate(t *te | |||||
| 704 | // pending consumption. A stale gate result must be rejected and retried. | 653 | // pending consumption. A stale gate result must be rejected and retried. | ||
| 705 | h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true }) | 654 | h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true }) | ||
| 706 | close(releaseGate) | 655 | close(releaseGate) | ||
| 707 | - | for range 4096 { | 656 | + | awaitTestCompletion(t, fireDone, "fire did not finish after retrying the registry snapshot") |
| 708 | - | select { | |||
| 709 | - | case <-fireDone: | |||
| 710 | - | goto fired | |||
| 711 | - | default: | |||
| 712 | - | runtime.Gosched() | |||
| 713 | - | } | |||
| 714 | - | } | |||
| 715 | - | t.Fatal("fire did not finish after retrying the registry snapshot") | |||
| 716 | 657 | ||||
| 717 | - | fired: | |||
| 718 | requireNoWake(t, h.wakes) | 658 | requireNoWake(t, h.wakes) | ||
| 719 | h.rc.noteSyncEnd(second, 2) | 659 | h.rc.noteSyncEnd(second, 2) | ||
| 720 | require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes)) | 660 | require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes)) | ||
| @@ -844,7 +784,7 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) { | |||||
| 844 | h.rc.attach(&attachedClient{}) | 784 | h.rc.attach(&attachedClient{}) | ||
| 845 | } | 785 | } | ||
| 846 | viewer := &attachedClient{} | 786 | viewer := &attachedClient{} | ||
| 847 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | 787 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) |
| 848 | 788 | ||||
| 849 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"}) | 789 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"}) | ||
| 850 | timers := h.armedTimers(t) | 790 | timers := h.armedTimers(t) | ||
| @@ -880,7 +820,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||||
| 880 | target, viewer := &attachedClient{}, &attachedClient{} | 820 | target, viewer := &attachedClient{}, &attachedClient{} | ||
| 881 | h.ackReady.Store(false) | 821 | h.ackReady.Store(false) | ||
| 882 | h.rc.attach(target) | 822 | h.rc.attach(target) | ||
| 883 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | 823 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) |
| 884 | h.rc.noteDetach(target) | 824 | h.rc.noteDetach(target) | ||
| 885 | 825 | ||||
| 886 | // A detached target only accepts PTY/session-owned invalidations. The old | 826 | // A detached target only accepts PTY/session-owned invalidations. The old | ||
| @@ -894,7 +834,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||||
| 894 | require.Len(t, timers, 1) | 834 | require.Len(t, timers, 1) | ||
| 895 | timers[0].ch <- time.Time{} | 835 | timers[0].ch <- time.Time{} | ||
| 896 | preview := awaitWake(t, h.previews) | 836 | preview := awaitWake(t, h.previews) | ||
| 897 | - | preview.lease = nil | 837 | + | require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease") |
| 898 | require.Equal(t, renderWake{coalesced: 1}, preview) | 838 | require.Equal(t, renderWake{coalesced: 1}, preview) | ||
| 899 | requireNoWake(t, h.wakes) | 839 | requireNoWake(t, h.wakes) | ||
| 900 | 840 | ||||
| @@ -937,7 +877,7 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |||||
| 937 | h := newCoordinatorHarness(t) | 877 | h := newCoordinatorHarness(t) | ||
| 938 | target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{} | 878 | target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{} | ||
| 939 | h.rc.attach(target) | 879 | h.rc.attach(target) | ||
| 940 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | 880 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) |
| 941 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"}) | 881 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"}) | ||
| 942 | stale := h.armedTimers(t) | 882 | stale := h.armedTimers(t) | ||
| 943 | require.Len(t, stale, 1) | 883 | require.Len(t, stale, 1) | ||
| @@ -954,8 +894,8 @@ func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) { | |||||
| 954 | h := newCoordinatorHarness(t) | 894 | h := newCoordinatorHarness(t) | ||
| 955 | one, two := &attachedClient{}, &attachedClient{} | 895 | one, two := &attachedClient{}, &attachedClient{} | ||
| 956 | first, second := make(chan renderWake, 1), make(chan renderWake, 1) | 896 | first, second := make(chan renderWake, 1), make(chan renderWake, 1) | ||
| 957 | - | h.rc.subscribePreviewFor(one, func(w renderWake) { first <- w }) | 897 | + | h.rc.subscribePreviewFor(one, 1, func(w renderWake) { first <- w }) |
| 958 | - | h.rc.subscribePreviewFor(two, func(w renderWake) { second <- w }) | 898 | + | h.rc.subscribePreviewFor(two, 1, func(w renderWake) { second <- w }) |
| 959 | h.rc.attach(&attachedClient{}) | 899 | h.rc.attach(&attachedClient{}) | ||
| 960 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"}) | 900 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"}) | ||
| 961 | last := h.armedTimers(t) | 901 | last := h.armedTimers(t) | ||
| @@ -963,7 +903,7 @@ func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) { | |||||
| 963 | awaitWake(t, first) | 903 | awaitWake(t, first) | ||
| 964 | awaitWake(t, second) | 904 | awaitWake(t, second) | ||
| 965 | 905 | ||||
| 966 | - | h.rc.teardownPreviewFor(one) | 906 | + | h.rc.teardownPreviewFor(one, 1) |
| 967 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"}) | 907 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"}) | ||
| 968 | last = h.armedTimers(t) | 908 | last = h.armedTimers(t) | ||
| 969 | last[len(last)-1].ch <- time.Time{} | 909 | last[len(last)-1].ch <- time.Time{} | ||
| @@ -1336,15 +1276,7 @@ func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) { | |||||
| 1336 | 1276 | ||||
| 1337 | func requireWorkerExit(t *testing.T, done <-chan struct{}) { | 1277 | func requireWorkerExit(t *testing.T, done <-chan struct{}) { | ||
| 1338 | t.Helper() | 1278 | t.Helper() | ||
| 1339 | - | for range 4096 { | 1279 | + | awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit") |
| 1340 | - | select { | |||
| 1341 | - | case <-done: | |||
| 1342 | - | return | |||
| 1343 | - | default: | |||
| 1344 | - | runtime.Gosched() | |||
| 1345 | - | } | |||
| 1346 | - | } | |||
| 1347 | - | t.Fatal("cancelled coordinator timer worker did not exit") | |||
| 1348 | } | 1280 | } | ||
| 1349 | 1281 | ||||
| 1350 | func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) { | 1282 | func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) { | ||
| @@ -1405,6 +1337,31 @@ func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) { | |||||
| 1405 | rc.mu.Unlock() | 1337 | rc.mu.Unlock() | ||
| 1406 | } | 1338 | } | ||
| 1407 | 1339 | ||||
| 1340 | + | func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) { | |||
| 1341 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 1342 | + | owner := &attachedClient{} | |||
| 1343 | + | rc.attach(owner) | |||
| 1344 | + | lease := rc.attachmentLease(owner) | |||
| 1345 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 40}, owner, lease) | |||
| 1346 | + | require.NotZero(t, epoch) | |||
| 1347 | + | require.True(t, rc.resizeCurrentForLease(epoch, owner, lease, true)) | |||
| 1348 | + | ||||
| 1349 | + | called := false | |||
| 1350 | + | rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true }) | |||
| 1351 | + | require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry") | |||
| 1352 | + | rc.mu.Lock() | |||
| 1353 | + | retryTimer, retryCancel, retryDone := rc.retryTimer, rc.retryCancel, rc.retryDone | |||
| 1354 | + | rc.mu.Unlock() | |||
| 1355 | + | require.Nil(t, retryTimer) | |||
| 1356 | + | require.Nil(t, retryCancel) | |||
| 1357 | + | require.NotNil(t, retryDone) | |||
| 1358 | + | select { | |||
| 1359 | + | case <-retryDone: | |||
| 1360 | + | default: | |||
| 1361 | + | t.Fatal("nil-clock retry did not publish its completion edge") | |||
| 1362 | + | } | |||
| 1363 | + | } | |||
| 1364 | + | ||||
| 1408 | func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) { | 1365 | func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) { | ||
| 1409 | clock := portsmocks.NewMockClock(t) | 1366 | clock := portsmocks.NewMockClock(t) | ||
| 1410 | timer := portsmocks.NewMockTimer(t) | 1367 | timer := portsmocks.NewMockTimer(t) | ||
| @@ -1442,7 +1399,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||||
| 1442 | h := newCoordinatorHarness(t) | 1399 | h := newCoordinatorHarness(t) | ||
| 1443 | target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{} | 1400 | target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{} | ||
| 1444 | h.rc.attach(target) | 1401 | h.rc.attach(target) | ||
| 1445 | - | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | 1402 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) |
| 1446 | h.rc.noteSyncBegin(p, 1) | 1403 | h.rc.noteSyncBegin(p, 1) | ||
| 1447 | watchdog := h.armedTimers(t)[0] | 1404 | watchdog := h.armedTimers(t)[0] | ||
| 1448 | transition.run(h.rc, target) | 1405 | transition.run(h.rc, target) | ||
M internal/usecase/daemon/render_coordinator_types.go +7 -13
| @@ -12,13 +12,9 @@ import ( | |||
| 12 | 12 | // Coordinator contracts and support types are kept with their ownership | |
| 13 | 13 | // semantics, separate from the scheduling transitions in render_coordinator. | |
| 14 | 14 | ||
| 15 | - | // S1 render coordinator contract. One coordinator serves one attached | |
| 16 | - | // session: producers mutate authoritative state and publish an invalidation; | |
| 17 | - | // only the coordinator decides when the transitional composition target runs. | |
| 18 | - | // Producers never compose or send. This file currently carries the inert | |
| 19 | - | // contract — state, options, and signatures exist so the behavioral | |
| 20 | - | // specification compiles — while wake scheduling, ACK gating, synchronized | |
| 21 | - | // output handling, and producer migration land in the GREEN slice. | |
| 15 | + | // One coordinator serves a session: producers mutate authoritative state and | |
| 16 | + | // publish an invalidation; only the coordinator decides when composition runs. | |
| 17 | + | // Producers never compose or send. | |
| 22 | 18 | ||
| 23 | 19 | // Coordinator deadline bounds. Urgent transitions (input echo, overlay state | |
| 24 | 20 | // changes) must become visible within urgentRenderDeadline; bulk output | |
| @@ -50,8 +46,7 @@ type renderInvalidation struct { | |||
| 50 | 46 | producer string | |
| 51 | 47 | } | |
| 52 | 48 | ||
| 53 | - | // renderWake is one coalesced composition request delivered to the | |
| 54 | - | // transitional composition target (replaced by the S2 pipeline). | |
| 49 | + | // renderWake is one coalesced composition request. | |
| 55 | 50 | type renderWake struct { | |
| 56 | 51 | reset bool | |
| 57 | 52 | urgent bool | |
| @@ -78,9 +73,8 @@ type attachmentLease struct { | |||
| 78 | 73 | } | |
| 79 | 74 | ||
| 80 | 75 | // resizeRequestMetadata is the coordinator-owned latest requested resize | |
| 81 | - | // state: S3's transaction entry point reads it through resizeSnapshot. It is | |
| 82 | - | // metadata ownership only — S1/S2 dispatch stays with the coordinator | |
| 83 | - | // epoch timer path. | |
| 76 | + | // state. Resize dispatch reads it through resizeSnapshot and validates the | |
| 77 | + | // coordinator epoch before applying it. | |
| 84 | 78 | type resizeRequestMetadata struct { | |
| 85 | 79 | size domain.Size | |
| 86 | 80 | source *attachedClient | |
| @@ -95,7 +89,7 @@ type resizeRequestMetadata struct { | |||
| 95 | 89 | type renderCoordinatorOptions struct { | |
| 96 | 90 | clock ports.Clock | |
| 97 | 91 | observer ports.RuntimeObserver | |
| 98 | - | // wake is the transitional composition target. | |
| 92 | + | // wake composes a coalesced render request. | |
| 99 | 93 | wake func(renderWake) | |
| 100 | 94 | // ackReady reports whether the attachment may compose another output | |
| 101 | 95 | // state (the outputStateStream window has capacity). | |
| @@ -12,13 +12,9 @@ import ( | |||||
| 12 | // Coordinator contracts and support types are kept with their ownership | 12 | // Coordinator contracts and support types are kept with their ownership | ||
| 13 | // semantics, separate from the scheduling transitions in render_coordinator. | 13 | // semantics, separate from the scheduling transitions in render_coordinator. | ||
| 14 | 14 | ||||
| 15 | - | // S1 render coordinator contract. One coordinator serves one attached | 15 | + | // One coordinator serves a session: producers mutate authoritative state and |
| 16 | - | // session: producers mutate authoritative state and publish an invalidation; | 16 | + | // publish an invalidation; only the coordinator decides when composition runs. |
| 17 | - | // only the coordinator decides when the transitional composition target runs. | 17 | + | // Producers never compose or send. |
| 18 | - | // Producers never compose or send. This file currently carries the inert | |||
| 19 | - | // contract — state, options, and signatures exist so the behavioral | |||
| 20 | - | // specification compiles — while wake scheduling, ACK gating, synchronized | |||
| 21 | - | // output handling, and producer migration land in the GREEN slice. | |||
| 22 | 18 | ||||
| 23 | // Coordinator deadline bounds. Urgent transitions (input echo, overlay state | 19 | // Coordinator deadline bounds. Urgent transitions (input echo, overlay state | ||
| 24 | // changes) must become visible within urgentRenderDeadline; bulk output | 20 | // changes) must become visible within urgentRenderDeadline; bulk output | ||
| @@ -50,8 +46,7 @@ type renderInvalidation struct { | |||||
| 50 | producer string | 46 | producer string | ||
| 51 | } | 47 | } | ||
| 52 | 48 | ||||
| 53 | - | // renderWake is one coalesced composition request delivered to the | 49 | + | // renderWake is one coalesced composition request. |
| 54 | - | // transitional composition target (replaced by the S2 pipeline). | |||
| 55 | type renderWake struct { | 50 | type renderWake struct { | ||
| 56 | reset bool | 51 | reset bool | ||
| 57 | urgent bool | 52 | urgent bool | ||
| @@ -78,9 +73,8 @@ type attachmentLease struct { | |||||
| 78 | } | 73 | } | ||
| 79 | 74 | ||||
| 80 | // resizeRequestMetadata is the coordinator-owned latest requested resize | 75 | // resizeRequestMetadata is the coordinator-owned latest requested resize | ||
| 81 | - | // state: S3's transaction entry point reads it through resizeSnapshot. It is | 76 | + | // state. Resize dispatch reads it through resizeSnapshot and validates the |
| 82 | - | // metadata ownership only — S1/S2 dispatch stays with the coordinator | 77 | + | // coordinator epoch before applying it. |
| 83 | - | // epoch timer path. | |||
| 84 | type resizeRequestMetadata struct { | 78 | type resizeRequestMetadata struct { | ||
| 85 | size domain.Size | 79 | size domain.Size | ||
| 86 | source *attachedClient | 80 | source *attachedClient | ||
| @@ -95,7 +89,7 @@ type resizeRequestMetadata struct { | |||||
| 95 | type renderCoordinatorOptions struct { | 89 | type renderCoordinatorOptions struct { | ||
| 96 | clock ports.Clock | 90 | clock ports.Clock | ||
| 97 | observer ports.RuntimeObserver | 91 | observer ports.RuntimeObserver | ||
| 98 | - | // wake is the transitional composition target. | 92 | + | // wake composes a coalesced render request. |
| 99 | wake func(renderWake) | 93 | wake func(renderWake) | ||
| 100 | // ackReady reports whether the attachment may compose another output | 94 | // ackReady reports whether the attachment may compose another output | ||
| 101 | // state (the outputStateStream window has capacity). | 95 | // state (the outputStateStream window has capacity). | ||
M internal/usecase/daemon/render_resize_deadline.go +2 -3
| @@ -128,12 +128,11 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at | |||
| 128 | 128 | c.mu.Unlock() | |
| 129 | 129 | stopAndJoinTimerWorker(old, nil) | |
| 130 | 130 | if clock == nil { | |
| 131 | + | // A nil clock disables deferred retries. Completing the generation keeps | |
| 132 | + | // retry bookkeeping consistent without recursively re-entering resize. | |
| 131 | 133 | c.mu.Lock() | |
| 132 | 134 | c.retryTimerOwner().completeLocked(gen) | |
| 133 | 135 | c.mu.Unlock() | |
| 134 | - | if c.retryCurrentForLease(epoch, source, lease) { | |
| 135 | - | run() | |
| 136 | - | } | |
| 137 | 136 | return | |
| 138 | 137 | } | |
| 139 | 138 | timer := clock.NewTimer(minOutputRenderDeadline) | |
| @@ -128,12 +128,11 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at | |||||
| 128 | c.mu.Unlock() | 128 | c.mu.Unlock() | ||
| 129 | stopAndJoinTimerWorker(old, nil) | 129 | stopAndJoinTimerWorker(old, nil) | ||
| 130 | if clock == nil { | 130 | if clock == nil { | ||
| 131 | + | // A nil clock disables deferred retries. Completing the generation keeps | |||
| 132 | + | // retry bookkeeping consistent without recursively re-entering resize. | |||
| 131 | c.mu.Lock() | 133 | c.mu.Lock() | ||
| 132 | c.retryTimerOwner().completeLocked(gen) | 134 | c.retryTimerOwner().completeLocked(gen) | ||
| 133 | c.mu.Unlock() | 135 | c.mu.Unlock() | ||
| 134 | - | if c.retryCurrentForLease(epoch, source, lease) { | |||
| 135 | - | run() | |||
| 136 | - | } | |||
| 137 | return | 136 | return | ||
| 138 | } | 137 | } | ||
| 139 | timer := clock.NewTimer(minOutputRenderDeadline) | 138 | timer := clock.NewTimer(minOutputRenderDeadline) | ||
M internal/usecase/daemon/render_test.go +17 -1
| @@ -405,7 +405,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 405 | 405 | viewer.overlays.pickerMu.Unlock() | |
| 406 | 406 | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | |
| 407 | 407 | previews := make(chan renderWake, 2) | |
| 408 | - | rc.subscribePreviewFor(viewer, func(w renderWake) { previews <- w }) | |
| 408 | + | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | |
| 409 | 409 | ||
| 410 | 410 | d.sessWg.Add(1) | |
| 411 | 411 | go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane()) | |
| @@ -509,6 +509,22 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 509 | 509 | // S2 keeps damage pending until the owning render capture consumes it. In | |
| 510 | 510 | // particular, a pane becoming invisible must not let a PTY reader erase data | |
| 511 | 511 | // that a later attachment or picker preview needs to render. | |
| 512 | + | func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) { | |
| 513 | + | p, release := newBlockingPTY(t) | |
| 514 | + | defer release() | |
| 515 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 516 | + | tb := sess.activeTab() | |
| 517 | + | pane := tb.focusedPane() | |
| 518 | + | ||
| 519 | + | // Holding daemon ownership makes a picker scan block. An active attached | |
| 520 | + | // tab must decide renderability from its session state without that scan. | |
| 521 | + | d.mu.Lock() | |
| 522 | + | defer d.mu.Unlock() | |
| 523 | + | done := make(chan bool, 1) | |
| 524 | + | go func() { done <- d.paneRenderable(sess, tb, pane) }() | |
| 525 | + | require.True(t, awaitTestValue(t, done, "active attached renderability scanned picker previews")) | |
| 526 | + | } | |
| 527 | + | ||
| 512 | 528 | func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |
| 513 | 529 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | |
| 514 | 530 | t.Helper() | |
| @@ -405,7 +405,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 405 | viewer.overlays.pickerMu.Unlock() | 405 | viewer.overlays.pickerMu.Unlock() | ||
| 406 | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | 406 | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | ||
| 407 | previews := make(chan renderWake, 2) | 407 | previews := make(chan renderWake, 2) | ||
| 408 | - | rc.subscribePreviewFor(viewer, func(w renderWake) { previews <- w }) | 408 | + | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) |
| 409 | 409 | ||||
| 410 | d.sessWg.Add(1) | 410 | d.sessWg.Add(1) | ||
| 411 | go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane()) | 411 | go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane()) | ||
| @@ -509,6 +509,22 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 509 | // S2 keeps damage pending until the owning render capture consumes it. In | 509 | // S2 keeps damage pending until the owning render capture consumes it. In | ||
| 510 | // particular, a pane becoming invisible must not let a PTY reader erase data | 510 | // particular, a pane becoming invisible must not let a PTY reader erase data | ||
| 511 | // that a later attachment or picker preview needs to render. | 511 | // that a later attachment or picker preview needs to render. | ||
| 512 | + | func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) { | |||
| 513 | + | p, release := newBlockingPTY(t) | |||
| 514 | + | defer release() | |||
| 515 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 516 | + | tb := sess.activeTab() | |||
| 517 | + | pane := tb.focusedPane() | |||
| 518 | + | ||||
| 519 | + | // Holding daemon ownership makes a picker scan block. An active attached | |||
| 520 | + | // tab must decide renderability from its session state without that scan. | |||
| 521 | + | d.mu.Lock() | |||
| 522 | + | defer d.mu.Unlock() | |||
| 523 | + | done := make(chan bool, 1) | |||
| 524 | + | go func() { done <- d.paneRenderable(sess, tb, pane) }() | |||
| 525 | + | require.True(t, awaitTestValue(t, done, "active attached renderability scanned picker previews")) | |||
| 526 | + | } | |||
| 527 | + | ||||
| 512 | func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | 528 | func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | ||
| 513 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | 529 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | ||
| 514 | t.Helper() | 530 | t.Helper() | ||
M internal/usecase/daemon/render_timer_test.go +2 -16
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "runtime" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -36,7 +35,7 @@ func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) { | |||
| 36 | 35 | require.False(t, ok, "a stale generation must not reclaim a replacement lane") | |
| 37 | 36 | ||
| 38 | 37 | require.True(t, owner.completeLocked(generation)) | |
| 39 | - | awaitTimerWorker(t, done) | |
| 38 | + | awaitTestCompletion(t, done, "timer worker did not complete") | |
| 40 | 39 | } | |
| 41 | 40 | ||
| 42 | 41 | func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) { | |
| @@ -46,23 +45,10 @@ func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) { | |||
| 46 | 45 | fired := make(chan struct{}, 1) | |
| 47 | 46 | runTimerWorker(timerC, cancel, done, func() { fired <- struct{}{} }) | |
| 48 | 47 | timerC <- time.Time{} | |
| 49 | - | awaitTimerWorker(t, done) | |
| 48 | + | awaitTestCompletion(t, done, "timer worker did not complete") | |
| 50 | 49 | select { | |
| 51 | 50 | case <-fired: | |
| 52 | 51 | default: | |
| 53 | 52 | t.Fatal("timer worker did not run callback") | |
| 54 | 53 | } | |
| 55 | 54 | } | |
| 56 | - | ||
| 57 | - | func awaitTimerWorker(t *testing.T, done <-chan struct{}) { | |
| 58 | - | t.Helper() | |
| 59 | - | for range 4096 { | |
| 60 | - | select { | |
| 61 | - | case <-done: | |
| 62 | - | return | |
| 63 | - | default: | |
| 64 | - | runtime.Gosched() | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | t.Fatal("timer worker did not complete") | |
| 68 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "runtime" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | "time" | 5 | "time" | ||
| 7 | 6 | ||||
| @@ -36,7 +35,7 @@ func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) { | |||||
| 36 | require.False(t, ok, "a stale generation must not reclaim a replacement lane") | 35 | require.False(t, ok, "a stale generation must not reclaim a replacement lane") | ||
| 37 | 36 | ||||
| 38 | require.True(t, owner.completeLocked(generation)) | 37 | require.True(t, owner.completeLocked(generation)) | ||
| 39 | - | awaitTimerWorker(t, done) | 38 | + | awaitTestCompletion(t, done, "timer worker did not complete") |
| 40 | } | 39 | } | ||
| 41 | 40 | ||||
| 42 | func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) { | 41 | func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) { | ||
| @@ -46,23 +45,10 @@ func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) { | |||||
| 46 | fired := make(chan struct{}, 1) | 45 | fired := make(chan struct{}, 1) | ||
| 47 | runTimerWorker(timerC, cancel, done, func() { fired <- struct{}{} }) | 46 | runTimerWorker(timerC, cancel, done, func() { fired <- struct{}{} }) | ||
| 48 | timerC <- time.Time{} | 47 | timerC <- time.Time{} | ||
| 49 | - | awaitTimerWorker(t, done) | 48 | + | awaitTestCompletion(t, done, "timer worker did not complete") |
| 50 | select { | 49 | select { | ||
| 51 | case <-fired: | 50 | case <-fired: | ||
| 52 | default: | 51 | default: | ||
| 53 | t.Fatal("timer worker did not run callback") | 52 | t.Fatal("timer worker did not run callback") | ||
| 54 | } | 53 | } | ||
| 55 | } | 54 | } | ||
| 56 | - | ||||
| 57 | - | func awaitTimerWorker(t *testing.T, done <-chan struct{}) { | |||
| 58 | - | t.Helper() | |||
| 59 | - | for range 4096 { | |||
| 60 | - | select { | |||
| 61 | - | case <-done: | |||
| 62 | - | return | |||
| 63 | - | default: | |||
| 64 | - | runtime.Gosched() | |||
| 65 | - | } | |||
| 66 | - | } | |||
| 67 | - | t.Fatal("timer worker did not complete") | |||
| 68 | - | } | |||
M internal/usecase/daemon/resume_test.go +2 -2
| @@ -171,10 +171,10 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 171 | 171 | rc.mu.Unlock() | |
| 172 | 172 | require.NotNil(t, workerDone) | |
| 173 | 173 | timer.ch <- time.Time{} | |
| 174 | - | awaitHandshakeWorker(t, workerDone) | |
| 174 | + | awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete") | |
| 175 | 175 | requireNoCoordinatorOutputFrame(t, tr.sends) | |
| 176 | 176 | ||
| 177 | - | close(tr.releaseWelcome) | |
| 177 | + | tr.release() | |
| 178 | 178 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 179 | 179 | first, err := ports.UnmarshalOutput(output.Payload) | |
| 180 | 180 | require.NoError(t, err) | |
| @@ -171,10 +171,10 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 171 | rc.mu.Unlock() | 171 | rc.mu.Unlock() | ||
| 172 | require.NotNil(t, workerDone) | 172 | require.NotNil(t, workerDone) | ||
| 173 | timer.ch <- time.Time{} | 173 | timer.ch <- time.Time{} | ||
| 174 | - | awaitHandshakeWorker(t, workerDone) | 174 | + | awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete") |
| 175 | requireNoCoordinatorOutputFrame(t, tr.sends) | 175 | requireNoCoordinatorOutputFrame(t, tr.sends) | ||
| 176 | 176 | ||||
| 177 | - | close(tr.releaseWelcome) | 177 | + | tr.release() |
| 178 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | 178 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 179 | first, err := ports.UnmarshalOutput(output.Payload) | 179 | first, err := ports.UnmarshalOutput(output.Payload) | ||
| 180 | require.NoError(t, err) | 180 | require.NoError(t, err) | ||
M internal/usecase/daemon/snapshot_test.go +3 -1
| @@ -624,6 +624,7 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||
| 624 | 624 | case <-time.After(time.Second): | |
| 625 | 625 | t.Fatal("timed out waiting for dirty snapshot write") | |
| 626 | 626 | } | |
| 627 | + | awaitSnapshotClean(t, dirty) | |
| 627 | 628 | ||
| 628 | 629 | clock.fireNext(t) | |
| 629 | 630 | select { | |
| @@ -850,6 +851,7 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |||
| 850 | 851 | case <-time.After(time.Second): | |
| 851 | 852 | t.Fatal("timed out waiting for retry snapshot write") | |
| 852 | 853 | } | |
| 854 | + | awaitSnapshotClean(t, sess) | |
| 853 | 855 | require.False(t, sess.snapDirty.Load()) | |
| 854 | 856 | } | |
| 855 | 857 | ||
| @@ -1037,7 +1039,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 1037 | 1039 | return sess | |
| 1038 | 1040 | } | |
| 1039 | 1041 | ||
| 1040 | - | func awaitSnapshotIdle(t *testing.T, sess *session) { | |
| 1042 | + | func awaitSnapshotIdle(t testing.TB, sess *session) { | |
| 1041 | 1043 | t.Helper() | |
| 1042 | 1044 | for { | |
| 1043 | 1045 | sess.snapshotMu.Lock() | |
| @@ -624,6 +624,7 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||||
| 624 | case <-time.After(time.Second): | 624 | case <-time.After(time.Second): | ||
| 625 | t.Fatal("timed out waiting for dirty snapshot write") | 625 | t.Fatal("timed out waiting for dirty snapshot write") | ||
| 626 | } | 626 | } | ||
| 627 | + | awaitSnapshotClean(t, dirty) | |||
| 627 | 628 | ||||
| 628 | clock.fireNext(t) | 629 | clock.fireNext(t) | ||
| 629 | select { | 630 | select { | ||
| @@ -850,6 +851,7 @@ func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |||||
| 850 | case <-time.After(time.Second): | 851 | case <-time.After(time.Second): | ||
| 851 | t.Fatal("timed out waiting for retry snapshot write") | 852 | t.Fatal("timed out waiting for retry snapshot write") | ||
| 852 | } | 853 | } | ||
| 854 | + | awaitSnapshotClean(t, sess) | |||
| 853 | require.False(t, sess.snapDirty.Load()) | 855 | require.False(t, sess.snapDirty.Load()) | ||
| 854 | } | 856 | } | ||
| 855 | 857 | ||||
| @@ -1037,7 +1039,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 1037 | return sess | 1039 | return sess | ||
| 1038 | } | 1040 | } | ||
| 1039 | 1041 | ||||
| 1040 | - | func awaitSnapshotIdle(t *testing.T, sess *session) { | 1042 | + | func awaitSnapshotIdle(t testing.TB, sess *session) { |
| 1041 | t.Helper() | 1043 | t.Helper() | ||
| 1042 | for { | 1044 | for { | ||
| 1043 | sess.snapshotMu.Lock() | 1045 | sess.snapshotMu.Lock() | ||
A internal/usecase/daemon/stalled_attachment_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 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 | + | // TestFinishAttachDoesNotWaitForBlockedOldRenderWorker verifies the replacement | |
| 18 | + | // boundary rather than relying on a scheduler delay: an old coordinator worker | |
| 19 | + | // is already blocked in Send when finishAttach publishes its replacement. | |
| 20 | + | func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) { | |
| 21 | + | pty, releasePTY := newBlockingPTY(t) | |
| 22 | + | defer releasePTY() | |
| 23 | + | clock := newCoordinatorMockClock(t, 4) | |
| 24 | + | d := newTestDaemon(t, newFactory(t, pty), clock.clock) | |
| 25 | + | ||
| 26 | + | oldTransport := portsmocks.NewMockTransport(t) | |
| 27 | + | oldSendEntered := make(chan struct{}) | |
| 28 | + | oldClosed := make(chan struct{}) | |
| 29 | + | var closeOnce sync.Once | |
| 30 | + | oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error { | |
| 31 | + | close(oldSendEntered) | |
| 32 | + | <-oldClosed | |
| 33 | + | return io.EOF | |
| 34 | + | }).Once() | |
| 35 | + | oldTransport.EXPECT().Close().RunAndReturn(func() error { | |
| 36 | + | closeOnce.Do(func() { close(oldClosed) }) | |
| 37 | + | return nil | |
| 38 | + | }).Once() | |
| 39 | + | ||
| 40 | + | hello := ports.Hello{ | |
| 41 | + | Version: ports.ProtocolVersion, | |
| 42 | + | Intent: ports.IntentNew, | |
| 43 | + | Name: "stalled-attachment", | |
| 44 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 45 | + | TermEnv: "xterm-256color", | |
| 46 | + | } | |
| 47 | + | sess, old, err := d.route(hello, oldTransport) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | rc := sess.renderCoordinator() | |
| 50 | + | lease := rc.attachmentLease(old) | |
| 51 | + | require.True(t, rc.markAttachmentReady(lease)) | |
| 52 | + | ||
| 53 | + | rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |
| 54 | + | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |
| 55 | + | <-oldSendEntered | |
| 56 | + | ||
| 57 | + | replacement := portsmocks.NewMockTransport(t) | |
| 58 | + | replacement.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 59 | + | replacement.EXPECT().Close().Return(nil).Maybe() | |
| 60 | + | attached := make(chan *attachedClient, 1) | |
| 61 | + | go func() { | |
| 62 | + | d.mu.Lock() | |
| 63 | + | attached <- d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello) | |
| 64 | + | }() | |
| 65 | + | ||
| 66 | + | select { | |
| 67 | + | case ac := <-attached: | |
| 68 | + | require.Same(t, replacement, ac.transport()) | |
| 69 | + | case <-time.After(time.Second): | |
| 70 | + | t.Fatal("replacement attach waited for the blocked old render worker") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // The exact old link is revoked and closed; it cannot be mistaken for the | |
| 74 | + | // replacement even after the retired worker finally leaves Send. | |
| 75 | + | d.attachmentCleanupWg.Wait() | |
| 76 | + | require.Nil(t, old.transport()) | |
| 77 | + | replacement.AssertNotCalled(t, "Close") | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 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 | + | // TestFinishAttachDoesNotWaitForBlockedOldRenderWorker verifies the replacement | |||
| 18 | + | // boundary rather than relying on a scheduler delay: an old coordinator worker | |||
| 19 | + | // is already blocked in Send when finishAttach publishes its replacement. | |||
| 20 | + | func TestFinishAttachDoesNotWaitForBlockedOldRenderWorker(t *testing.T) { | |||
| 21 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 22 | + | defer releasePTY() | |||
| 23 | + | clock := newCoordinatorMockClock(t, 4) | |||
| 24 | + | d := newTestDaemon(t, newFactory(t, pty), clock.clock) | |||
| 25 | + | ||||
| 26 | + | oldTransport := portsmocks.NewMockTransport(t) | |||
| 27 | + | oldSendEntered := make(chan struct{}) | |||
| 28 | + | oldClosed := make(chan struct{}) | |||
| 29 | + | var closeOnce sync.Once | |||
| 30 | + | oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error { | |||
| 31 | + | close(oldSendEntered) | |||
| 32 | + | <-oldClosed | |||
| 33 | + | return io.EOF | |||
| 34 | + | }).Once() | |||
| 35 | + | oldTransport.EXPECT().Close().RunAndReturn(func() error { | |||
| 36 | + | closeOnce.Do(func() { close(oldClosed) }) | |||
| 37 | + | return nil | |||
| 38 | + | }).Once() | |||
| 39 | + | ||||
| 40 | + | hello := ports.Hello{ | |||
| 41 | + | Version: ports.ProtocolVersion, | |||
| 42 | + | Intent: ports.IntentNew, | |||
| 43 | + | Name: "stalled-attachment", | |||
| 44 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 45 | + | TermEnv: "xterm-256color", | |||
| 46 | + | } | |||
| 47 | + | sess, old, err := d.route(hello, oldTransport) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | rc := sess.renderCoordinator() | |||
| 50 | + | lease := rc.attachmentLease(old) | |||
| 51 | + | require.True(t, rc.markAttachmentReady(lease)) | |||
| 52 | + | ||||
| 53 | + | rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |||
| 54 | + | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |||
| 55 | + | <-oldSendEntered | |||
| 56 | + | ||||
| 57 | + | replacement := portsmocks.NewMockTransport(t) | |||
| 58 | + | replacement.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |||
| 59 | + | replacement.EXPECT().Close().Return(nil).Maybe() | |||
| 60 | + | attached := make(chan *attachedClient, 1) | |||
| 61 | + | go func() { | |||
| 62 | + | d.mu.Lock() | |||
| 63 | + | attached <- d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello) | |||
| 64 | + | }() | |||
| 65 | + | ||||
| 66 | + | select { | |||
| 67 | + | case ac := <-attached: | |||
| 68 | + | require.Same(t, replacement, ac.transport()) | |||
| 69 | + | case <-time.After(time.Second): | |||
| 70 | + | t.Fatal("replacement attach waited for the blocked old render worker") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // The exact old link is revoked and closed; it cannot be mistaken for the | |||
| 74 | + | // replacement even after the retired worker finally leaves Send. | |||
| 75 | + | d.attachmentCleanupWg.Wait() | |||
| 76 | + | require.Nil(t, old.transport()) | |||
| 77 | + | replacement.AssertNotCalled(t, "Close") | |||
| 78 | + | } | |||
M internal/usecase/daemon/status_test.go +5 -16
| @@ -1381,22 +1381,11 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |||
| 1381 | 1381 | // The queued create, switch, and resize transitions may collapse into one | |
| 1382 | 1382 | // latest-state wake. Advance every retained coordinator deadline | |
| 1383 | 1383 | // until that coalesced output is observable. | |
| 1384 | - | var resized ports.Frame | |
| 1385 | - | deadline := time.NewTimer(2 * time.Second) | |
| 1386 | - | defer deadline.Stop() | |
| 1387 | - | for resized.Type != ports.MsgOutput { | |
| 1388 | - | select { | |
| 1389 | - | case f := <-sends: | |
| 1390 | - | if f.Type != ports.MsgOutput { | |
| 1391 | - | t.Fatalf("unexpected frame type %d while awaiting resized output", f.Type) | |
| 1392 | - | } | |
| 1393 | - | resized = f | |
| 1394 | - | case timer := <-clock.timers: | |
| 1395 | - | timer.ch <- time.Time{} | |
| 1396 | - | case <-deadline.C: | |
| 1397 | - | t.Fatal("timed out awaiting resized output frame") | |
| 1398 | - | } | |
| 1399 | - | } | |
| 1384 | + | resized := awaitCoordinatorOutput( | |
| 1385 | + | t, sends, clock.timers, | |
| 1386 | + | "while awaiting resized output", | |
| 1387 | + | "timed out awaiting resized output frame", | |
| 1388 | + | ) | |
| 1400 | 1389 | ||
| 1401 | 1390 | for _, f := range []ports.Frame{first, resized} { | |
| 1402 | 1391 | out, err := ports.UnmarshalOutput(f.Payload) | |
| @@ -1381,22 +1381,11 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |||||
| 1381 | // The queued create, switch, and resize transitions may collapse into one | 1381 | // The queued create, switch, and resize transitions may collapse into one | ||
| 1382 | // latest-state wake. Advance every retained coordinator deadline | 1382 | // latest-state wake. Advance every retained coordinator deadline | ||
| 1383 | // until that coalesced output is observable. | 1383 | // until that coalesced output is observable. | ||
| 1384 | - | var resized ports.Frame | 1384 | + | resized := awaitCoordinatorOutput( |
| 1385 | - | deadline := time.NewTimer(2 * time.Second) | 1385 | + | t, sends, clock.timers, |
| 1386 | - | defer deadline.Stop() | 1386 | + | "while awaiting resized output", |
| 1387 | - | for resized.Type != ports.MsgOutput { | 1387 | + | "timed out awaiting resized output frame", |
| 1388 | - | select { | 1388 | + | ) |
| 1389 | - | case f := <-sends: | |||
| 1390 | - | if f.Type != ports.MsgOutput { | |||
| 1391 | - | t.Fatalf("unexpected frame type %d while awaiting resized output", f.Type) | |||
| 1392 | - | } | |||
| 1393 | - | resized = f | |||
| 1394 | - | case timer := <-clock.timers: | |||
| 1395 | - | timer.ch <- time.Time{} | |||
| 1396 | - | case <-deadline.C: | |||
| 1397 | - | t.Fatal("timed out awaiting resized output frame") | |||
| 1398 | - | } | |||
| 1399 | - | } | |||
| 1400 | 1389 | ||||
| 1401 | for _, f := range []ports.Frame{first, resized} { | 1390 | for _, f := range []ports.Frame{first, resized} { | ||
| 1402 | out, err := ports.UnmarshalOutput(f.Payload) | 1391 | out, err := ports.UnmarshalOutput(f.Payload) | ||
A internal/usecase/daemon/test_wait_helpers_test.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const testWaitTimeout = 2 * time.Second | |
| 11 | + | ||
| 12 | + | func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T { | |
| 13 | + | t.Helper() | |
| 14 | + | timer := time.NewTimer(testWaitTimeout) | |
| 15 | + | defer timer.Stop() | |
| 16 | + | select { | |
| 17 | + | case value := <-ch: | |
| 18 | + | return value | |
| 19 | + | case <-timer.C: | |
| 20 | + | t.Fatal(failure) | |
| 21 | + | var zero T | |
| 22 | + | return zero | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func awaitTestCompletion(t *testing.T, done <-chan struct{}, failure string) { | |
| 27 | + | t.Helper() | |
| 28 | + | awaitTestValue(t, done, failure) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func awaitCoordinatorOutput( | |
| 32 | + | t *testing.T, | |
| 33 | + | sends <-chan ports.Frame, | |
| 34 | + | timers <-chan *coordinatorMockTimer, | |
| 35 | + | frameContext string, | |
| 36 | + | timeoutFailure string, | |
| 37 | + | ) ports.Frame { | |
| 38 | + | t.Helper() | |
| 39 | + | deadline := time.NewTimer(testWaitTimeout) | |
| 40 | + | defer deadline.Stop() | |
| 41 | + | for { | |
| 42 | + | select { | |
| 43 | + | case frame := <-sends: | |
| 44 | + | if frame.Type != ports.MsgOutput { | |
| 45 | + | t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext) | |
| 46 | + | } | |
| 47 | + | return frame | |
| 48 | + | case timer := <-timers: | |
| 49 | + | timer.ch <- time.Time{} | |
| 50 | + | case <-deadline.C: | |
| 51 | + | t.Fatal(timeoutFailure) | |
| 52 | + | return ports.Frame{} | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const testWaitTimeout = 2 * time.Second | |||
| 11 | + | ||||
| 12 | + | func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T { | |||
| 13 | + | t.Helper() | |||
| 14 | + | timer := time.NewTimer(testWaitTimeout) | |||
| 15 | + | defer timer.Stop() | |||
| 16 | + | select { | |||
| 17 | + | case value := <-ch: | |||
| 18 | + | return value | |||
| 19 | + | case <-timer.C: | |||
| 20 | + | t.Fatal(failure) | |||
| 21 | + | var zero T | |||
| 22 | + | return zero | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func awaitTestCompletion(t *testing.T, done <-chan struct{}, failure string) { | |||
| 27 | + | t.Helper() | |||
| 28 | + | awaitTestValue(t, done, failure) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func awaitCoordinatorOutput( | |||
| 32 | + | t *testing.T, | |||
| 33 | + | sends <-chan ports.Frame, | |||
| 34 | + | timers <-chan *coordinatorMockTimer, | |||
| 35 | + | frameContext string, | |||
| 36 | + | timeoutFailure string, | |||
| 37 | + | ) ports.Frame { | |||
| 38 | + | t.Helper() | |||
| 39 | + | deadline := time.NewTimer(testWaitTimeout) | |||
| 40 | + | defer deadline.Stop() | |||
| 41 | + | for { | |||
| 42 | + | select { | |||
| 43 | + | case frame := <-sends: | |||
| 44 | + | if frame.Type != ports.MsgOutput { | |||
| 45 | + | t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext) | |||
| 46 | + | } | |||
| 47 | + | return frame | |||
| 48 | + | case timer := <-timers: | |||
| 49 | + | timer.ch <- time.Time{} | |||
| 50 | + | case <-deadline.C: | |||
| 51 | + | t.Fatal(timeoutFailure) | |||
| 52 | + | return ports.Frame{} | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | } | |||
M internal/usecase/daemon/transactional_resize.go +3 -0
| @@ -243,6 +243,9 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 243 | 243 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 244 | 244 | } | |
| 245 | 245 | if succeeded { | |
| 246 | + | // Retry completion changes VT state after the original layout commit. | |
| 247 | + | // Keep a named session's eventual snapshot generation aligned with it. | |
| 248 | + | markSnapshotDirty(sess) | |
| 246 | 249 | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |
| 247 | 250 | } | |
| 248 | 251 | } | |
| @@ -243,6 +243,9 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 243 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | 243 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | ||
| 244 | } | 244 | } | ||
| 245 | if succeeded { | 245 | if succeeded { | ||
| 246 | + | // Retry completion changes VT state after the original layout commit. | |||
| 247 | + | // Keep a named session's eventual snapshot generation aligned with it. | |||
| 248 | + | markSnapshotDirty(sess) | |||
| 246 | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | 249 | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | ||
| 247 | } | 250 | } | ||
| 248 | } | 251 | } | ||
M internal/usecase/daemon/transactional_resize_test.go +35 -0
| @@ -234,6 +234,41 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||
| 234 | 234 | require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout") | |
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | + | // A retry changes VT state after the committed layout, so it needs a distinct | |
| 238 | + | // snapshot generation. A failed retry must retain the prior screen and dirty | |
| 239 | + | // generation. | |
| 240 | + | func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing.T) { | |
| 241 | + | resizeErr := errors.New("scripted resize failure") | |
| 242 | + | for _, tc := range []struct { | |
| 243 | + | name string | |
| 244 | + | errs []error | |
| 245 | + | wantGeneration uint64 | |
| 246 | + | wantScreen domain.Size | |
| 247 | + | }{ | |
| 248 | + | {name: "success", errs: []error{resizeErr, nil}, wantGeneration: 2, wantScreen: domain.Size{Cols: 100, Rows: 28}}, | |
| 249 | + | {name: "failure", errs: []error{resizeErr, resizeErr}, wantGeneration: 1, wantScreen: domain.Size{Cols: 80, Rows: 23}}, | |
| 250 | + | } { | |
| 251 | + | t.Run(tc.name, func(t *testing.T) { | |
| 252 | + | pty := &transactionalResizePTY{errs: tc.errs} | |
| 253 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 254 | + | sess.snapEligible.Store(true) | |
| 255 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 256 | + | ||
| 257 | + | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 258 | + | rc := sess.renderCoordinator() | |
| 259 | + | snapshot := rc.resizeSnapshot() | |
| 260 | + | d.retryResizeMembers(sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}}) | |
| 261 | + | ||
| 262 | + | sess.snapshotMu.Lock() | |
| 263 | + | generation := sess.snapshotGeneration | |
| 264 | + | sess.snapshotMu.Unlock() | |
| 265 | + | require.Equal(t, tc.wantGeneration, generation) | |
| 266 | + | require.True(t, sess.snapDirty.Load()) | |
| 267 | + | require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 268 | + | }) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 237 | 272 | // S3 acceptance: a failed member is retried only for the newest committed | |
| 238 | 273 | // epoch. An intervening request supersedes the old retry, and a successful | |
| 239 | 274 | // retry must resize its VT before the forced full-frame emission. | |
| @@ -234,6 +234,41 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||||
| 234 | require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout") | 234 | require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout") | ||
| 235 | } | 235 | } | ||
| 236 | 236 | ||||
| 237 | + | // A retry changes VT state after the committed layout, so it needs a distinct | |||
| 238 | + | // snapshot generation. A failed retry must retain the prior screen and dirty | |||
| 239 | + | // generation. | |||
| 240 | + | func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing.T) { | |||
| 241 | + | resizeErr := errors.New("scripted resize failure") | |||
| 242 | + | for _, tc := range []struct { | |||
| 243 | + | name string | |||
| 244 | + | errs []error | |||
| 245 | + | wantGeneration uint64 | |||
| 246 | + | wantScreen domain.Size | |||
| 247 | + | }{ | |||
| 248 | + | {name: "success", errs: []error{resizeErr, nil}, wantGeneration: 2, wantScreen: domain.Size{Cols: 100, Rows: 28}}, | |||
| 249 | + | {name: "failure", errs: []error{resizeErr, resizeErr}, wantGeneration: 1, wantScreen: domain.Size{Cols: 80, Rows: 23}}, | |||
| 250 | + | } { | |||
| 251 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 252 | + | pty := &transactionalResizePTY{errs: tc.errs} | |||
| 253 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 254 | + | sess.snapEligible.Store(true) | |||
| 255 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |||
| 256 | + | ||||
| 257 | + | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |||
| 258 | + | rc := sess.renderCoordinator() | |||
| 259 | + | snapshot := rc.resizeSnapshot() | |||
| 260 | + | d.retryResizeMembers(sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}}) | |||
| 261 | + | ||||
| 262 | + | sess.snapshotMu.Lock() | |||
| 263 | + | generation := sess.snapshotGeneration | |||
| 264 | + | sess.snapshotMu.Unlock() | |||
| 265 | + | require.Equal(t, tc.wantGeneration, generation) | |||
| 266 | + | require.True(t, sess.snapDirty.Load()) | |||
| 267 | + | require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |||
| 268 | + | }) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 237 | // S3 acceptance: a failed member is retried only for the newest committed | 272 | // S3 acceptance: a failed member is retried only for the newest committed | ||
| 238 | // epoch. An intervening request supersedes the old retry, and a successful | 273 | // epoch. An intervening request supersedes the old retry, and a successful | ||
| 239 | // retry must resize its VT before the forced full-frame emission. | 274 | // retry must resize its VT before the forced full-frame emission. | ||
M internal/usecase/daemon/transport_observability_test.go +4 -4
| @@ -25,7 +25,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||
| 25 | 25 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) { | |
| 26 | 26 | t.Fatal("first resize was not accepted") | |
| 27 | 27 | } | |
| 28 | - | first := <-sends | |
| 28 | + | first := awaitFrame(t, sends, ports.MsgOutput) | |
| 29 | 29 | if first.Type != ports.MsgOutput { | |
| 30 | 30 | t.Fatalf("first frame = %v, want output", first.Type) | |
| 31 | 31 | } | |
| @@ -38,7 +38,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||
| 38 | 38 | } | |
| 39 | 39 | ac.ackOutputState(output.NewStateNum) | |
| 40 | 40 | rc.notifyAck() | |
| 41 | - | second := <-sends | |
| 41 | + | second := awaitFrame(t, sends, ports.MsgOutput) | |
| 42 | 42 | if second.Type != ports.MsgOutput { | |
| 43 | 43 | t.Fatalf("second frame = %v, want output", second.Type) | |
| 44 | 44 | } | |
| @@ -118,7 +118,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||
| 118 | 118 | close(paintDone) | |
| 119 | 119 | }() | |
| 120 | 120 | awaitDaemonObserver(t, observer.entered, "blocked render observer") | |
| 121 | - | frame := <-sends // The frame was emitted before the observer began its I/O. | |
| 121 | + | frame := awaitFrame(t, sends, ports.MsgOutput) // The frame was emitted before the observer began its I/O. | |
| 122 | 122 | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 123 | 123 | if err != nil { | |
| 124 | 124 | t.Fatalf("decode emitted output: %v", err) | |
| @@ -153,7 +153,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||
| 153 | 153 | d.paint(sess, ac, false, nil) | |
| 154 | 154 | close(secondPaint) | |
| 155 | 155 | }() | |
| 156 | - | <-sends // The second frame is live even though serialized observer I/O waits. | |
| 156 | + | _ = awaitFrame(t, sends, ports.MsgOutput) // The second frame is live even though serialized observer I/O waits. | |
| 157 | 157 | ||
| 158 | 158 | close(observer.release) | |
| 159 | 159 | awaitDaemonObserver(t, paintDone, "first render completion") | |
| @@ -25,7 +25,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||||
| 25 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) { | 25 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) { | ||
| 26 | t.Fatal("first resize was not accepted") | 26 | t.Fatal("first resize was not accepted") | ||
| 27 | } | 27 | } | ||
| 28 | - | first := <-sends | 28 | + | first := awaitFrame(t, sends, ports.MsgOutput) |
| 29 | if first.Type != ports.MsgOutput { | 29 | if first.Type != ports.MsgOutput { | ||
| 30 | t.Fatalf("first frame = %v, want output", first.Type) | 30 | t.Fatalf("first frame = %v, want output", first.Type) | ||
| 31 | } | 31 | } | ||
| @@ -38,7 +38,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||||
| 38 | } | 38 | } | ||
| 39 | ac.ackOutputState(output.NewStateNum) | 39 | ac.ackOutputState(output.NewStateNum) | ||
| 40 | rc.notifyAck() | 40 | rc.notifyAck() | ||
| 41 | - | second := <-sends | 41 | + | second := awaitFrame(t, sends, ports.MsgOutput) |
| 42 | if second.Type != ports.MsgOutput { | 42 | if second.Type != ports.MsgOutput { | ||
| 43 | t.Fatalf("second frame = %v, want output", second.Type) | 43 | t.Fatalf("second frame = %v, want output", second.Type) | ||
| 44 | } | 44 | } | ||
| @@ -118,7 +118,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||||
| 118 | close(paintDone) | 118 | close(paintDone) | ||
| 119 | }() | 119 | }() | ||
| 120 | awaitDaemonObserver(t, observer.entered, "blocked render observer") | 120 | awaitDaemonObserver(t, observer.entered, "blocked render observer") | ||
| 121 | - | frame := <-sends // The frame was emitted before the observer began its I/O. | 121 | + | frame := awaitFrame(t, sends, ports.MsgOutput) // The frame was emitted before the observer began its I/O. |
| 122 | output, err := ports.UnmarshalOutput(frame.Payload) | 122 | output, err := ports.UnmarshalOutput(frame.Payload) | ||
| 123 | if err != nil { | 123 | if err != nil { | ||
| 124 | t.Fatalf("decode emitted output: %v", err) | 124 | t.Fatalf("decode emitted output: %v", err) | ||
| @@ -153,7 +153,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||||
| 153 | d.paint(sess, ac, false, nil) | 153 | d.paint(sess, ac, false, nil) | ||
| 154 | close(secondPaint) | 154 | close(secondPaint) | ||
| 155 | }() | 155 | }() | ||
| 156 | - | <-sends // The second frame is live even though serialized observer I/O waits. | 156 | + | _ = awaitFrame(t, sends, ports.MsgOutput) // The second frame is live even though serialized observer I/O waits. |
| 157 | 157 | ||||
| 158 | close(observer.release) | 158 | close(observer.release) | ||
| 159 | awaitDaemonObserver(t, paintDone, "first render completion") | 159 | awaitDaemonObserver(t, paintDone, "first render completion") | ||
M pkg/vt/history_codec.go +61 -57
| @@ -135,53 +135,13 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |
| 138 | - | // malformed declarations, truncated data, and trailing bytes. | |
| 138 | + | // malformed declarations, cells, truncated data, and trailing bytes. | |
| 139 | 139 | func UnmarshalHistory(data []byte) (HistoryView, error) { | |
| 140 | - | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 141 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 142 | - | } | |
| 143 | - | if _, ok := preflightHistory(data[5:]); !ok { | |
| 144 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | p := historyParser{data: data[5:]} | |
| 148 | - | chunkCount, ok := p.uint32() | |
| 149 | - | if !ok || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 150 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 151 | - | } | |
| 152 | - | chunks := make([]*HistoryChunk, 0, chunkCount) | |
| 153 | - | rowsTotal := 0 | |
| 154 | - | for range chunkCount { | |
| 155 | - | rowCount, ok := p.uint32() | |
| 156 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 { | |
| 157 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 158 | - | } | |
| 159 | - | if rowsTotal > math.MaxInt-int(rowCount) { | |
| 160 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 161 | - | } | |
| 162 | - | rows := make([][]renderer.Cell, 0, rowCount) | |
| 163 | - | for range rowCount { | |
| 164 | - | cellCount, ok := p.uint32() | |
| 165 | - | if !ok || uint64(cellCount) > maxHistoryRowCells || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |
| 166 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 167 | - | } | |
| 168 | - | row := make([]renderer.Cell, cellCount) | |
| 169 | - | for i := range row { | |
| 170 | - | cell, ok := p.cell() | |
| 171 | - | if !ok || !validHistoryCell(cell) { | |
| 172 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 173 | - | } | |
| 174 | - | row[i] = cell | |
| 175 | - | } | |
| 176 | - | rows = append(rows, row) | |
| 177 | - | } | |
| 178 | - | rowsTotal += int(rowCount) | |
| 179 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 180 | - | } | |
| 181 | - | if len(p.data) != 0 { | |
| 140 | + | view, _, ok := parseHistory(data, true) | |
| 141 | + | if !ok { | |
| 182 | 142 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 183 | 143 | } | |
| 184 | - | return HistoryView{chunks: chunks, rows: rowsTotal}, nil | |
| 144 | + | return view, nil | |
| 185 | 145 | } | |
| 186 | 146 | ||
| 187 | 147 | type historyDecodeStats struct { | |
| @@ -192,35 +152,79 @@ type historyDecodeStats struct { | |||
| 192 | 152 | bytes uint64 | |
| 193 | 153 | } | |
| 194 | 154 | ||
| 195 | - | // preflightHistory validates declarations and aggregate budgets without | |
| 196 | - | // allocating decoded history. Its totals can be composed by snapshot decoders. | |
| 155 | + | // preflightHistory validates a history blob without allocating decoded rows. | |
| 156 | + | // It shares parseHistory with UnmarshalHistory so both paths enforce the same | |
| 157 | + | // structural, cell, and aggregate-budget constraints. | |
| 197 | 158 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | |
| 198 | - | p := historyParser{data: data} | |
| 159 | + | _, stats, ok := parseHistory(data, false) | |
| 160 | + | return stats, ok | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // parseHistory is the authoritative history payload validation path. populate | |
| 164 | + | // controls whether validated cells are retained in a HistoryView. | |
| 165 | + | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | |
| 166 | + | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 167 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | p := historyParser{data: data[5:]} | |
| 199 | 171 | chunkCount, ok := p.uint32() | |
| 200 | 172 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 201 | - | return historyDecodeStats{}, false | |
| 173 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 202 | 174 | } | |
| 203 | 175 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |
| 176 | + | var chunks []*HistoryChunk | |
| 177 | + | if populate { | |
| 178 | + | chunks = make([]*HistoryChunk, 0, chunkCount) | |
| 179 | + | } | |
| 204 | 180 | for range chunkCount { | |
| 205 | 181 | rowCount, ok := p.uint32() | |
| 206 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 207 | - | return historyDecodeStats{}, false | |
| 182 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |
| 183 | + | uint64(rowCount) > uint64(len(p.data))/4 || | |
| 184 | + | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 185 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | var rows [][]renderer.Cell | |
| 189 | + | if populate { | |
| 190 | + | rows = make([][]renderer.Cell, 0, rowCount) | |
| 208 | 191 | } | |
| 209 | 192 | for range rowCount { | |
| 210 | 193 | cellCount, ok := p.uint32() | |
| 211 | 194 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |
| 212 | - | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |
| 213 | - | return historyDecodeStats{}, false | |
| 214 | - | } | |
| 215 | - | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |
| 195 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || | |
| 196 | + | uint64(cellCount) > uint64(len(p.data))/historyCellBytes || | |
| 197 | + | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |
| 216 | 198 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | |
| 217 | 199 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 218 | - | return historyDecodeStats{}, false | |
| 200 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 219 | 201 | } | |
| 220 | - | p.data = p.data[int(cellBytes):] | |
| 202 | + | ||
| 203 | + | var row []renderer.Cell | |
| 204 | + | if populate { | |
| 205 | + | row = make([]renderer.Cell, cellCount) | |
| 206 | + | } | |
| 207 | + | for i := uint32(0); i < cellCount; i++ { | |
| 208 | + | cell, ok := p.cell() | |
| 209 | + | if !ok || !validHistoryCell(cell) { | |
| 210 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 211 | + | } | |
| 212 | + | if populate { | |
| 213 | + | row[i] = cell | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | if populate { | |
| 217 | + | rows = append(rows, row) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | if populate { | |
| 221 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 221 | 222 | } | |
| 222 | 223 | } | |
| 223 | - | return stats, len(p.data) == 0 | |
| 224 | + | if len(p.data) != 0 { | |
| 225 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 226 | + | } | |
| 227 | + | return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true | |
| 224 | 228 | } | |
| 225 | 229 | ||
| 226 | 230 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |
| @@ -135,53 +135,13 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | 137 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | ||
| 138 | - | // malformed declarations, truncated data, and trailing bytes. | 138 | + | // malformed declarations, cells, truncated data, and trailing bytes. |
| 139 | func UnmarshalHistory(data []byte) (HistoryView, error) { | 139 | func UnmarshalHistory(data []byte) (HistoryView, error) { | ||
| 140 | - | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | 140 | + | view, _, ok := parseHistory(data, true) |
| 141 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | 141 | + | if !ok { |
| 142 | - | } | |||
| 143 | - | if _, ok := preflightHistory(data[5:]); !ok { | |||
| 144 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | p := historyParser{data: data[5:]} | |||
| 148 | - | chunkCount, ok := p.uint32() | |||
| 149 | - | if !ok || uint64(chunkCount) > uint64(len(p.data))/4 { | |||
| 150 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 151 | - | } | |||
| 152 | - | chunks := make([]*HistoryChunk, 0, chunkCount) | |||
| 153 | - | rowsTotal := 0 | |||
| 154 | - | for range chunkCount { | |||
| 155 | - | rowCount, ok := p.uint32() | |||
| 156 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 { | |||
| 157 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 158 | - | } | |||
| 159 | - | if rowsTotal > math.MaxInt-int(rowCount) { | |||
| 160 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 161 | - | } | |||
| 162 | - | rows := make([][]renderer.Cell, 0, rowCount) | |||
| 163 | - | for range rowCount { | |||
| 164 | - | cellCount, ok := p.uint32() | |||
| 165 | - | if !ok || uint64(cellCount) > maxHistoryRowCells || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |||
| 166 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 167 | - | } | |||
| 168 | - | row := make([]renderer.Cell, cellCount) | |||
| 169 | - | for i := range row { | |||
| 170 | - | cell, ok := p.cell() | |||
| 171 | - | if !ok || !validHistoryCell(cell) { | |||
| 172 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 173 | - | } | |||
| 174 | - | row[i] = cell | |||
| 175 | - | } | |||
| 176 | - | rows = append(rows, row) | |||
| 177 | - | } | |||
| 178 | - | rowsTotal += int(rowCount) | |||
| 179 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 180 | - | } | |||
| 181 | - | if len(p.data) != 0 { | |||
| 182 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | 142 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | ||
| 183 | } | 143 | } | ||
| 184 | - | return HistoryView{chunks: chunks, rows: rowsTotal}, nil | 144 | + | return view, nil |
| 185 | } | 145 | } | ||
| 186 | 146 | ||||
| 187 | type historyDecodeStats struct { | 147 | type historyDecodeStats struct { | ||
| @@ -192,35 +152,79 @@ type historyDecodeStats struct { | |||||
| 192 | bytes uint64 | 152 | bytes uint64 | ||
| 193 | } | 153 | } | ||
| 194 | 154 | ||||
| 195 | - | // preflightHistory validates declarations and aggregate budgets without | 155 | + | // preflightHistory validates a history blob without allocating decoded rows. |
| 196 | - | // allocating decoded history. Its totals can be composed by snapshot decoders. | 156 | + | // It shares parseHistory with UnmarshalHistory so both paths enforce the same |
| 157 | + | // structural, cell, and aggregate-budget constraints. | |||
| 197 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | 158 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | ||
| 198 | - | p := historyParser{data: data} | 159 | + | _, stats, ok := parseHistory(data, false) |
| 160 | + | return stats, ok | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // parseHistory is the authoritative history payload validation path. populate | |||
| 164 | + | // controls whether validated cells are retained in a HistoryView. | |||
| 165 | + | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | |||
| 166 | + | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |||
| 167 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | p := historyParser{data: data[5:]} | |||
| 199 | chunkCount, ok := p.uint32() | 171 | chunkCount, ok := p.uint32() | ||
| 200 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | 172 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | ||
| 201 | - | return historyDecodeStats{}, false | 173 | + | return HistoryView{}, historyDecodeStats{}, false |
| 202 | } | 174 | } | ||
| 203 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | 175 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | ||
| 176 | + | var chunks []*HistoryChunk | |||
| 177 | + | if populate { | |||
| 178 | + | chunks = make([]*HistoryChunk, 0, chunkCount) | |||
| 179 | + | } | |||
| 204 | for range chunkCount { | 180 | for range chunkCount { | ||
| 205 | rowCount, ok := p.uint32() | 181 | rowCount, ok := p.uint32() | ||
| 206 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | 182 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || |
| 207 | - | return historyDecodeStats{}, false | 183 | + | uint64(rowCount) > uint64(len(p.data))/4 || |
| 184 | + | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |||
| 185 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | var rows [][]renderer.Cell | |||
| 189 | + | if populate { | |||
| 190 | + | rows = make([][]renderer.Cell, 0, rowCount) | |||
| 208 | } | 191 | } | ||
| 209 | for range rowCount { | 192 | for range rowCount { | ||
| 210 | cellCount, ok := p.uint32() | 193 | cellCount, ok := p.uint32() | ||
| 211 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | 194 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | ||
| 212 | - | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | 195 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || |
| 213 | - | return historyDecodeStats{}, false | 196 | + | uint64(cellCount) > uint64(len(p.data))/historyCellBytes || |
| 214 | - | } | 197 | + | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || |
| 215 | - | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |||
| 216 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | 198 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | ||
| 217 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | 199 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | ||
| 218 | - | return historyDecodeStats{}, false | 200 | + | return HistoryView{}, historyDecodeStats{}, false |
| 219 | } | 201 | } | ||
| 220 | - | p.data = p.data[int(cellBytes):] | 202 | + | |
| 203 | + | var row []renderer.Cell | |||
| 204 | + | if populate { | |||
| 205 | + | row = make([]renderer.Cell, cellCount) | |||
| 206 | + | } | |||
| 207 | + | for i := uint32(0); i < cellCount; i++ { | |||
| 208 | + | cell, ok := p.cell() | |||
| 209 | + | if !ok || !validHistoryCell(cell) { | |||
| 210 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 211 | + | } | |||
| 212 | + | if populate { | |||
| 213 | + | row[i] = cell | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | if populate { | |||
| 217 | + | rows = append(rows, row) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | if populate { | |||
| 221 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 221 | } | 222 | } | ||
| 222 | } | 223 | } | ||
| 223 | - | return stats, len(p.data) == 0 | 224 | + | if len(p.data) != 0 { |
| 225 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 226 | + | } | |||
| 227 | + | return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true | |||
| 224 | } | 228 | } | ||
| 225 | 229 | ||||
| 226 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | 230 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | ||
M pkg/vt/history_codec_test.go +55 -0
| @@ -227,6 +227,61 @@ func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |||
| 227 | 227 | return data | |
| 228 | 228 | } | |
| 229 | 229 | ||
| 230 | + | func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |
| 231 | + | valid, err := MarshalHistory(historyViewWithDimensions(1, 1)) | |
| 232 | + | if err != nil { | |
| 233 | + | t.Fatalf("marshal valid history: %v", err) | |
| 234 | + | } | |
| 235 | + | invalidRune := append([]byte(nil), valid...) | |
| 236 | + | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | |
| 237 | + | invalidUnderlineStyle := append([]byte(nil), valid...) | |
| 238 | + | invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1) | |
| 239 | + | ||
| 240 | + | tests := []struct { | |
| 241 | + | name string | |
| 242 | + | data []byte | |
| 243 | + | valid bool | |
| 244 | + | }{ | |
| 245 | + | {name: "valid", data: valid, valid: true}, | |
| 246 | + | {name: "truncated cell", data: valid[:len(valid)-1]}, | |
| 247 | + | {name: "trailing byte", data: append(append([]byte(nil), valid...), 0)}, | |
| 248 | + | {name: "invalid rune", data: invalidRune}, | |
| 249 | + | {name: "invalid underline style", data: invalidUnderlineStyle}, | |
| 250 | + | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |
| 251 | + | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | |
| 252 | + | } | |
| 253 | + | for _, tt := range tests { | |
| 254 | + | t.Run(tt.name, func(t *testing.T) { | |
| 255 | + | _, preflightErr := PreflightHistoryBlob(tt.data) | |
| 256 | + | _, unmarshalErr := UnmarshalHistory(tt.data) | |
| 257 | + | if (preflightErr == nil) != tt.valid || (unmarshalErr == nil) != tt.valid { | |
| 258 | + | t.Fatalf("preflight error = %v, unmarshal error = %v, want valid = %t", preflightErr, unmarshalErr, tt.valid) | |
| 259 | + | } | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |
| 265 | + | valid, err := MarshalHistory(historyViewWithDimensions(1, 1)) | |
| 266 | + | if err != nil { | |
| 267 | + | f.Fatalf("marshal valid history: %v", err) | |
| 268 | + | } | |
| 269 | + | invalidRune := append([]byte(nil), valid...) | |
| 270 | + | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | |
| 271 | + | f.Add(valid) | |
| 272 | + | f.Add(invalidRune) | |
| 273 | + | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | |
| 274 | + | f.Add([]byte("not a history payload")) | |
| 275 | + | ||
| 276 | + | f.Fuzz(func(t *testing.T, data []byte) { | |
| 277 | + | _, preflightErr := PreflightHistoryBlob(data) | |
| 278 | + | _, unmarshalErr := UnmarshalHistory(data) | |
| 279 | + | if (preflightErr == nil) != (unmarshalErr == nil) { | |
| 280 | + | t.Fatalf("preflight error = %v, unmarshal error = %v", preflightErr, unmarshalErr) | |
| 281 | + | } | |
| 282 | + | }) | |
| 283 | + | } | |
| 284 | + | ||
| 230 | 285 | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | |
| 231 | 286 | t.Helper() | |
| 232 | 287 | if got := view.Len(); got != len(want) { | |
| @@ -227,6 +227,61 @@ func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |||||
| 227 | return data | 227 | return data | ||
| 228 | } | 228 | } | ||
| 229 | 229 | ||||
| 230 | + | func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 231 | + | valid, err := MarshalHistory(historyViewWithDimensions(1, 1)) | |||
| 232 | + | if err != nil { | |||
| 233 | + | t.Fatalf("marshal valid history: %v", err) | |||
| 234 | + | } | |||
| 235 | + | invalidRune := append([]byte(nil), valid...) | |||
| 236 | + | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | |||
| 237 | + | invalidUnderlineStyle := append([]byte(nil), valid...) | |||
| 238 | + | invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1) | |||
| 239 | + | ||||
| 240 | + | tests := []struct { | |||
| 241 | + | name string | |||
| 242 | + | data []byte | |||
| 243 | + | valid bool | |||
| 244 | + | }{ | |||
| 245 | + | {name: "valid", data: valid, valid: true}, | |||
| 246 | + | {name: "truncated cell", data: valid[:len(valid)-1]}, | |||
| 247 | + | {name: "trailing byte", data: append(append([]byte(nil), valid...), 0)}, | |||
| 248 | + | {name: "invalid rune", data: invalidRune}, | |||
| 249 | + | {name: "invalid underline style", data: invalidUnderlineStyle}, | |||
| 250 | + | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |||
| 251 | + | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | |||
| 252 | + | } | |||
| 253 | + | for _, tt := range tests { | |||
| 254 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 255 | + | _, preflightErr := PreflightHistoryBlob(tt.data) | |||
| 256 | + | _, unmarshalErr := UnmarshalHistory(tt.data) | |||
| 257 | + | if (preflightErr == nil) != tt.valid || (unmarshalErr == nil) != tt.valid { | |||
| 258 | + | t.Fatalf("preflight error = %v, unmarshal error = %v, want valid = %t", preflightErr, unmarshalErr, tt.valid) | |||
| 259 | + | } | |||
| 260 | + | }) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||
| 265 | + | valid, err := MarshalHistory(historyViewWithDimensions(1, 1)) | |||
| 266 | + | if err != nil { | |||
| 267 | + | f.Fatalf("marshal valid history: %v", err) | |||
| 268 | + | } | |||
| 269 | + | invalidRune := append([]byte(nil), valid...) | |||
| 270 | + | binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0)) | |||
| 271 | + | f.Add(valid) | |||
| 272 | + | f.Add(invalidRune) | |||
| 273 | + | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | |||
| 274 | + | f.Add([]byte("not a history payload")) | |||
| 275 | + | ||||
| 276 | + | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 277 | + | _, preflightErr := PreflightHistoryBlob(data) | |||
| 278 | + | _, unmarshalErr := UnmarshalHistory(data) | |||
| 279 | + | if (preflightErr == nil) != (unmarshalErr == nil) { | |||
| 280 | + | t.Fatalf("preflight error = %v, unmarshal error = %v", preflightErr, unmarshalErr) | |||
| 281 | + | } | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 230 | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | 285 | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | ||
| 231 | t.Helper() | 286 | t.Helper() | ||
| 232 | if got := view.Len(); got != len(want) { | 287 | if got := view.Len(); got != len(want) { | ||
M pkg/vt/snapshot.go +1 -4
| @@ -33,10 +33,7 @@ func (s *DecodeStats) Add(other DecodeStats) bool { | |||
| 33 | 33 | // PreflightHistoryBlob validates one self-contained history blob without | |
| 34 | 34 | // allocating decoded rows. | |
| 35 | 35 | func PreflightHistoryBlob(data []byte) (DecodeStats, error) { | |
| 36 | - | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 37 | - | return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory) | |
| 38 | - | } | |
| 39 | - | stats, ok := preflightHistory(data[5:]) | |
| 36 | + | stats, ok := preflightHistory(data) | |
| 40 | 37 | if !ok { | |
| 41 | 38 | return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory) | |
| 42 | 39 | } | |
| @@ -33,10 +33,7 @@ func (s *DecodeStats) Add(other DecodeStats) bool { | |||||
| 33 | // PreflightHistoryBlob validates one self-contained history blob without | 33 | // PreflightHistoryBlob validates one self-contained history blob without | ||
| 34 | // allocating decoded rows. | 34 | // allocating decoded rows. | ||
| 35 | func PreflightHistoryBlob(data []byte) (DecodeStats, error) { | 35 | func PreflightHistoryBlob(data []byte) (DecodeStats, error) { | ||
| 36 | - | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | 36 | + | stats, ok := preflightHistory(data) |
| 37 | - | return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory) | |||
| 38 | - | } | |||
| 39 | - | stats, ok := preflightHistory(data[5:]) | |||
| 40 | if !ok { | 37 | if !ok { | ||
| 41 | return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory) | 38 | return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory) | ||
| 42 | } | 39 | } | ||