detached 092c799

Merge pull request #115 from bnema/fix/perf-harness-teardown-trace

fix: survive client teardown hangup without truncating perf traces
commit
092c79957e77325b3068ad9a826b3c4d1b3cde1a
author
bnema · · 2026-07-19 06:01 UTC
committer
GitHub · · 2026-07-19 06:01 UTC
merge of
156ea64 + dae912d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +384 -61
A cmd/vev-perf-harness/hangup_test.go +171 -0
@@ -0,0 +1,171 @@
1+//go:build linux
2+
3+package main
4+
5+import (
6+ "encoding/json"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+)
15+
16+// TestClientTracePairsAfterPTYHangup drives the real teardown escalation: a
17+// live client is severed by closing its controlling PTY master (the harness
18+// hangup stage) with no graceful "exit\n". A client that dies from the default
19+// SIGHUP disposition truncates its receive pump's in-flight span, so its trace
20+// merge fails with an unpaired adapter_receive_start. A client that survives the
21+// hangup closes its transport and serializes the matching receive-end first, so
22+// the merge pairs.
23+func TestClientTracePairsAfterPTYHangup(t *testing.T) {
24+ if testing.Short() {
25+ t.Skip("public CLI hangup lifecycle")
26+ }
27+ root, err := filepath.Abs(filepath.Join("..", ".."))
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ // Keep the run directory (and thus the Unix socket path) short: MkdirTemp in
32+ // the system temp root stays well under the platform socket-path limit.
33+ runDir, err := os.MkdirTemp("", "vev-hangup-")
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ removeTestTree(t, runDir)
38+ bin := filepath.Join(runDir, "vev")
39+ build := exec.Command("go", "build", "-o", bin, "./")
40+ build.Dir = root
41+ if output, err := build.CombinedOutput(); err != nil {
42+ t.Fatalf("build public CLI: %v\n%s", err, output)
43+ }
44+
45+ s := scenario{ID: "hangup-local", Transport: "local", Workload: "idle", Roles: []string{"daemon", "client"}}
46+ selected, err := manifestTransport(manifest{}, s.Transport)
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+
51+ maps := make([]processMapping, 0, len(s.Roles))
52+ for i, role := range s.Roles {
53+ pid := fmt.Sprintf("%s-r%03d-%s-%02d", safeName(s.ID), 1, safeName(role), i+1)
54+ path := filepath.Join(runDir, pid+".jsonl")
55+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if err := f.Close(); err != nil {
60+ t.Fatal(err)
61+ }
62+ maps = append(maps, processMapping{ProcessID: pid, ClockDomain: pid, TracePath: path, Role: role, Scenario: s.ID, Run: 1, Identity: "harness-assigned:" + pid})
63+ }
64+
65+ launcher := &cliLauncher{bin: bin}
66+ var daemonProc launchedProcess
67+ var client *cliProcess
68+ var clientMapping processMapping
69+ for _, pm := range launchOrder(maps) {
70+ p, err := launcher.Launch(pm, routeRoleArgs(s, pm, selected))
71+ if err != nil {
72+ t.Fatalf("launch %s: %v", pm.Role, err)
73+ }
74+ switch pm.Role {
75+ case "daemon":
76+ daemonProc = p
77+ ready, ok := p.(interface{ WaitReady() error })
78+ if !ok {
79+ t.Fatal("daemon role does not expose WaitReady")
80+ }
81+ if err := ready.WaitReady(); err != nil {
82+ t.Fatalf("daemon readiness: %v", err)
83+ }
84+ case "client":
85+ cp, ok := p.(*cliProcess)
86+ if !ok {
87+ t.Fatalf("client role is %T, want *cliProcess", p)
88+ }
89+ client, clientMapping = cp, pm
90+ }
91+ }
92+ if client == nil || daemonProc == nil {
93+ t.Fatal("did not launch both roles")
94+ }
95+ // Close the daemon last, on every exit path, so a failed assertion cannot
96+ // leak a foreground daemon or its runtime directory.
97+ defer func() {
98+ if err := daemonProc.Close(); err != nil {
99+ t.Errorf("close daemon role: %v", err)
100+ }
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+ }()
110+
111+ // Wait until the client is attached with a receive pump blocked mid-frame:
112+ // its trace then holds one more adapter_receive_start than end. That in-flight
113+ // span is exactly what an ungraceful death truncates.
114+ if err := waitForInflightReceive(clientMapping.TracePath, 10*time.Second); err != nil {
115+ t.Fatalf("client never reached a blocked receive: %v", err)
116+ }
117+
118+ // Sever the controlling terminal with no graceful exit. This is the harness
119+ // hangup escalation stage, delivering SIGHUP to the client session leader.
120+ if err := client.pty.Close(); err != nil {
121+ t.Fatalf("close client PTY master: %v", err)
122+ }
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)
127+ }
128+
129+ // The severed client's trace must pair. On the unfixed client this fails with
130+ // "missing process-local span pair" for the truncated adapter_receive span.
131+ if _, err := mergeProcessTraces([]processMapping{clientMapping}); err != nil {
132+ t.Fatalf("client trace did not pair after PTY hangup: %v", err)
133+ }
134+}
135+
136+// waitForInflightReceive polls a process trace until it holds an unpaired
137+// adapter_receive_start (more starts than ends). It tolerates a partially
138+// written trailing line while the traced process appends concurrently.
139+func waitForInflightReceive(path string, timeout time.Duration) error {
140+ deadline := time.Now().Add(timeout)
141+ for {
142+ starts, ends := 0, 0
143+ data, err := os.ReadFile(path)
144+ if err != nil {
145+ return err
146+ }
147+ for _, line := range strings.Split(string(data), "\n") {
148+ line = strings.TrimSpace(line)
149+ if line == "" {
150+ continue
151+ }
152+ var r traceRecord
153+ if json.Unmarshal([]byte(line), &r) != nil {
154+ continue // a concurrently appended, not-yet-complete final line
155+ }
156+ switch r.Kind {
157+ case "adapter_receive_start":
158+ starts++
159+ case "adapter_receive_end":
160+ ends++
161+ }
162+ }
163+ if starts > ends {
164+ return nil
165+ }
166+ if time.Now().After(deadline) {
167+ return fmt.Errorf("adapter_receive starts=%d ends=%d, want an in-flight start", starts, ends)
168+ }
169+ time.Sleep(10 * time.Millisecond)
170+ }
171+}
@@ -0,0 +1,171 @@
1+//go:build linux
2+
3+package main
4+
5+import (
6+ "encoding/json"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+)
15+
16+// TestClientTracePairsAfterPTYHangup drives the real teardown escalation: a
17+// live client is severed by closing its controlling PTY master (the harness
18+// hangup stage) with no graceful "exit\n". A client that dies from the default
19+// SIGHUP disposition truncates its receive pump's in-flight span, so its trace
20+// merge fails with an unpaired adapter_receive_start. A client that survives the
21+// hangup closes its transport and serializes the matching receive-end first, so
22+// the merge pairs.
23+func TestClientTracePairsAfterPTYHangup(t *testing.T) {
24+ if testing.Short() {
25+ t.Skip("public CLI hangup lifecycle")
26+ }
27+ root, err := filepath.Abs(filepath.Join("..", ".."))
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ // Keep the run directory (and thus the Unix socket path) short: MkdirTemp in
32+ // the system temp root stays well under the platform socket-path limit.
33+ runDir, err := os.MkdirTemp("", "vev-hangup-")
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ removeTestTree(t, runDir)
38+ bin := filepath.Join(runDir, "vev")
39+ build := exec.Command("go", "build", "-o", bin, "./")
40+ build.Dir = root
41+ if output, err := build.CombinedOutput(); err != nil {
42+ t.Fatalf("build public CLI: %v\n%s", err, output)
43+ }
44+
45+ s := scenario{ID: "hangup-local", Transport: "local", Workload: "idle", Roles: []string{"daemon", "client"}}
46+ selected, err := manifestTransport(manifest{}, s.Transport)
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+
51+ maps := make([]processMapping, 0, len(s.Roles))
52+ for i, role := range s.Roles {
53+ pid := fmt.Sprintf("%s-r%03d-%s-%02d", safeName(s.ID), 1, safeName(role), i+1)
54+ path := filepath.Join(runDir, pid+".jsonl")
55+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if err := f.Close(); err != nil {
60+ t.Fatal(err)
61+ }
62+ maps = append(maps, processMapping{ProcessID: pid, ClockDomain: pid, TracePath: path, Role: role, Scenario: s.ID, Run: 1, Identity: "harness-assigned:" + pid})
63+ }
64+
65+ launcher := &cliLauncher{bin: bin}
66+ var daemonProc launchedProcess
67+ var client *cliProcess
68+ var clientMapping processMapping
69+ for _, pm := range launchOrder(maps) {
70+ p, err := launcher.Launch(pm, routeRoleArgs(s, pm, selected))
71+ if err != nil {
72+ t.Fatalf("launch %s: %v", pm.Role, err)
73+ }
74+ switch pm.Role {
75+ case "daemon":
76+ daemonProc = p
77+ ready, ok := p.(interface{ WaitReady() error })
78+ if !ok {
79+ t.Fatal("daemon role does not expose WaitReady")
80+ }
81+ if err := ready.WaitReady(); err != nil {
82+ t.Fatalf("daemon readiness: %v", err)
83+ }
84+ case "client":
85+ cp, ok := p.(*cliProcess)
86+ if !ok {
87+ t.Fatalf("client role is %T, want *cliProcess", p)
88+ }
89+ client, clientMapping = cp, pm
90+ }
91+ }
92+ if client == nil || daemonProc == nil {
93+ t.Fatal("did not launch both roles")
94+ }
95+ // Close the daemon last, on every exit path, so a failed assertion cannot
96+ // leak a foreground daemon or its runtime directory.
97+ defer func() {
98+ if err := daemonProc.Close(); err != nil {
99+ t.Errorf("close daemon role: %v", err)
100+ }
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+ }()
110+
111+ // Wait until the client is attached with a receive pump blocked mid-frame:
112+ // its trace then holds one more adapter_receive_start than end. That in-flight
113+ // span is exactly what an ungraceful death truncates.
114+ if err := waitForInflightReceive(clientMapping.TracePath, 10*time.Second); err != nil {
115+ t.Fatalf("client never reached a blocked receive: %v", err)
116+ }
117+
118+ // Sever the controlling terminal with no graceful exit. This is the harness
119+ // hangup escalation stage, delivering SIGHUP to the client session leader.
120+ if err := client.pty.Close(); err != nil {
121+ t.Fatalf("close client PTY master: %v", err)
122+ }
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)
127+ }
128+
129+ // The severed client's trace must pair. On the unfixed client this fails with
130+ // "missing process-local span pair" for the truncated adapter_receive span.
131+ if _, err := mergeProcessTraces([]processMapping{clientMapping}); err != nil {
132+ t.Fatalf("client trace did not pair after PTY hangup: %v", err)
133+ }
134+}
135+
136+// waitForInflightReceive polls a process trace until it holds an unpaired
137+// adapter_receive_start (more starts than ends). It tolerates a partially
138+// written trailing line while the traced process appends concurrently.
139+func waitForInflightReceive(path string, timeout time.Duration) error {
140+ deadline := time.Now().Add(timeout)
141+ for {
142+ starts, ends := 0, 0
143+ data, err := os.ReadFile(path)
144+ if err != nil {
145+ return err
146+ }
147+ for _, line := range strings.Split(string(data), "\n") {
148+ line = strings.TrimSpace(line)
149+ if line == "" {
150+ continue
151+ }
152+ var r traceRecord
153+ if json.Unmarshal([]byte(line), &r) != nil {
154+ continue // a concurrently appended, not-yet-complete final line
155+ }
156+ switch r.Kind {
157+ case "adapter_receive_start":
158+ starts++
159+ case "adapter_receive_end":
160+ ends++
161+ }
162+ }
163+ if starts > ends {
164+ return nil
165+ }
166+ if time.Now().After(deadline) {
167+ return fmt.Errorf("adapter_receive starts=%d ends=%d, want an in-flight start", starts, ends)
168+ }
169+ time.Sleep(10 * time.Millisecond)
170+ }
171+}
M cmd/vev-perf-harness/launcher.go +128 -61
@@ -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) {
@@ -625,89 +626,150 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
625626 return nil
626627 }
627628
629+// Close tears down a launched role along a bounded escalation ladder. Each rung
630+// performs one action, then waits a single closeDeadline for the process to be
631+// reaped before the next rung escalates. The graceful rungs — the public daemon
632+// stop, the client shell exit, the controlling-terminal hangup, and SIGTERM —
633+// let a role close its blocked transport operations and serialize their end
634+// marks; only a SIGKILL severs a process before it can, so only that rung fails
635+// teardown. Every path closes the PTY exactly once and finalizes output and
636+// runtime state.
628637 func (p *cliProcess) Close() error {
629- var result error
630638 p.closed.Do(func() {
631- // No measurement waits for terminal chunks during teardown. Continue
632- // consuming them so a full channel cannot block the client before it
633- // receives the shell exit request and closes its transport receive span.
634- if p.chunks != nil && p.done != nil {
635- go func() {
636- for {
637- select {
638- case _, ok := <-p.chunks:
639- if !ok {
640- return
641- }
642- case <-p.done:
643- return
644- }
645- }
646- }()
647- }
648- // A public terminal client owns a shell session. Ask that shell to exit
649- // before closing the PTY so its transport can finish its receive span;
650- // this avoids turning ordinary harness teardown into an aborted trace.
639+ // 1. Drain terminal output for the whole teardown. Without an active
640+ // consumer a full chunks channel stalls copyTerminal, which then stops
641+ // reading the PTY master and blocks the client mid-restore before it can
642+ // close its observed receive operation. The drain stops with p.done.
643+ drainDone := p.drainTerminalOutput()
644+
651645 exited := false
652- if p.shutdown != nil && p.waitErr != nil {
646+ // waitForExit blocks up to one bounded deadline for the reap.
647+ waitForExit := func() {
648+ if p.waitErr == nil || exited {
649+ return
650+ }
653651 select {
654652 case <-p.waitErr:
655653 exited = true
656- default:
657- // The client may have just ended the last session and raced this
658- // best-effort public shutdown. The normal bounded reaping path below
659- // remains authoritative in that case.
660- _ = p.shutdown()
654+ case <-p.closeDeadline():
661655 }
662656 }
663- if p.pty != nil && p.waitErr != nil {
664- _, _ = p.pty.Write([]byte("exit\n"))
657+ // reaped reports whether the process is already gone; a role with no
658+ // process to reap needs no escalation at all.
659+ reaped := func() bool {
660+ if p.waitErr == nil || exited {
661+ return true
662+ }
665663 select {
666664 case <-p.waitErr:
667665 exited = true
668- case <-p.closeDeadline():
666+ return true
667+ default:
668+ return false
669669 }
670670 }
671- // Keep draining terminal output while the client exits gracefully. If the
672- // drain stops first, restoration output can fill the PTY and block the
673- // client before it closes its observed transport receive operation.
674- if p.done != nil {
675- close(p.done)
671+ ptyClosed := false
672+ closePTY := func() {
673+ if p.pty != nil && !ptyClosed {
674+ _ = p.pty.Close()
675+ ptyClosed = true
676+ }
676677 }
677- if p.pty != nil {
678- _ = p.pty.Close()
678+
679+ // 2. Daemon role: ask the public daemon to stop before any signal so its
680+ // blocked carriage can close and serialize failed end marks.
681+ if p.shutdown != nil && !reaped() {
682+ _ = p.shutdown()
683+ waitForExit()
679684 }
680- if p.waitErr != nil && !exited {
681- select {
682- case <-p.waitErr:
683- case <-p.closeDeadline():
684- // Teardown is deliberately bounded: a stuck public CLI or child
685- // must not hold a local harness run indefinitely.
686- p.forceProcessGroupCleanup()
687- select {
688- case <-p.waitErr:
689- case <-p.closeDeadline():
690- p.forceProcessGroupKill()
691- select {
692- case <-p.waitErr:
693- case <-p.closeDeadline():
694- result = errors.New("timed out reaping public CLI process")
695- }
696- }
685+ // 3. Client role: a public terminal client owns a shell session. Ask that
686+ // shell to exit so the client detaches and its transport finishes its
687+ // receive span cleanly.
688+ if p.pty != nil && !reaped() {
689+ _, _ = p.pty.Write([]byte("exit\n"))
690+ waitForExit()
691+ }
692+ // 4. Hang up the controlling terminal. The attach path installs a signal
693+ // handler, so this SIGHUP is a graceful detach — not an abrupt kill — and
694+ // the client still flushes its in-flight receive end mark.
695+ if p.pty != nil && !reaped() {
696+ closePTY()
697+ waitForExit()
698+ }
699+ // 5. SIGTERM the role's own process group. The daemon and, through its
700+ // attach signal handler, the client both treat this as a graceful stop.
701+ if !reaped() {
702+ p.forceProcessGroupCleanup()
703+ waitForExit()
704+ }
705+ // 6. SIGKILL the role's process group. This severs the process; an
706+ // in-flight observed operation cannot be closed, so its trace may truncate.
707+ killed := false
708+ if !reaped() {
709+ killed = true
710+ p.forceProcessGroupKill()
711+ waitForExit()
712+ }
713+ // 7. A SIGKILL escalation cannot guarantee a complete trace. Fail teardown
714+ // with a clear cause rather than letting the truncation surface later as a
715+ // downstream merge mystery. The graceful rungs never error on their own.
716+ if killed {
717+ if exited {
718+ p.closeResult = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")
719+ } else {
720+ p.closeResult = errors.New("timed out reaping public CLI process")
697721 }
698722 }
723+
724+ // Close the PTY exactly once on every path: an early graceful exit skips
725+ // the hangup rung, so close it here before the drain stops.
726+ closePTY()
727+ if p.done != nil {
728+ close(p.done)
729+ }
730+ if drainDone != nil {
731+ <-drainDone
732+ }
699733 if p.output != nil {
700- if err := p.output.Close(); result == nil {
701- result = err
734+ if err := p.output.Close(); p.closeResult == nil {
735+ p.closeResult = err
702736 }
703737 }
704738 if p.cleanupRuntime != nil {
705- if err := p.cleanupRuntime(); result == nil {
706- result = err
739+ if err := p.cleanupRuntime(); p.closeResult == nil {
740+ p.closeResult = err
707741 }
708742 }
709743 })
710- return result
744+ return p.closeResult
745+}
746+
747+// drainTerminalOutput consumes copyTerminal's chunks for the duration of a
748+// teardown so a full channel can never wedge copyTerminal (and thus the client's
749+// PTY). It returns nil when there is no terminal to drain, and otherwise a
750+// channel closed once the consumer has stopped on p.done.
751+func (p *cliProcess) drainTerminalOutput() <-chan struct{} {
752+ if p.chunks == nil || p.done == nil {
753+ return nil
754+ }
755+ done := make(chan struct{})
756+ chunks := p.chunks
757+ go func() {
758+ defer close(done)
759+ for {
760+ select {
761+ case _, ok := <-chunks:
762+ if !ok {
763+ // copyTerminal closed the channel; a closed channel is always
764+ // ready, so stop selecting it and wait only for the stop signal.
765+ chunks = nil
766+ }
767+ case <-p.done:
768+ return
769+ }
770+ }
771+ }()
772+ return done
711773 }
712774
713775 func (p *cliProcess) closeDeadline() <-chan time.Time {
@@ -748,7 +810,12 @@ func (p *cliProcess) forceProcessGroupKill() {
748810 // openPTY is the small Linux PTY boundary needed to drive the public terminal
749811 // client. No daemon or adapter implementation is imported by this command.
750812 func openPTY() (*os.File, *os.File, error) {
751- masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
813+ // O_CLOEXEC keeps these harness-owned descriptors out of the launched client:
814+ // exec dup2s the slave onto the child's stdio, but an inherited master (or a
815+ // second slave) would otherwise leak in and hold the terminal open, so closing
816+ // the harness master could never hang up the client. cmd.Start still remaps the
817+ // slave onto fds 0-2, which are exempt from close-on-exec.
818+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
752819 if err != nil {
753820 return nil, nil, err
754821 }
@@ -760,7 +827,7 @@ func openPTY() (*os.File, *os.File, error) {
760827 if err := rawterm.UnlockPt(masterFD); err != nil {
761828 return closeMaster(err)
762829 }
763- slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0)
830+ slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
764831 if err != nil {
765832 return closeMaster(err)
766833 }
@@ -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) {
@@ -625,89 +626,150 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
625 return nil626 return nil
626 }627 }
627 628
629+// Close tears down a launched role along a bounded escalation ladder. Each rung
630+// performs one action, then waits a single closeDeadline for the process to be
631+// reaped before the next rung escalates. The graceful rungs — the public daemon
632+// stop, the client shell exit, the controlling-terminal hangup, and SIGTERM —
633+// let a role close its blocked transport operations and serialize their end
634+// marks; only a SIGKILL severs a process before it can, so only that rung fails
635+// teardown. Every path closes the PTY exactly once and finalizes output and
636+// runtime state.
628 func (p *cliProcess) Close() error {637 func (p *cliProcess) Close() error {
629- var result error
630 p.closed.Do(func() {638 p.closed.Do(func() {
631- // No measurement waits for terminal chunks during teardown. Continue639+ // 1. Drain terminal output for the whole teardown. Without an active
632- // consuming them so a full channel cannot block the client before it640+ // consumer a full chunks channel stalls copyTerminal, which then stops
633- // receives the shell exit request and closes its transport receive span.641+ // reading the PTY master and blocks the client mid-restore before it can
634- if p.chunks != nil && p.done != nil {642+ // close its observed receive operation. The drain stops with p.done.
635- go func() {643+ drainDone := p.drainTerminalOutput()
636- for {644+
637- select {
638- case _, ok := <-p.chunks:
639- if !ok {
640- return
641- }
642- case <-p.done:
643- return
644- }
645- }
646- }()
647- }
648- // A public terminal client owns a shell session. Ask that shell to exit
649- // before closing the PTY so its transport can finish its receive span;
650- // this avoids turning ordinary harness teardown into an aborted trace.
651 exited := false645 exited := false
652- if p.shutdown != nil && p.waitErr != nil {646+ // waitForExit blocks up to one bounded deadline for the reap.
647+ waitForExit := func() {
648+ if p.waitErr == nil || exited {
649+ return
650+ }
653 select {651 select {
654 case <-p.waitErr:652 case <-p.waitErr:
655 exited = true653 exited = true
656- default:654+ case <-p.closeDeadline():
657- // The client may have just ended the last session and raced this
658- // best-effort public shutdown. The normal bounded reaping path below
659- // remains authoritative in that case.
660- _ = p.shutdown()
661 }655 }
662 }656 }
663- if p.pty != nil && p.waitErr != nil {657+ // reaped reports whether the process is already gone; a role with no
664- _, _ = p.pty.Write([]byte("exit\n"))658+ // process to reap needs no escalation at all.
659+ reaped := func() bool {
660+ if p.waitErr == nil || exited {
661+ return true
662+ }
665 select {663 select {
666 case <-p.waitErr:664 case <-p.waitErr:
667 exited = true665 exited = true
668- case <-p.closeDeadline():666+ return true
667+ default:
668+ return false
669 }669 }
670 }670 }
671- // Keep draining terminal output while the client exits gracefully. If the671+ ptyClosed := false
672- // drain stops first, restoration output can fill the PTY and block the672+ closePTY := func() {
673- // client before it closes its observed transport receive operation.673+ if p.pty != nil && !ptyClosed {
674- if p.done != nil {674+ _ = p.pty.Close()
675- close(p.done)675+ ptyClosed = true
676+ }
676 }677 }
677- if p.pty != nil {678+
678- _ = p.pty.Close()679+ // 2. Daemon role: ask the public daemon to stop before any signal so its
680+ // blocked carriage can close and serialize failed end marks.
681+ if p.shutdown != nil && !reaped() {
682+ _ = p.shutdown()
683+ waitForExit()
679 }684 }
680- if p.waitErr != nil && !exited {685+ // 3. Client role: a public terminal client owns a shell session. Ask that
681- select {686+ // shell to exit so the client detaches and its transport finishes its
682- case <-p.waitErr:687+ // receive span cleanly.
683- case <-p.closeDeadline():688+ if p.pty != nil && !reaped() {
684- // Teardown is deliberately bounded: a stuck public CLI or child689+ _, _ = p.pty.Write([]byte("exit\n"))
685- // must not hold a local harness run indefinitely.690+ waitForExit()
686- p.forceProcessGroupCleanup()691+ }
687- select {692+ // 4. Hang up the controlling terminal. The attach path installs a signal
688- case <-p.waitErr:693+ // handler, so this SIGHUP is a graceful detach — not an abrupt kill — and
689- case <-p.closeDeadline():694+ // the client still flushes its in-flight receive end mark.
690- p.forceProcessGroupKill()695+ if p.pty != nil && !reaped() {
691- select {696+ closePTY()
692- case <-p.waitErr:697+ waitForExit()
693- case <-p.closeDeadline():698+ }
694- result = errors.New("timed out reaping public CLI process")699+ // 5. SIGTERM the role's own process group. The daemon and, through its
695- }700+ // attach signal handler, the client both treat this as a graceful stop.
696- }701+ if !reaped() {
702+ p.forceProcessGroupCleanup()
703+ waitForExit()
704+ }
705+ // 6. SIGKILL the role's process group. This severs the process; an
706+ // in-flight observed operation cannot be closed, so its trace may truncate.
707+ killed := false
708+ if !reaped() {
709+ killed = true
710+ p.forceProcessGroupKill()
711+ waitForExit()
712+ }
713+ // 7. A SIGKILL escalation cannot guarantee a complete trace. Fail teardown
714+ // with a clear cause rather than letting the truncation surface later as a
715+ // downstream merge mystery. The graceful rungs never error on their own.
716+ if killed {
717+ if exited {
718+ p.closeResult = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")
719+ } else {
720+ p.closeResult = errors.New("timed out reaping public CLI process")
697 }721 }
698 }722 }
723+
724+ // Close the PTY exactly once on every path: an early graceful exit skips
725+ // the hangup rung, so close it here before the drain stops.
726+ closePTY()
727+ if p.done != nil {
728+ close(p.done)
729+ }
730+ if drainDone != nil {
731+ <-drainDone
732+ }
699 if p.output != nil {733 if p.output != nil {
700- if err := p.output.Close(); result == nil {734+ if err := p.output.Close(); p.closeResult == nil {
701- result = err735+ p.closeResult = err
702 }736 }
703 }737 }
704 if p.cleanupRuntime != nil {738 if p.cleanupRuntime != nil {
705- if err := p.cleanupRuntime(); result == nil {739+ if err := p.cleanupRuntime(); p.closeResult == nil {
706- result = err740+ p.closeResult = err
707 }741 }
708 }742 }
709 })743 })
710- return result744+ return p.closeResult
745+}
746+
747+// drainTerminalOutput consumes copyTerminal's chunks for the duration of a
748+// teardown so a full channel can never wedge copyTerminal (and thus the client's
749+// PTY). It returns nil when there is no terminal to drain, and otherwise a
750+// channel closed once the consumer has stopped on p.done.
751+func (p *cliProcess) drainTerminalOutput() <-chan struct{} {
752+ if p.chunks == nil || p.done == nil {
753+ return nil
754+ }
755+ done := make(chan struct{})
756+ chunks := p.chunks
757+ go func() {
758+ defer close(done)
759+ for {
760+ select {
761+ case _, ok := <-chunks:
762+ if !ok {
763+ // copyTerminal closed the channel; a closed channel is always
764+ // ready, so stop selecting it and wait only for the stop signal.
765+ chunks = nil
766+ }
767+ case <-p.done:
768+ return
769+ }
770+ }
771+ }()
772+ return done
711 }773 }
712 774
713 func (p *cliProcess) closeDeadline() <-chan time.Time {775 func (p *cliProcess) closeDeadline() <-chan time.Time {
@@ -748,7 +810,12 @@ func (p *cliProcess) forceProcessGroupKill() {
748 // openPTY is the small Linux PTY boundary needed to drive the public terminal810 // openPTY is the small Linux PTY boundary needed to drive the public terminal
749 // client. No daemon or adapter implementation is imported by this command.811 // client. No daemon or adapter implementation is imported by this command.
750 func openPTY() (*os.File, *os.File, error) {812 func openPTY() (*os.File, *os.File, error) {
751- masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)813+ // O_CLOEXEC keeps these harness-owned descriptors out of the launched client:
814+ // exec dup2s the slave onto the child's stdio, but an inherited master (or a
815+ // second slave) would otherwise leak in and hold the terminal open, so closing
816+ // the harness master could never hang up the client. cmd.Start still remaps the
817+ // slave onto fds 0-2, which are exempt from close-on-exec.
818+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
752 if err != nil {819 if err != nil {
753 return nil, nil, err820 return nil, nil, err
754 }821 }
@@ -760,7 +827,7 @@ func openPTY() (*os.File, *os.File, error) {
760 if err := rawterm.UnlockPt(masterFD); err != nil {827 if err := rawterm.UnlockPt(masterFD); err != nil {
761 return closeMaster(err)828 return closeMaster(err)
762 }829 }
763- slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0)830+ slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
764 if err != nil {831 if err != nil {
765 return closeMaster(err)832 return closeMaster(err)
766 }833 }
M cmd/vev-perf-harness/launcher_test.go +76 -0
@@ -134,6 +134,82 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
134134 }
135135 }
136136
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+ },
177+ }
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+ })
210+ }
211+}
212+
137213 func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {
138214 path := filepath.Join(t.TempDir(), "daemon.sock")
139215 var lc net.ListenConfig
@@ -134,6 +134,82 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
134 }134 }
135 }135 }
136 136
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+ },
177+ }
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+ })
210+ }
211+}
212+
137 func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {213 func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {
138 path := filepath.Join(t.TempDir(), "daemon.sock")214 path := filepath.Join(t.TempDir(), "daemon.sock")
139 var lc net.ListenConfig215 var lc net.ListenConfig
M internal/app/run.go +9 -0
@@ -480,6 +480,15 @@ func runDaemon() (retErr error) {
480480 // attach loop. Logging goes to the shared file: the client must never write
481481 // to the console while the terminal is raw.
482482 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {
483+ // Treat a controlling-terminal hangup or termination request as a graceful
484+ // detach rather than an abrupt process death. Cancelling the context unwinds
485+ // the client's pumps, closes its transport, and lets the deferred trace closer
486+ // flush the in-flight receive's end mark — so teardown never truncates a span.
487+ // Raw mode disables ISIG, so catching SIGINT here does not affect interactive
488+ // Ctrl+C, which the daemon delivers to the remote shell as a normal keystroke.
489+ ctx, stop := signal.NotifyContext(ctx, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
490+ defer stop()
491+
483492 log, logCloser, err := configureLogging(logging.Client, false)
484493 if err != nil {
485494 return err
@@ -480,6 +480,15 @@ func runDaemon() (retErr error) {
480 // attach loop. Logging goes to the shared file: the client must never write480 // attach loop. Logging goes to the shared file: the client must never write
481 // to the console while the terminal is raw.481 // to the console while the terminal is raw.
482 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {482 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {
483+ // Treat a controlling-terminal hangup or termination request as a graceful
484+ // detach rather than an abrupt process death. Cancelling the context unwinds
485+ // the client's pumps, closes its transport, and lets the deferred trace closer
486+ // flush the in-flight receive's end mark — so teardown never truncates a span.
487+ // Raw mode disables ISIG, so catching SIGINT here does not affect interactive
488+ // Ctrl+C, which the daemon delivers to the remote shell as a normal keystroke.
489+ ctx, stop := signal.NotifyContext(ctx, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
490+ defer stop()
491+
483 log, logCloser, err := configureLogging(logging.Client, false)492 log, logCloser, err := configureLogging(logging.Client, false)
484 if err != nil {493 if err != nil {
485 return err494 return err

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