// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause //go:build linux && amd64 package debug import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "sourcedock.dev/petrbalvin/gasm-sdk/verify" ) // buildGasm produces the gasm binary the debugger spawns as its debuggee. func buildGasm(t *testing.T) string { t.Helper() if p := os.Getenv("GASM_TEST_BIN"); p != "" { return p } bin := filepath.Join(t.TempDir(), "gasm") cmd := exec.Command("go", "build", "-o", bin, "sourcedock.dev/petrbalvin/gasm-sdk/cmd/gasm") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("build gasm: %v: %s", err, out) } return bin } // TestLaunchAndBreakpoint drives a real ptrace session end to end: launch the // debuggee, break on the first instruction of the function and expect a // breakpoint trap instead of a clean exit. func TestLaunchAndBreakpoint(t *testing.T) { if runtime.GOARCH != "amd64" { t.Skip("runs only on amd64 hosts") } // The tracer is the OS thread that forked the debuggee (PTRACE_TRACEME // binds the relation to that thread); every ptrace request must come // from the same thread, so pin the test goroutine to one thread. runtime.LockOSThread() defer runtime.UnlockOSThread() bin := buildGasm(t) const kernelPath = "../testdata/verify/basic_amd64.s" k, err := verify.Load(kernelPath) if err != nil { t.Fatalf("Load: %v", err) } t.Cleanup(k.Close) fl, err := k.Func("wideCopy") if err != nil { t.Fatalf("Func: %v", err) } sess, err := Launch(bin, kernelPath, "wideCopy", make([]byte, fl.Args)) if err != nil { t.Fatalf("Launch: %v", err) } t.Cleanup(sess.Kill) bm := NewBreakpoints(sess) entry := sess.CodeBase() + uint64(fl.Offset) if _, err := bm.Set(entry, "entry"); err != nil { t.Fatalf("Set: %v", err) } // The INT3 must be visible in the debuggee's memory. word, err := sess.Peek(entry) if err != nil { t.Fatalf("Peek: %v", err) } if b := word & 0xFF; b != 0xCC { t.Fatalf("int3 not patched: first byte %#02x at %#x", b, entry) } // The debuggee raises a second SIGSTOP after the launch barrier (the // child's RunTarget marks its entry), so like the REPL and the cover // mode the test keeps resuming until the breakpoint trap arrives. for range 10 { if err := sess.Continue(); err != nil { st, _ := os.ReadFile(fmt.Sprintf("/proc/%d/stat", sess.Pid())) status, _ := os.ReadFile(fmt.Sprintf("/proc/%d/status", sess.Pid())) t.Fatalf("Continue: %v\nstate: %s\n%s", err, fieldName(st), statusDump(status)) } if sess.Exited() { t.Fatal("debuggee exited instead of trapping on the breakpoint") } regs, err := sess.GetRegs() if err != nil { t.Fatalf("GetRegs: %v", err) } if bp := bm.HandleTrap(®s); bp != nil { if bp.Addr != entry { t.Fatalf("trap at %#x, want %#x", bp.Addr, entry) } return // trap on the entry breakpoint: the whole flow works } } t.Fatal("no breakpoint trap after 10 resumes") } func fieldName(stat []byte) string { f := strings.Split(string(stat), " ") if len(f) > 2 { return "state=" + f[2] } return "no stat" } func statusDump(b []byte) string { var out []string for l := range strings.SplitSeq(string(b), "\n") { if strings.HasPrefix(l, "State") || strings.HasPrefix(l, "Pid") || strings.HasPrefix(l, "PPid") || strings.HasPrefix(l, "TracerPid") || strings.HasPrefix(l, "Threads") || strings.HasPrefix(l, "SigPnd") || strings.HasPrefix(l, "SigBlk") || strings.HasPrefix(l, "SigIgn") { out = append(out, l) } } return strings.Join(out, "\n") }