Skip to content

Commit 463cd31

Browse files
gustavobertoiclaude
andcommitted
test(G5): cross-process port-allocation race over the flock (spec 08)
Adds the two-terminal concurrency guarantee at the layer that matters, across REAL OS processes (subprocess-helper pattern), with no daemon and no flakiness: the ledger is pre-created, then 5 copies of the test binary each allocate 6 host ports under the machine-global flock. Port allocation is the mutation that cannot be made idempotent — two processes handing out the same host port is a hard bug — so "every allocated port is distinct across every process" is a direct proof the cross-process flock serialized the critical section. A no-op flock would let two processes pick the same lowest-free port and collide. Note: writing this surfaced that concurrent FIRST-open of a brand-new ledger from several processes relies on a reliable-flock filesystem (the WAL switch + migrate race otherwise); that hazard is already mitigated by `doctor`'s 9p/networked-FS warning (internal/cli fsCheck). The test deliberately pre-migrates so it validates the allocation flock specifically, independent of that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 35b3793 commit 463cd31

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

‎internal/state/crossproc_test.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package state
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
"testing"
13+
14+
"github.com/open-source-cloud/devstack/internal/lock"
15+
)
16+
17+
// G5 — the two-terminal concurrency guarantee, exercised across REAL OS processes
18+
// (not just goroutines). The ledger is pre-created once; then N copies of the test
19+
// binary each allocate K host ports under the machine-global flock. Port
20+
// allocation is the mutation that CANNOT be made idempotent — two processes
21+
// handing out the same host port is a hard bug — so distinct ports across every
22+
// process is a direct proof the cross-process flock serialized the critical
23+
// section. If the flock were a no-op, two processes would read the same "lowest
24+
// free" port and collide (spec 08, ARCHITECTURE §"#1 rule: lock-first").
25+
26+
const (
27+
helperEnv = "DEVSTACK_STATE_ALLOC_HELPER"
28+
portsPerProc = 6
29+
helperProcs = 5
30+
allocBase = 41000
31+
allocCeiling = 42000
32+
)
33+
34+
// TestHelperAllocPorts is the subprocess body (gated by the env var); a no-op
35+
// during a normal `go test` run.
36+
func TestHelperAllocPorts(t *testing.T) {
37+
if os.Getenv(helperEnv) != "1" {
38+
return
39+
}
40+
dir := os.Getenv("HELPER_DATA")
41+
lockPath := os.Getenv("HELPER_LOCK")
42+
id := os.Getenv("HELPER_ID")
43+
44+
db, err := Open(context.Background(), dir, "ctx")
45+
if err != nil {
46+
fmt.Fprintln(os.Stderr, "open:", err)
47+
os.Exit(3)
48+
}
49+
defer db.Close()
50+
51+
ports := make([]string, 0, portsPerProc)
52+
for i := range portsPerProc {
53+
var p int
54+
err := lock.WithLock(context.Background(), lockPath, func() error {
55+
var e error
56+
// Unique (owner,purpose) per allocation so PortFor never short-circuits;
57+
// the flock is what must keep the chosen ports distinct across processes.
58+
p, e = db.AllocatePort("proc"+id, "p"+strconv.Itoa(i), allocBase, allocCeiling, func(int) bool { return true })
59+
return e
60+
})
61+
if err != nil {
62+
fmt.Fprintln(os.Stderr, "alloc:", err)
63+
os.Exit(4)
64+
}
65+
ports = append(ports, strconv.Itoa(p))
66+
}
67+
// Only the port line goes to stdout; os.Exit skips the test framework's trailer.
68+
fmt.Fprintln(os.Stdout, strings.Join(ports, ","))
69+
os.Exit(0)
70+
}
71+
72+
func TestCrossProcessPortAllocationNeverCollides(t *testing.T) {
73+
dir := t.TempDir()
74+
lockPath := filepath.Join(t.TempDir(), "devstack.lock")
75+
76+
// Pre-create + migrate the ledger once so the concurrent children only race the
77+
// ALLOCATION critical section (not schema creation). This isolates the flock
78+
// guarantee we care about here.
79+
db, err := Open(context.Background(), dir, "ctx")
80+
if err != nil {
81+
t.Fatalf("seed ledger: %v", err)
82+
}
83+
db.Close()
84+
85+
var (
86+
mu sync.Mutex
87+
outputs []string
88+
failed int
89+
wg sync.WaitGroup
90+
)
91+
for i := range helperProcs {
92+
wg.Add(1)
93+
go func(i int) {
94+
defer wg.Done()
95+
cmd := exec.Command(os.Args[0], "-test.run=^TestHelperAllocPorts$")
96+
cmd.Env = append(os.Environ(),
97+
helperEnv+"=1",
98+
"HELPER_DATA="+dir,
99+
"HELPER_LOCK="+lockPath,
100+
"HELPER_ID="+strconv.Itoa(i),
101+
)
102+
out, err := cmd.Output()
103+
mu.Lock()
104+
defer mu.Unlock()
105+
if err != nil {
106+
failed++
107+
t.Logf("helper %d failed: %v", i, err)
108+
return
109+
}
110+
outputs = append(outputs, strings.TrimSpace(string(out)))
111+
}(i)
112+
}
113+
wg.Wait()
114+
115+
if failed > 0 {
116+
t.Fatalf("%d/%d helper processes failed", failed, helperProcs)
117+
}
118+
119+
seen := map[string]bool{}
120+
total := 0
121+
for _, line := range outputs {
122+
for p := range strings.SplitSeq(lastNonEmptyLine(line), ",") {
123+
if p == "" {
124+
continue
125+
}
126+
if seen[p] {
127+
t.Errorf("port %s allocated by two processes — the cross-process flock did not serialize allocation", p)
128+
}
129+
seen[p] = true
130+
total++
131+
}
132+
}
133+
if want := helperProcs * portsPerProc; total != want {
134+
t.Errorf("allocated %d distinct ports, want %d", total, want)
135+
}
136+
}
137+
138+
// lastNonEmptyLine guards against any stray output before the ports line.
139+
func lastNonEmptyLine(s string) string {
140+
lines := strings.Split(strings.TrimSpace(s), "\n")
141+
for i := len(lines) - 1; i >= 0; i-- {
142+
if strings.TrimSpace(lines[i]) != "" {
143+
return strings.TrimSpace(lines[i])
144+
}
145+
}
146+
return ""
147+
}

0 commit comments

Comments
 (0)