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:
1 parent
b5d6f1b46a
commit
4ccd3bb4c6
1 file changed
+113
-9
+113
-9
@@ -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
|
||||
|
||||
Reference in new issue
Block a user