fix(debug): make ptrace sessions reliable on Go tracees

This commit is contained in:
2026-08-30 22:35:22 +02:00
parent 96cc70731f
commit 9cb1666b35
6 changed files with 232 additions and 39 deletions
+67 -27
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -32,7 +33,14 @@ func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
}
// LaunchWithBuffers is like Launch but also allocates buffers in the debuggee.
//
// It pins the calling goroutine to its OS thread and leaves it pinned: the
// debuggee's PTRACE_TRACEME binds the tracer relation to the forking thread,
// and every ptrace request on the session must come from that same thread.
// All Session methods must therefore be called from the goroutine that
// launched the session (the REPL and coverage loops do exactly that).
func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec string) (*Session, []uint64, error) {
runtime.LockOSThread() // ptrace requests must stay on the forking thread
self, err := os.Executable()
if err != nil {
return nil, nil, fmt.Errorf("debug: cannot find gasm binary: %w", err)
@@ -78,37 +86,26 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s
}
time.Sleep(5 * time.Millisecond)
}
var ws syscall.WaitStatus
if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil {
// The debuggee parks itself with SIGSTOP once the JIT code is mapped.
// A Go tracee also reports SIGURG preemption as signal-delivery-stops,
// so the wait loops until a stop the debugger cares about instead of
// assuming the first event is the SIGSTOP.
if _, err := s.waitStopped(); err != nil {
cmd.Process.Kill()
os.RemoveAll(tmpDir)
return nil, nil, fmt.Errorf("debug: wait for stop: %w", err)
return nil, nil, fmt.Errorf("debug: wait for debuggee: %w", err)
}
entryFile := filepath.Join(tmpDir, "entry")
for range 500 {
if _, err := os.Stat(entryFile); err == nil {
break
}
time.Sleep(5 * time.Millisecond)
}
if err := s.Continue(); err != nil {
return nil, nil, fmt.Errorf("debug: continue to entry: %w", err)
}
if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil {
return nil, nil, fmt.Errorf("debug: wait for entry: %w", err)
}
s.stopped = true
s.codeBase = findRWXMapping(s.pid)
// The debuggee reports its JIT mapping in the codebase file; that is the
// exact region the kernel was written to. Scanning /proc/pid/maps for
// any RWX region is only the fallback.
if data, err := os.ReadFile(filepath.Join(tmpDir, "codebase")); err == nil {
fmt.Sscanf(string(data), "%d", &s.codeBase)
}
if s.codeBase == 0 {
baseFile := filepath.Join(tmpDir, "codebase")
if data, err := os.ReadFile(baseFile); err == nil {
fmt.Sscanf(string(data), "%d", &s.codeBase)
}
s.codeBase = findRWXMapping(s.pid)
}
var bufAddrs []uint64
@@ -142,6 +139,47 @@ func (s *Session) wait() error {
return nil
}
// waitStopped consumes ptrace-stop events until one the debugger cares
// about arrives: SIGTRAP (a breakpoint or a completed single-step) or the
// debuggee's own SIGSTOP. A Go tracee's runtime raises SIGURG for
// asynchronous preemption, and every signal on a traced thread surfaces as
// a signal-delivery-stop, so those are suppressed and the tracee resumed
// without them. Runtime noise is why a single wait can return in the
// middle of runtime code and a resume can then fail: the event stream must
// be drained by the tracer.
func (s *Session) waitStopped() (syscall.Signal, error) {
for {
var ws syscall.WaitStatus
if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil {
return 0, err
}
if ws.Exited() {
s.exited = true
return 0, fmt.Errorf("debuggee exited with status %d", ws.ExitStatus())
}
if ws.Signaled() {
s.exited = true
return 0, fmt.Errorf("debuggee killed by signal %v", ws.Signal())
}
switch sig := ws.StopSignal(); sig {
case syscall.SIGTRAP, syscall.SIGSTOP:
s.stopped = true
return sig, nil
default:
// Runtime noise (SIGURG preemption and friends): resume the
// tracee without delivering the signal.
if _, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_CONT),
uintptr(s.pid),
0, 0, 0, 0,
); errno != 0 {
return 0, fmt.Errorf("debug: PTRACE_CONT: %w", errno)
}
}
}
}
// Peek reads a word (8 bytes) from the debuggee's memory at addr.
func (s *Session) Peek(addr uint64) (uint64, error) {
mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_RDONLY, 0)
@@ -224,7 +262,8 @@ func (s *Session) Step() error {
if errno != 0 {
return fmt.Errorf("debug: PTRACE_SINGLESTEP: %w", errno)
}
return s.wait()
_, err := s.waitStopped()
return err
}
// Continue resumes execution until the next breakpoint or exit.
@@ -241,7 +280,8 @@ func (s *Session) Continue() error {
if errno != 0 {
return fmt.Errorf("debug: PTRACE_CONT: %w", errno)
}
return s.wait()
_, err := s.waitStopped()
return err
}
// Exited returns true if the debuggee has terminated.