diff --git a/debug/debug_audit_test.go b/debug/debug_audit_test.go index 436d6f1..4c13249 100644 --- a/debug/debug_audit_test.go +++ b/debug/debug_audit_test.go @@ -8,6 +8,8 @@ package debug import ( "fmt" "os" + "os/exec" + "path/filepath" "runtime" "strings" "testing" @@ -181,7 +183,7 @@ TEXT ·wptwo(SB), NOSPLIT, $0-32 if err := sess.ClearWatchpoint(0); err != nil { t.Fatalf("ClearWatchpoint(0): %v", err) } - dr0, err := ptracePeekUser(sess.Pid(), drOffset) + dr0, err := ptracePeekUser(sess.tid, drOffset) if err != nil { t.Fatalf("read DR0: %v", err) } @@ -191,16 +193,35 @@ TEXT ·wptwo(SB), NOSPLIT, $0-32 } // TestLaunchFailsFastOnDeadDebuggee proves a debuggee that dies before -// signalling readiness surfaces promptly: the ready poll used to run its -// full 2.5 seconds before the wait discovered the exit. +// signalling readiness surfaces through the readiness poll's dead-file +// watch: the poll used to run its full 2.5 seconds before the wait +// discovered the exit. +// +// The property is checked without a wall-clock bound. A stub debuggee +// marks itself dead the instant it starts (a bare Go binary, none of gasm's +// table initialisation), so the notice is on disk and stays there well +// inside the poll's budget on any machine, and the only way Launch can +// report is through the dead file itself. func TestLaunchFailsFastOnDeadDebuggee(t *testing.T) { runtime.LockOSThread() defer runtime.UnlockOSThread() - bin := buildGasm(t) path := boundaryKernel(t) - start := time.Now() - sess, err := Launch(bin, path, "nosuchfunction", nil) - elapsed := time.Since(start) + + sess, err := Launch(buildStubDebuggee(t), path, "nosuchfunction", nil) + if err == nil { + sess.Kill() + t.Fatal("Launch with a dead debuggee should fail") + } + if !strings.Contains(err.Error(), "before signalling readiness") { + t.Fatalf("error does not come from the dead-file watch: %v", err) + } + if !strings.Contains(err.Error(), "stub debuggee died before readiness") { + t.Errorf("error does not carry the debuggee's own reason: %v", err) + } + + // The real debuggee's failure path: the same watch must catch a gasm + // that fails on an unknown function before it ever TRACEMEs. + sess, err = Launch(buildGasm(t), path, "nosuchfunction", nil) if err == nil { sess.Kill() t.Fatal("Launch with an unknown function should fail") @@ -209,9 +230,92 @@ func TestLaunchFailsFastOnDeadDebuggee(t *testing.T) { !strings.Contains(err.Error(), "debuggee exited") { t.Errorf("error does not name the dead debuggee: %v", err) } - if elapsed >= 1500*time.Millisecond { - t.Fatalf("Launch took %v to report the dead debuggee; the readiness poll must detect the exit, not time out", elapsed) +} + +// buildStubDebuggee compiles a stand-in debuggee that marks itself dead the +// moment it starts. The real gasm pays for its generated instruction tables +// before it can fail, which under a loaded machine turned any fixed latency +// expectation into a race; the stub starts in single-digit milliseconds, so +// the dead notice always lands inside the readiness poll's budget. +func buildStubDebuggee(t *testing.T) string { + t.Helper() + if testing.Short() { + t.Skip("live ptrace session: skipped in -short mode") } + const src = `package main + +import ( + "os" + "path/filepath" +) + +func main() { + dir := os.Getenv("GASM_DEBUG_TMP") + os.WriteFile(filepath.Join(dir, "dead"), + []byte("stub debuggee died before readiness"), 0o644) + os.Exit(1) +} +` + dir := t.TempDir() + srcPath := filepath.Join(dir, "stub_debuggee.go") + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write stub source: %v", err) + } + bin := filepath.Join(dir, "stub_debuggee") + out, err := exec.Command("go", "build", "-o", bin, srcPath).CombinedOutput() + if err != nil { + t.Fatalf("build stub debuggee: %v: %s", err, out) + } + return bin +} + +// TestKillReturnsForEveryTraceeState proves the kill sequence returns for a +// debuggee in any state: parked in a ptrace-stop from the launch barrier, +// run to its exit, and killed twice the way the REPL's quit path and the +// session cleanup both do. The old sequence sent SIGKILL and then blocked +// in Wait4 on a traced child the kill alone never woke, so a test failure +// on the way out of a session hung the whole package. A regression here +// hangs, so a watchdog fails the run. +func TestKillReturnsForEveryTraceeState(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + bin := buildGasm(t) + path := boundaryKernel(t) + + timer := time.AfterFunc(time.Minute, func() { + panic("watchdog: Kill blocked instead of returning for a tracee state") + }) + defer timer.Stop() + + // Parked at the readiness barrier: the tracee is stopped under ptrace. + sess, _, _ := launchKernel(t, bin, path, "boundary", nil) + tmpDir := sess.tmpDir + sess.Kill() + if !sess.Exited() { + t.Fatal("Kill must mark the parked debuggee exited") + } + if _, err := os.Stat(tmpDir); !os.IsNotExist(err) { + t.Errorf("Kill left the scratch directory %s behind", tmpDir) + } + + // The double kill: the REPL's quit path and the test cleanup both call + // Kill on the same session, and the second call returns too. + sess.Kill() + + // Run to completion: the debuggee has exited, waitStopped reaped it and + // Kill still returns without blocking on the collected child. + sess2, bm, fl := launchKernel(t, bin, path, "boundary", nil) + entry := sess2.CodeBase() + uint64(fl.Offset) + if _, err := bm.Set(entry, "entry"); err != nil { + t.Fatalf("Set: %v", err) + } + runToEntry(t, sess2, bm, entry) + for !sess2.Exited() { + if err := sess2.Continue(); err != nil && !sess2.Exited() { + t.Fatalf("Continue: %v", err) + } + } + sess2.Kill() } // TestStrayTrapRunsThrough proves the continue loop survives a trap