From d45f5d4202f973bddf97acbc0b28f379273fcaaf Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 12:53:46 -0300 Subject: [PATCH] test(project): start the waitdelay deadline once the grandchild holds the pipe TestSubprocessBudgetOutlivesAGrandchild gave the call a fixed 200ms. On Windows that can run out before the test binary has started twice (child, then grandchild): the context killed the child before it spawned the grandchild, Output returned at once, and the cleanup waited ten seconds for a grandchild that never existed: "the grandchild did not exit once released" (os-tests on #834, run 39273705110). The grandchild now writes a holding file once it holds the pipe, and the test cancels the context at that moment and measures Output from there. The test changes because it asserted something the contract never promised: that two process spawns fit in 200ms. Measured on Linux with the child sleeping 1s before spawning the grandchild: 3/3 failures before with the same message, 3/3 passes after. Without cmd.WaitDelay the test still fails (Output blocked 9.99s past the deadline), and 50/50 runs pass with it. --- internal/project/waitdelay_test.go | 36 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/project/waitdelay_test.go b/internal/project/waitdelay_test.go index a3f25f43..d2d4bba3 100644 --- a/internal/project/waitdelay_test.go +++ b/internal/project/waitdelay_test.go @@ -12,13 +12,15 @@ import ( // helperEnv selects which half of the process tree a helper run plays. const helperEnv = "LICH_WAITDELAY_HELPER" -// releaseEnv names the directory the grandchild watches: the test writes +// releaseEnv names the directory the grandchild and the test signal each other +// in: the grandchild writes holdingFile once it holds the pipe, the test writes // releaseFile there once it is done with the grandchild, which then exits and // writes exitedFile. Left to sleep out its hold, the grandchild outlived a // package that finished sooner, still running the test binary go test was // deleting: "unlinkat ... project.test.exe: Access is denied" on Windows. const ( releaseEnv = "LICH_WAITDELAY_RELEASE" + holdingFile = "holding" releaseFile = "release" exitedFile = "exited" ) @@ -50,6 +52,9 @@ func TestSubprocessHelper(t *testing.T) { os.Exit(0) case "grandchild": dir := os.Getenv(releaseEnv) + if err := os.WriteFile(filepath.Join(dir, holdingFile), nil, 0o644); err != nil { + os.Exit(1) + } awaitFile(filepath.Join(dir, releaseFile), grandchildHold) if err := os.WriteFile(filepath.Join(dir, exitedFile), nil, 0o644); err != nil { os.Exit(1) @@ -72,6 +77,11 @@ func awaitFile(path string, limit time.Duration) bool { // here exits immediately and the grandchild behind it keeps stdout open until // released: with nothing closing that pipe, Output waits for it and reports // success, deadline or none. +// +// The deadline is the moment the grandchild holds the pipe, not a timer: a +// fixed 200ms ran out on Windows before the test binary had started twice, the +// context killed the child before it spawned the grandchild, and the cleanup +// waited ten seconds for a grandchild that never existed. func TestSubprocessBudgetOutlivesAGrandchild(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -83,16 +93,30 @@ func TestSubprocessBudgetOutlivesAGrandchild(t *testing.T) { t.Fatal("the grandchild did not exit once released") } }) - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() cmd := commandContext(ctx, os.Args[0], "-test.run=TestSubprocessHelper") cmd.Env = append(cmd.Env, helperEnv+"=child", releaseEnv+"="+dir) - start := time.Now() + type deadline struct { + held bool + at time.Time + } + reached := make(chan deadline, 1) + go func() { + held := awaitFile(filepath.Join(dir, holdingFile), grandchildHold) + cancel() + reached <- deadline{held: held, at: time.Now()} + }() + _, err := cmd.Output() - elapsed := time.Since(start) + returned := time.Now() + d := <-reached - if elapsed > 5*time.Second { - t.Fatalf("Output blocked %s on a grandchild holding the pipe, err %v: the 200ms budget bounded the child alone", elapsed, err) + if !d.held { + t.Fatalf("the grandchild never held the pipe within %s, Output err %v", grandchildHold, err) + } + if elapsed := returned.Sub(d.at); elapsed > 5*time.Second { + t.Fatalf("Output blocked %s past the deadline on a grandchild holding the pipe, err %v: the budget bounded the child alone", elapsed, err) } }