test(debug): put the dead-debuggee and kill audits on deterministic ground

The launch-failure audit raced the clock: it asserted the dead debuggee
surfaced within 1.5 seconds, a bound the loaded machine behind a ten-way
test storm regularly starved past even though the poll detects the dead
notice within milliseconds of its appearance.  The audit now proves the
property itself: a stub debuggee that starts in single-digit milliseconds
marks itself dead, so the notice is always inside the poll's budget and
the error must come from the dead-file watch, while the real binary is
checked without any wall-clock bound.  A companion audit drives Kill
through a parked, a doubly killed and a run-to-exit session, the states
whose cleanup used to hang the package, under a watchdog.

Assisted-by: GLM 5.3
This commit is contained in:
petrbalvin committed 2026-10-07 13:46:29 +02:00
1 parent b5d6f1b46a
commit 4ccd3bb4c6
1 file changed
+113 -9
+113 -9
View File
@@ -8,6 +8,8 @@ package debug
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
@@ -181,7 +183,7 @@ TEXT ·wptwo(SB), NOSPLIT, $0-32
if err := sess.ClearWatchpoint(0); err != nil { if err := sess.ClearWatchpoint(0); err != nil {
t.Fatalf("ClearWatchpoint(0): %v", err) t.Fatalf("ClearWatchpoint(0): %v", err)
} }
dr0, err := ptracePeekUser(sess.Pid(), drOffset) dr0, err := ptracePeekUser(sess.tid, drOffset)
if err != nil { if err != nil {
t.Fatalf("read DR0: %v", err) t.Fatalf("read DR0: %v", err)
} }
@@ -191,16 +193,35 @@ TEXT ·wptwo(SB), NOSPLIT, $0-32
} }
// TestLaunchFailsFastOnDeadDebuggee proves a debuggee that dies before // TestLaunchFailsFastOnDeadDebuggee proves a debuggee that dies before
// signalling readiness surfaces promptly: the ready poll used to run its // signalling readiness surfaces through the readiness poll's dead-file
// full 2.5 seconds before the wait discovered the exit. // 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) { func TestLaunchFailsFastOnDeadDebuggee(t *testing.T) {
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()
bin := buildGasm(t)
path := boundaryKernel(t) path := boundaryKernel(t)
start := time.Now()
sess, err := Launch(bin, path, "nosuchfunction", nil) sess, err := Launch(buildStubDebuggee(t), path, "nosuchfunction", nil)
elapsed := time.Since(start) 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 { if err == nil {
sess.Kill() sess.Kill()
t.Fatal("Launch with an unknown function should fail") t.Fatal("Launch with an unknown function should fail")
@@ -209,9 +230,92 @@ func TestLaunchFailsFastOnDeadDebuggee(t *testing.T) {
!strings.Contains(err.Error(), "debuggee exited") { !strings.Contains(err.Error(), "debuggee exited") {
t.Errorf("error does not name the dead debuggee: %v", err) 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 // TestStrayTrapRunsThrough proves the continue loop survives a trap