detached dae912d

refactor(perf-harness): address review findings

Close's error result is now cached in a cliProcess field so repeated
calls return the original outcome instead of nil; the hangup regression
test closes the client via the bounded escalation ladder (with a
defensive defer) instead of manually draining waitErr; and the two
Close escalation unit tests are merged into one table-driven test per
repo convention.
commit
dae912d26288c9c4aed32d1d83539df65c9eb612
author
brice · · 2026-07-18 16:40 UTC
parent
681ca80
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +92 -55
M cmd/vev-perf-harness/hangup_test.go +12 -9
@@ -99,6 +99,14 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
9999 t.Errorf("close daemon role: %v", err)
100100 }
101101 }()
102+ // Close the client on every exit path so a failed assertion cannot leak the
103+ // process. Close is idempotent (sync.Once): the explicit call below, once
104+ // reached, is the one whose result is asserted; this defer is a backstop.
105+ defer func() {
106+ if err := client.Close(); err != nil {
107+ t.Errorf("close client role: %v", err)
108+ }
109+ }()
102110
103111 // Wait until the client is attached with a receive pump blocked mid-frame:
104112 // its trace then holds one more adapter_receive_start than end. That in-flight
@@ -112,15 +120,10 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
112120 if err := client.pty.Close(); err != nil {
113121 t.Fatalf("close client PTY master: %v", err)
114122 }
115- select {
116- case <-client.waitErr:
117- case <-time.After(10 * time.Second):
118- t.Fatal("client did not exit after PTY hangup")
119- }
120- if client.output != nil {
121- if err := client.output.Close(); err != nil {
122- t.Errorf("close client terminal output: %v", err)
123- }
123+ // Close runs the bounded escalation ladder; the hangup above already severed
124+ // the PTY, so this should reap gracefully without reaching SIGTERM/SIGKILL.
125+ if err := client.Close(); err != nil {
126+ t.Fatalf("client did not exit cleanly after PTY hangup: %v", err)
124127 }
125128
126129 // The severed client's trace must pair. On the unfixed client this fails with
@@ -99,6 +99,14 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
99 t.Errorf("close daemon role: %v", err)99 t.Errorf("close daemon role: %v", err)
100 }100 }
101 }()101 }()
102+ // Close the client on every exit path so a failed assertion cannot leak the
103+ // process. Close is idempotent (sync.Once): the explicit call below, once
104+ // reached, is the one whose result is asserted; this defer is a backstop.
105+ defer func() {
106+ if err := client.Close(); err != nil {
107+ t.Errorf("close client role: %v", err)
108+ }
109+ }()
102 110
103 // Wait until the client is attached with a receive pump blocked mid-frame:111 // Wait until the client is attached with a receive pump blocked mid-frame:
104 // its trace then holds one more adapter_receive_start than end. That in-flight112 // its trace then holds one more adapter_receive_start than end. That in-flight
@@ -112,15 +120,10 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
112 if err := client.pty.Close(); err != nil {120 if err := client.pty.Close(); err != nil {
113 t.Fatalf("close client PTY master: %v", err)121 t.Fatalf("close client PTY master: %v", err)
114 }122 }
115- select {123+ // Close runs the bounded escalation ladder; the hangup above already severed
116- case <-client.waitErr:124+ // the PTY, so this should reap gracefully without reaching SIGTERM/SIGKILL.
117- case <-time.After(10 * time.Second):125+ if err := client.Close(); err != nil {
118- t.Fatal("client did not exit after PTY hangup")126+ t.Fatalf("client did not exit cleanly after PTY hangup: %v", err)
119- }
120- if client.output != nil {
121- if err := client.output.Close(); err != nil {
122- t.Errorf("close client terminal output: %v", err)
123- }
124 }127 }
125 128
126 // The severed client's trace must pair. On the unfixed client this fails with129 // The severed client's trace must pair. On the unfixed client this fails with
M cmd/vev-perf-harness/launcher.go +8 -8
@@ -131,6 +131,7 @@ type cliProcess struct {
131131 cleanupRuntime func() error
132132 terminalReady bool
133133 readyPath string
134+ closeResult error
134135 }
135136
136137 func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
@@ -634,7 +635,6 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
634635 // teardown. Every path closes the PTY exactly once and finalizes output and
635636 // runtime state.
636637 func (p *cliProcess) Close() error {
637- var result error
638638 p.closed.Do(func() {
639639 // 1. Drain terminal output for the whole teardown. Without an active
640640 // consumer a full chunks channel stalls copyTerminal, which then stops
@@ -715,9 +715,9 @@ func (p *cliProcess) Close() error {
715715 // downstream merge mystery. The graceful rungs never error on their own.
716716 if killed {
717717 if exited {
718- result = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")
718+ p.closeResult = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")
719719 } else {
720- result = errors.New("timed out reaping public CLI process")
720+ p.closeResult = errors.New("timed out reaping public CLI process")
721721 }
722722 }
723723
@@ -731,17 +731,17 @@ func (p *cliProcess) Close() error {
731731 <-drainDone
732732 }
733733 if p.output != nil {
734- if err := p.output.Close(); result == nil {
735- result = err
734+ if err := p.output.Close(); p.closeResult == nil {
735+ p.closeResult = err
736736 }
737737 }
738738 if p.cleanupRuntime != nil {
739- if err := p.cleanupRuntime(); result == nil {
740- result = err
739+ if err := p.cleanupRuntime(); p.closeResult == nil {
740+ p.closeResult = err
741741 }
742742 }
743743 })
744- return result
744+ return p.closeResult
745745 }
746746
747747 // drainTerminalOutput consumes copyTerminal's chunks for the duration of a
@@ -131,6 +131,7 @@ type cliProcess struct {
131 cleanupRuntime func() error131 cleanupRuntime func() error
132 terminalReady bool132 terminalReady bool
133 readyPath string133 readyPath string
134+ closeResult error
134 }135 }
135 136
136 func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {137 func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
@@ -634,7 +635,6 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
634 // teardown. Every path closes the PTY exactly once and finalizes output and635 // teardown. Every path closes the PTY exactly once and finalizes output and
635 // runtime state.636 // runtime state.
636 func (p *cliProcess) Close() error {637 func (p *cliProcess) Close() error {
637- var result error
638 p.closed.Do(func() {638 p.closed.Do(func() {
639 // 1. Drain terminal output for the whole teardown. Without an active639 // 1. Drain terminal output for the whole teardown. Without an active
640 // consumer a full chunks channel stalls copyTerminal, which then stops640 // consumer a full chunks channel stalls copyTerminal, which then stops
@@ -715,9 +715,9 @@ func (p *cliProcess) Close() error {
715 // downstream merge mystery. The graceful rungs never error on their own.715 // downstream merge mystery. The graceful rungs never error on their own.
716 if killed {716 if killed {
717 if exited {717 if exited {
718- result = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")718+ p.closeResult = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")
719 } else {719 } else {
720- result = errors.New("timed out reaping public CLI process")720+ p.closeResult = errors.New("timed out reaping public CLI process")
721 }721 }
722 }722 }
723 723
@@ -731,17 +731,17 @@ func (p *cliProcess) Close() error {
731 <-drainDone731 <-drainDone
732 }732 }
733 if p.output != nil {733 if p.output != nil {
734- if err := p.output.Close(); result == nil {734+ if err := p.output.Close(); p.closeResult == nil {
735- result = err735+ p.closeResult = err
736 }736 }
737 }737 }
738 if p.cleanupRuntime != nil {738 if p.cleanupRuntime != nil {
739- if err := p.cleanupRuntime(); result == nil {739+ if err := p.cleanupRuntime(); p.closeResult == nil {
740- result = err740+ p.closeResult = err
741 }741 }
742 }742 }
743 })743 })
744- return result744+ return p.closeResult
745 }745 }
746 746
747 // drainTerminalOutput consumes copyTerminal's chunks for the duration of a747 // drainTerminalOutput consumes copyTerminal's chunks for the duration of a
M cmd/vev-perf-harness/launcher_test.go +72 -38
@@ -134,45 +134,79 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
134134 }
135135 }
136136
137-func TestCLIProcessCloseSIGTERMStopIsGraceful(t *testing.T) {
138- // A role reaped during the SIGTERM wait stopped gracefully: no SIGKILL, and
139- // Close must not report an error for a stage that can still flush a trace.
140- waitErr := make(chan error, 1)
141- never := make(chan time.Time)
142- var term, kill int
143- p := &cliProcess{
144- waitErr: waitErr,
145- waitTimeout: func() <-chan time.Time { return never },
146- forceCleanup: func() { term++; waitErr <- nil },
147- forceKill: func() { kill++ },
148- }
149- if err := p.Close(); err != nil {
150- t.Fatalf("graceful SIGTERM stop returned error: %v", err)
151- }
152- if term != 1 || kill != 0 {
153- t.Fatalf("TERM/KILL calls = %d/%d, want 1/0", term, kill)
154- }
155-}
156-
157-func TestCLIProcessCloseErrorsAfterKillEscalation(t *testing.T) {
158- // A role that only dies to SIGKILL may have truncated its trace, so Close must
159- // surface a named escalation error rather than a silent success.
160- waitErr := make(chan error, 1)
161- deadline := make(chan time.Time, 1)
162- deadline <- time.Now() // one ready deadline releases the SIGTERM wait
163- var term, kill int
164- p := &cliProcess{
165- waitErr: waitErr,
166- waitTimeout: func() <-chan time.Time { return deadline },
167- forceCleanup: func() { term++ },
168- forceKill: func() { kill++; waitErr <- nil },
169- }
170- err := p.Close()
171- if err == nil || !strings.Contains(err.Error(), "killed") {
172- t.Fatalf("kill escalation error = %v, want a named killed escalation", err)
137+func TestCLIProcessCloseEscalationOutcome(t *testing.T) {
138+ tests := []struct {
139+ name string
140+ reapsAt string // "term" or "kill": which rung's callback reaps the process
141+ waitTimeout func() func() <-chan time.Time
142+ wantErrSub string // "" means Close must return nil
143+ wantTerm int
144+ wantKill int
145+ }{
146+ {
147+ // A role reaped during the SIGTERM wait stopped gracefully: no SIGKILL,
148+ // and Close must not report an error for a stage that can still flush a
149+ // trace. The deadline channel never fires, so only the SIGTERM callback
150+ // reaping the process can end the wait.
151+ name: "SIGTERM stop is graceful",
152+ reapsAt: "term",
153+ waitTimeout: func() func() <-chan time.Time {
154+ never := make(chan time.Time)
155+ return func() <-chan time.Time { return never }
156+ },
157+ wantErrSub: "",
158+ wantTerm: 1,
159+ wantKill: 0,
160+ },
161+ {
162+ // A role that only dies to SIGKILL may have truncated its trace, so
163+ // Close must surface a named escalation error rather than a silent
164+ // success. One ready deadline releases the SIGTERM wait so the ladder
165+ // escalates to SIGKILL, whose callback then reaps the process.
166+ name: "errors after kill escalation",
167+ reapsAt: "kill",
168+ waitTimeout: func() func() <-chan time.Time {
169+ deadline := make(chan time.Time, 1)
170+ deadline <- time.Now()
171+ return func() <-chan time.Time { return deadline }
172+ },
173+ wantErrSub: "killed",
174+ wantTerm: 1,
175+ wantKill: 1,
176+ },
173177 }
174- if term != 1 || kill != 1 {
175- t.Fatalf("TERM/KILL calls = %d/%d, want 1/1", term, kill)
178+ for _, tt := range tests {
179+ t.Run(tt.name, func(t *testing.T) {
180+ waitErr := make(chan error, 1)
181+ var term, kill int
182+ p := &cliProcess{
183+ waitErr: waitErr,
184+ waitTimeout: tt.waitTimeout(),
185+ forceCleanup: func() {
186+ term++
187+ if tt.reapsAt == "term" {
188+ waitErr <- nil
189+ }
190+ },
191+ forceKill: func() {
192+ kill++
193+ if tt.reapsAt == "kill" {
194+ waitErr <- nil
195+ }
196+ },
197+ }
198+ err := p.Close()
199+ if tt.wantErrSub == "" {
200+ if err != nil {
201+ t.Fatalf("Close() error = %v, want nil", err)
202+ }
203+ } else if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
204+ t.Fatalf("Close() error = %v, want an error containing %q", err, tt.wantErrSub)
205+ }
206+ if term != tt.wantTerm || kill != tt.wantKill {
207+ t.Fatalf("TERM/KILL calls = %d/%d, want %d/%d", term, kill, tt.wantTerm, tt.wantKill)
208+ }
209+ })
176210 }
177211 }
178212
@@ -134,45 +134,79 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
134 }134 }
135 }135 }
136 136
137-func TestCLIProcessCloseSIGTERMStopIsGraceful(t *testing.T) {137+func TestCLIProcessCloseEscalationOutcome(t *testing.T) {
138- // A role reaped during the SIGTERM wait stopped gracefully: no SIGKILL, and138+ tests := []struct {
139- // Close must not report an error for a stage that can still flush a trace.139+ name string
140- waitErr := make(chan error, 1)140+ reapsAt string // "term" or "kill": which rung's callback reaps the process
141- never := make(chan time.Time)141+ waitTimeout func() func() <-chan time.Time
142- var term, kill int142+ wantErrSub string // "" means Close must return nil
143- p := &cliProcess{143+ wantTerm int
144- waitErr: waitErr,144+ wantKill int
145- waitTimeout: func() <-chan time.Time { return never },145+ }{
146- forceCleanup: func() { term++; waitErr <- nil },146+ {
147- forceKill: func() { kill++ },147+ // A role reaped during the SIGTERM wait stopped gracefully: no SIGKILL,
148- }148+ // and Close must not report an error for a stage that can still flush a
149- if err := p.Close(); err != nil {149+ // trace. The deadline channel never fires, so only the SIGTERM callback
150- t.Fatalf("graceful SIGTERM stop returned error: %v", err)150+ // reaping the process can end the wait.
151- }151+ name: "SIGTERM stop is graceful",
152- if term != 1 || kill != 0 {152+ reapsAt: "term",
153- t.Fatalf("TERM/KILL calls = %d/%d, want 1/0", term, kill)153+ waitTimeout: func() func() <-chan time.Time {
154- }154+ never := make(chan time.Time)
155-}155+ return func() <-chan time.Time { return never }
156-156+ },
157-func TestCLIProcessCloseErrorsAfterKillEscalation(t *testing.T) {157+ wantErrSub: "",
158- // A role that only dies to SIGKILL may have truncated its trace, so Close must158+ wantTerm: 1,
159- // surface a named escalation error rather than a silent success.159+ wantKill: 0,
160- waitErr := make(chan error, 1)160+ },
161- deadline := make(chan time.Time, 1)161+ {
162- deadline <- time.Now() // one ready deadline releases the SIGTERM wait162+ // A role that only dies to SIGKILL may have truncated its trace, so
163- var term, kill int163+ // Close must surface a named escalation error rather than a silent
164- p := &cliProcess{164+ // success. One ready deadline releases the SIGTERM wait so the ladder
165- waitErr: waitErr,165+ // escalates to SIGKILL, whose callback then reaps the process.
166- waitTimeout: func() <-chan time.Time { return deadline },166+ name: "errors after kill escalation",
167- forceCleanup: func() { term++ },167+ reapsAt: "kill",
168- forceKill: func() { kill++; waitErr <- nil },168+ waitTimeout: func() func() <-chan time.Time {
169- }169+ deadline := make(chan time.Time, 1)
170- err := p.Close()170+ deadline <- time.Now()
171- if err == nil || !strings.Contains(err.Error(), "killed") {171+ return func() <-chan time.Time { return deadline }
172- t.Fatalf("kill escalation error = %v, want a named killed escalation", err)172+ },
173+ wantErrSub: "killed",
174+ wantTerm: 1,
175+ wantKill: 1,
176+ },
173 }177 }
174- if term != 1 || kill != 1 {178+ for _, tt := range tests {
175- t.Fatalf("TERM/KILL calls = %d/%d, want 1/1", term, kill)179+ t.Run(tt.name, func(t *testing.T) {
180+ waitErr := make(chan error, 1)
181+ var term, kill int
182+ p := &cliProcess{
183+ waitErr: waitErr,
184+ waitTimeout: tt.waitTimeout(),
185+ forceCleanup: func() {
186+ term++
187+ if tt.reapsAt == "term" {
188+ waitErr <- nil
189+ }
190+ },
191+ forceKill: func() {
192+ kill++
193+ if tt.reapsAt == "kill" {
194+ waitErr <- nil
195+ }
196+ },
197+ }
198+ err := p.Close()
199+ if tt.wantErrSub == "" {
200+ if err != nil {
201+ t.Fatalf("Close() error = %v, want nil", err)
202+ }
203+ } else if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
204+ t.Fatalf("Close() error = %v, want an error containing %q", err, tt.wantErrSub)
205+ }
206+ if term != tt.wantTerm || kill != tt.wantKill {
207+ t.Fatalf("TERM/KILL calls = %d/%d, want %d/%d", term, kill, tt.wantTerm, tt.wantKill)
208+ }
209+ })
176 }210 }
177 }211 }
178 212

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