Skip to content

Commit a81c1a3

Browse files
committed
fix: drop a stale buffered exit status so a reused pid waits for its own child
1 parent 7a91084 commit a81c1a3

2 files changed

Lines changed: 68 additions & 3 deletions

File tree

internal/reaper/pidreuse_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package reaper
2+
3+
import (
4+
"syscall"
5+
"testing"
6+
"time"
7+
)
8+
9+
func exit(code int) syscall.WaitStatus { return syscall.WaitStatus(code << 8) }
10+
11+
// TestWaitDropsStalePIDReuse ensures a buffered status left by an earlier child
12+
// is not handed to a new child that reused the same pid: Wait must ignore the
13+
// stale entry, install a fresh waiter, and route the new child's real exit
14+
// (R-SINIT5).
15+
func TestWaitDropsStalePIDReuse(t *testing.T) {
16+
r := NewRegistry()
17+
// An orphan exited earlier with pid 100 and its status was buffered (no waiter).
18+
r.Deliver(100, exit(7))
19+
// Age it so it predates the reused pid's registration.
20+
r.mu.Lock()
21+
r.pending[100] = pending{ws: exit(7), at: time.Now().Add(-5 * time.Second)}
22+
r.mu.Unlock()
23+
24+
ch := r.Wait(100) // a new child reused pid 100 and registers interest
25+
select {
26+
case ws := <-ch:
27+
t.Fatalf("Wait returned stale status (exit %d) for a reused pid; must wait for the new child", ws.ExitStatus())
28+
default:
29+
}
30+
31+
r.Deliver(100, exit(0)) // the new child's real exit
32+
select {
33+
case ws := <-ch:
34+
if ws.ExitStatus() != 0 {
35+
t.Errorf("routed exit %d, want 0 (the new child)", ws.ExitStatus())
36+
}
37+
default:
38+
t.Fatal("the new child's status did not route to the waiter")
39+
}
40+
}
41+
42+
// TestWaitReturnsFreshBufferedStatus keeps the legitimate register-vs-exit race
43+
// working: a status buffered just before Wait is returned immediately.
44+
func TestWaitReturnsFreshBufferedStatus(t *testing.T) {
45+
r := NewRegistry()
46+
r.Deliver(200, exit(3))
47+
select {
48+
case ws := <-r.Wait(200):
49+
if ws.ExitStatus() != 3 {
50+
t.Errorf("got exit %d, want 3", ws.ExitStatus())
51+
}
52+
default:
53+
t.Fatal("a freshly buffered status should be returned immediately")
54+
}
55+
}

internal/reaper/reaper.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,27 @@ func NewRegistry() *Registry {
3434
}
3535
}
3636

37+
// staleWait bounds how old a buffered status may be to satisfy a Wait. A caller
38+
// registers interest immediately after spawning, so a status legitimately
39+
// buffered in the register-vs-exit race is only microseconds old; anything older
40+
// belongs to an earlier child whose pid has since been reused (R-SINIT5), and
41+
// must not be handed to the new child's waiter.
42+
const staleWait = time.Second
43+
3744
// Wait registers interest in pid and returns a one-shot channel for its exit
3845
// status. If the child already exited (its status was delivered before this
39-
// call -- the tiny register-vs-exit window), the buffered status is returned
40-
// immediately.
46+
// call -- the tiny register-vs-exit window), the freshly buffered status is
47+
// returned immediately. A buffered status older than staleWait is a leftover
48+
// from a prior child with the same (reused) pid: it is dropped and a fresh
49+
// waiter installed so the new child's real exit routes here.
4150
func (r *Registry) Wait(pid int) <-chan syscall.WaitStatus {
4251
ch := make(chan syscall.WaitStatus, 1)
4352
r.mu.Lock()
44-
if p, ok := r.pending[pid]; ok {
53+
if p, ok := r.pending[pid]; ok && time.Since(p.at) < staleWait {
4554
ch <- p.ws
4655
delete(r.pending, pid)
4756
} else {
57+
delete(r.pending, pid) // drop any stale leftover for a reused pid
4858
r.waiters[pid] = ch
4959
}
5060
r.mu.Unlock()

0 commit comments

Comments
 (0)