detached dbc6bb7

fix(perf-harness): drain client output during shutdown

commit
dbc6bb740c6d75d139e6eb1c9ed2054a6c5397d3
author
brice · · 2026-07-18 13:55 UTC
parent
68c86a5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +55 -3
M cmd/vev-perf-harness/launcher.go +6 -3
@@ -628,9 +628,6 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
628628 func (p *cliProcess) Close() error {
629629 var result error
630630 p.closed.Do(func() {
631- if p.done != nil {
632- close(p.done)
633- }
634631 // A public terminal client owns a shell session. Ask that shell to exit
635632 // before closing the PTY so its transport can finish its receive span;
636633 // this avoids turning ordinary harness teardown into an aborted trace.
@@ -654,6 +651,12 @@ func (p *cliProcess) Close() error {
654651 case <-p.closeDeadline():
655652 }
656653 }
654+ // Keep draining terminal output while the client exits gracefully. If the
655+ // drain stops first, restoration output can fill the PTY and block the
656+ // client before it closes its observed transport receive operation.
657+ if p.done != nil {
658+ close(p.done)
659+ }
657660 if p.pty != nil {
658661 _ = p.pty.Close()
659662 }
@@ -628,9 +628,6 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
628 func (p *cliProcess) Close() error {628 func (p *cliProcess) Close() error {
629 var result error629 var result error
630 p.closed.Do(func() {630 p.closed.Do(func() {
631- if p.done != nil {
632- close(p.done)
633- }
634 // A public terminal client owns a shell session. Ask that shell to exit631 // A public terminal client owns a shell session. Ask that shell to exit
635 // before closing the PTY so its transport can finish its receive span;632 // before closing the PTY so its transport can finish its receive span;
636 // this avoids turning ordinary harness teardown into an aborted trace.633 // this avoids turning ordinary harness teardown into an aborted trace.
@@ -654,6 +651,12 @@ func (p *cliProcess) Close() error {
654 case <-p.closeDeadline():651 case <-p.closeDeadline():
655 }652 }
656 }653 }
654+ // Keep draining terminal output while the client exits gracefully. If the
655+ // drain stops first, restoration output can fill the PTY and block the
656+ // client before it closes its observed transport receive operation.
657+ if p.done != nil {
658+ close(p.done)
659+ }
657 if p.pty != nil {660 if p.pty != nil {
658 _ = p.pty.Close()661 _ = p.pty.Close()
659 }662 }
M cmd/vev-perf-harness/launcher_test.go +49 -0
@@ -11,12 +11,61 @@ import (
1111 "os/exec"
1212 "path/filepath"
1313 "strings"
14+ "sync"
1415 "testing"
1516 "time"
1617
1718 "github.com/bnema/vev/pkg/safedir"
1819 )
1920
21+func TestCLIProcessCloseKeepsTerminalDrainActiveUntilClientExit(t *testing.T) {
22+ pty, err := os.CreateTemp(t.TempDir(), "pty")
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ waiting := make(chan struct{})
27+ deadline := make(chan time.Time)
28+ waitErr := make(chan error)
29+ done := make(chan struct{})
30+ closed := make(chan error, 1)
31+ var waitingOnce sync.Once
32+ p := &cliProcess{
33+ pty: pty,
34+ done: done,
35+ waitErr: waitErr,
36+ waitTimeout: func() <-chan time.Time {
37+ waitingOnce.Do(func() { close(waiting) })
38+ return deadline
39+ },
40+ }
41+ go func() { closed <- p.Close() }()
42+
43+ select {
44+ case <-waiting:
45+ case <-time.After(time.Second):
46+ t.Fatal("Close did not begin waiting for graceful client exit")
47+ }
48+ select {
49+ case <-done:
50+ t.Fatal("terminal drain stopped before the client exited")
51+ default:
52+ }
53+ waitErr <- nil
54+ select {
55+ case err := <-closed:
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ case <-time.After(time.Second):
60+ t.Fatal("Close did not finish after the client exited")
61+ }
62+ select {
63+ case <-done:
64+ default:
65+ t.Fatal("terminal drain remained active after client exit")
66+ }
67+}
68+
2069 func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
2170 deadline := make(chan time.Time)
2271 close(deadline)
@@ -11,12 +11,61 @@ import (
11 "os/exec"11 "os/exec"
12 "path/filepath"12 "path/filepath"
13 "strings"13 "strings"
14+ "sync"
14 "testing"15 "testing"
15 "time"16 "time"
16 17
17 "github.com/bnema/vev/pkg/safedir"18 "github.com/bnema/vev/pkg/safedir"
18 )19 )
19 20
21+func TestCLIProcessCloseKeepsTerminalDrainActiveUntilClientExit(t *testing.T) {
22+ pty, err := os.CreateTemp(t.TempDir(), "pty")
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ waiting := make(chan struct{})
27+ deadline := make(chan time.Time)
28+ waitErr := make(chan error)
29+ done := make(chan struct{})
30+ closed := make(chan error, 1)
31+ var waitingOnce sync.Once
32+ p := &cliProcess{
33+ pty: pty,
34+ done: done,
35+ waitErr: waitErr,
36+ waitTimeout: func() <-chan time.Time {
37+ waitingOnce.Do(func() { close(waiting) })
38+ return deadline
39+ },
40+ }
41+ go func() { closed <- p.Close() }()
42+
43+ select {
44+ case <-waiting:
45+ case <-time.After(time.Second):
46+ t.Fatal("Close did not begin waiting for graceful client exit")
47+ }
48+ select {
49+ case <-done:
50+ t.Fatal("terminal drain stopped before the client exited")
51+ default:
52+ }
53+ waitErr <- nil
54+ select {
55+ case err := <-closed:
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ case <-time.After(time.Second):
60+ t.Fatal("Close did not finish after the client exited")
61+ }
62+ select {
63+ case <-done:
64+ default:
65+ t.Fatal("terminal drain remained active after client exit")
66+ }
67+}
68+
20 func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {69 func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
21 deadline := make(chan time.Time)70 deadline := make(chan time.Time)
22 close(deadline)71 close(deadline)

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close