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) } }