mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
The windows-execenv job's TestPrepareIsolated_WindowsKillsDescendantBeforeRetry
flakes on the Windows runner: its helper subprocess ends in a bare select{},
which the Go runtime can reap with 'all goroutines are asleep - deadlock!'
(exit status 2) once every goroutine is parked with no wakeup source. That
races the parent's Job Object kill, so PrepareIsolated returns 'helper failed'
instead of the context.Canceled the test asserts.
Block on a timer-backed sleep loop instead: a pending timer is a wakeable
source, so the runtime never declares a deadlock, and the process still dies
the instant the Job Object tears the tree down.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
143 lines
4.2 KiB
Go
143 lines
4.2 KiB
Go
//go:build windows
|
|
|
|
package execenv
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
windowsPreparationTreeHelperMode = "windows-preparation-tree-helper"
|
|
windowsPreparationDelayedMode = "windows-preparation-delayed-writer"
|
|
)
|
|
|
|
func testArgsAfterDoubleDash() []string {
|
|
for i, arg := range os.Args {
|
|
if arg == "--" && i+1 < len(os.Args) {
|
|
return os.Args[i+1:]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestWindowsPreparationJobHelperProcess starts a deliberately orphaned child
|
|
// and then blocks forever. The parent test cancels this helper and verifies the
|
|
// Job Object kills the delayed child before it can write.
|
|
func TestWindowsPreparationJobHelperProcess(t *testing.T) {
|
|
args := testArgsAfterDoubleDash()
|
|
if len(args) != 3 || args[0] != windowsPreparationTreeHelperMode {
|
|
return
|
|
}
|
|
var request preparationRequest
|
|
if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
marker, ready := args[1], args[2]
|
|
child := exec.Command(
|
|
os.Args[0],
|
|
"-test.run=^TestWindowsPreparationDelayedWriter$",
|
|
"--",
|
|
windowsPreparationDelayedMode,
|
|
marker,
|
|
)
|
|
if err := child.Start(); err != nil {
|
|
os.Exit(3)
|
|
}
|
|
if err := os.WriteFile(ready, []byte("ready"), 0o600); err != nil {
|
|
os.Exit(4)
|
|
}
|
|
// Block until the parent tears this whole process tree down via the Job
|
|
// Object. A bare select{} is unsafe here: once the helper reaches it, the Go
|
|
// runtime can find every goroutine parked with no possible wakeup and reap
|
|
// the process itself with "all goroutines are asleep - deadlock!" (exit
|
|
// status 2). That races the parent's kill and surfaces as a spurious
|
|
// "helper failed" instead of the context.Canceled the parent test asserts,
|
|
// which is the flake this job hit on main. Sleeping keeps a pending timer —
|
|
// a wakeable source — so the runtime never declares a deadlock; the process
|
|
// still dies the instant the Job Object kills the tree.
|
|
for {
|
|
time.Sleep(time.Hour)
|
|
}
|
|
}
|
|
|
|
func TestWindowsPreparationDelayedWriter(t *testing.T) {
|
|
args := testArgsAfterDoubleDash()
|
|
if len(args) != 2 || args[0] != windowsPreparationDelayedMode {
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
if err := os.WriteFile(args[1], []byte("leaked"), 0o600); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
func TestPrepareIsolated_WindowsKillsDescendantBeforeRetry(t *testing.T) {
|
|
dir := t.TempDir()
|
|
marker := filepath.Join(dir, "old-attempt-marker")
|
|
ready := filepath.Join(dir, "descendant-ready")
|
|
command := []string{
|
|
os.Args[0],
|
|
"-test.run=^TestWindowsPreparationJobHelperProcess$",
|
|
"--",
|
|
windowsPreparationTreeHelperMode,
|
|
marker,
|
|
ready,
|
|
}
|
|
params := PrepareParams{
|
|
WorkspacesRoot: filepath.Join(dir, "workspaces"),
|
|
WorkspaceID: "ws-windows-job",
|
|
TaskID: "12345678-1111-2222-3333-444444444444",
|
|
Provider: "claude",
|
|
Task: TaskContextForEnv{IssueID: "issue-windows-job"},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
_, err := PrepareIsolated(ctx, command, params, nil)
|
|
result <- err
|
|
}()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(ready); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
cancel()
|
|
<-result
|
|
t.Fatal("helper did not start its delayed descendant")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
if err := <-result; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("PrepareIsolated error = %v, want context canceled", err)
|
|
}
|
|
|
|
// The child would write after two seconds if only the direct helper were
|
|
// killed. Waiting beyond that point makes the absence of the marker a
|
|
// runtime proof that the Job Object terminated the whole tree.
|
|
time.Sleep(2500 * time.Millisecond)
|
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("timed-out helper descendant survived and wrote marker: %v", err)
|
|
}
|
|
|
|
retryCtx, retryCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer retryCancel()
|
|
env, err := PrepareIsolated(retryCtx, preparationHelperTestCommand(), params, nil)
|
|
if err != nil {
|
|
t.Fatalf("immediate retry PrepareIsolated: %v", err)
|
|
}
|
|
if env == nil || env.RootDir != PredictRootDir(params.WorkspacesRoot, params.WorkspaceID, params.TaskID) {
|
|
t.Fatalf("retry environment = %#v, want the predicted root", env)
|
|
}
|
|
}
|