289 lines
9.9 KiB
Go
289 lines
9.9 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build freebsd && (amd64 || arm64 || riscv64)
|
|
|
|
package debug
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// Session is a ptrace debugging session controlling one debuggee process.
|
|
// The FreeBSD implementation sits behind the same surface as the Linux one:
|
|
// PT_TRACE_ME from the debuggee, PT_CONTINUE/PT_STEP from the tracer, and
|
|
// tracee memory through PT_IO (FreeBSD has no /proc/pid/mem to fall back
|
|
// on, so PT_IO is the only supported route).
|
|
type Session struct {
|
|
pid int
|
|
cmd *exec.Cmd
|
|
stopped bool
|
|
exited bool
|
|
codeBase uint64 // base address of the JIT code in the debuggee
|
|
tmpDir string // scratch directory of the session, removed on Kill
|
|
wpSlots [16]bool // hardware watchpoint slots in use (DR0-DR3, arm64 dbw 0-15)
|
|
// lastSignal holds the signal of the most recent stop when that stop
|
|
// was a genuine signal-delivery-stop the caller must see (a fault such
|
|
// as SIGSEGV, SIGBUS, SIGFPE or SIGILL); 0 for breakpoint traps,
|
|
// single-steps, SIGSTOP and suppressed runtime signals.
|
|
lastSignal syscall.Signal
|
|
}
|
|
|
|
// Launch starts the debuggee subprocess (gasm debug --target ...) and
|
|
// attaches to it via ptrace.
|
|
func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
|
|
sess, _, err := LaunchWithBuffers(gasmBin, asmPath, funcName, args, "")
|
|
return sess, err
|
|
}
|
|
|
|
// 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 PT_TRACE_ME 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)
|
|
}
|
|
if gasmBin != "" {
|
|
self = gasmBin
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "gasm-debug-*")
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("debug: tempdir: %w", err)
|
|
}
|
|
argsFile := filepath.Join(tmpDir, "args.bin")
|
|
if err := os.WriteFile(argsFile, args, 0o644); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, nil, fmt.Errorf("debug: write args: %w", err)
|
|
}
|
|
|
|
if bufSpec != "" {
|
|
if err := os.WriteFile(filepath.Join(tmpDir, "bufspec"), []byte(bufSpec), 0o644); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, nil, fmt.Errorf("debug: write bufspec: %w", err)
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(self, "debug", "--func", funcName, "--args", argsFile, asmPath)
|
|
cmd.Env = append(os.Environ(), "GASM_DEBUG_TARGET=1", "GASM_DEBUG_TMP="+tmpDir)
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = os.Stderr
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, nil, fmt.Errorf("debug: start debuggee: %w", err)
|
|
}
|
|
|
|
s := &Session{pid: cmd.Process.Pid, cmd: cmd, tmpDir: tmpDir}
|
|
|
|
readyFile := filepath.Join(tmpDir, "ready")
|
|
for range 500 {
|
|
if _, err := os.Stat(readyFile); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
// 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 debuggee: %w", err)
|
|
}
|
|
s.stopped = true
|
|
|
|
// The debuggee reports its JIT mapping in the codebase file; that is
|
|
// the supported path on FreeBSD, where no /proc/pid/maps exists to
|
|
// scan for the RWX region as a fallback.
|
|
if data, err := os.ReadFile(filepath.Join(tmpDir, "codebase")); err == nil {
|
|
fmt.Sscanf(string(data), "%d", &s.codeBase)
|
|
}
|
|
|
|
var bufAddrs []uint64
|
|
if bufSpec != "" {
|
|
addrFile := filepath.Join(tmpDir, "bufaddrs")
|
|
if data, err := os.ReadFile(addrFile); err == nil {
|
|
for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") {
|
|
var addr uint64
|
|
if _, err := fmt.Sscanf(line, "%d", &addr); err == nil {
|
|
bufAddrs = append(bufAddrs, addr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return s, bufAddrs, nil
|
|
}
|
|
|
|
// waitStopped consumes ptrace-stop events until one the debugger cares
|
|
// about arrives: SIGTRAP (a breakpoint or a completed single-step), the
|
|
// debuggee's own SIGSTOP, or a genuine signal-delivery-stop. A Go tracee's
|
|
// runtime raises SIGURG for asynchronous preemption, and every signal on a
|
|
// traced thread surfaces as a signal-delivery-stop, so SIGURG is suppressed
|
|
// and the tracee resumed without it. Every other signal (SIGSEGV, SIGBUS,
|
|
// SIGFPE, SIGILL, ...) is returned to the caller: resuming with signal 0
|
|
// would restart the faulting instruction and fault forever, so a faulting
|
|
// kernel must surface as a stop the caller reports.
|
|
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
|
|
s.lastSignal = 0
|
|
return sig, nil
|
|
case syscall.SIGURG:
|
|
// Go runtime asynchronous preemption: resume the tracee
|
|
// without delivering the signal.
|
|
s.lastSignal = 0
|
|
if err := unix.PtraceCont(s.pid, 0); err != nil {
|
|
return 0, fmt.Errorf("debug: PT_CONTINUE: %w", err)
|
|
}
|
|
default:
|
|
// A genuine signal-delivery-stop. Report it; the caller
|
|
// decides how to proceed.
|
|
s.stopped = true
|
|
s.lastSignal = sig
|
|
return sig, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// LastSignal returns the signal of the most recent stop when that stop was
|
|
// a genuine signal-delivery-stop (a fault such as SIGSEGV, SIGFPE, SIGILL
|
|
// or SIGBUS), and 0 for breakpoint traps, single-steps, SIGSTOP and
|
|
// suppressed runtime signals.
|
|
func (s *Session) LastSignal() syscall.Signal { return s.lastSignal }
|
|
|
|
// Peek reads a word (8 bytes) from the debuggee's memory at addr, through
|
|
// PT_IO with PIOD_READ_D.
|
|
func (s *Session) Peek(addr uint64) (uint64, error) {
|
|
var buf [8]byte
|
|
if _, err := unix.PtraceIO(unix.PIOD_READ_D, s.pid, uintptr(addr), buf[:], len(buf)); err != nil {
|
|
return 0, fmt.Errorf("debug: read mem %#x: %w", addr, err)
|
|
}
|
|
return uint64(buf[0]) | uint64(buf[1])<<8 | uint64(buf[2])<<16 | uint64(buf[3])<<24 |
|
|
uint64(buf[4])<<32 | uint64(buf[5])<<40 | uint64(buf[6])<<48 | uint64(buf[7])<<56, nil
|
|
}
|
|
|
|
// Poke writes a word (8 bytes) to the debuggee's memory at addr, through
|
|
// PT_IO with PIOD_WRITE_D.
|
|
func (s *Session) Poke(addr, val uint64) error {
|
|
buf := []byte{byte(val), byte(val >> 8), byte(val >> 16), byte(val >> 24),
|
|
byte(val >> 32), byte(val >> 40), byte(val >> 48), byte(val >> 56)}
|
|
if _, err := unix.PtraceIO(unix.PIOD_WRITE_D, s.pid, uintptr(addr), buf, len(buf)); err != nil {
|
|
return fmt.Errorf("debug: write mem %#x: %w", addr, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadMemory reads len bytes from the debuggee's memory at addr in one
|
|
// PT_IO request, the shape the request is built for.
|
|
func (s *Session) ReadMemory(addr uint64, length int) ([]byte, error) {
|
|
out := make([]byte, length)
|
|
n, err := unix.PtraceIO(unix.PIOD_READ_D, s.pid, uintptr(addr), out, length)
|
|
return out[:n], err
|
|
}
|
|
|
|
// WriteMemory writes bytes to the debuggee's memory at addr in one PT_IO
|
|
// request.
|
|
func (s *Session) WriteMemory(addr uint64, data []byte) error {
|
|
_, err := unix.PtraceIO(unix.PIOD_WRITE_D, s.pid, uintptr(addr), data, len(data))
|
|
return err
|
|
}
|
|
|
|
// Step executes a single instruction in the debuggee.
|
|
func (s *Session) Step() error {
|
|
if s.exited {
|
|
return fmt.Errorf("debug: debuggee has exited")
|
|
}
|
|
if err := unix.PtraceSingleStep(s.pid); err != nil {
|
|
return fmt.Errorf("debug: PT_STEP: %w", err)
|
|
}
|
|
_, err := s.waitStopped()
|
|
return err
|
|
}
|
|
|
|
// Continue resumes execution until the next breakpoint or exit.
|
|
func (s *Session) Continue() error {
|
|
if s.exited {
|
|
return fmt.Errorf("debug: debuggee has exited")
|
|
}
|
|
if err := unix.PtraceCont(s.pid, 0); err != nil {
|
|
return fmt.Errorf("debug: PT_CONTINUE: %w", err)
|
|
}
|
|
_, err := s.waitStopped()
|
|
return err
|
|
}
|
|
|
|
// Exited returns true if the debuggee has terminated.
|
|
func (s *Session) Exited() bool { return s.exited }
|
|
|
|
// Pid returns the debuggee's process ID.
|
|
func (s *Session) Pid() int { return s.pid }
|
|
|
|
// CodeBase returns the base address of the JIT code in the debuggee.
|
|
func (s *Session) CodeBase() uint64 { return s.codeBase }
|
|
|
|
// Kill terminates the debuggee and removes the session's scratch
|
|
// directory, so a successful session leaves no gasm-debug-* debris behind.
|
|
func (s *Session) Kill() {
|
|
if !s.exited {
|
|
syscall.Kill(s.pid, syscall.SIGKILL)
|
|
syscall.Wait4(s.pid, nil, 0, nil)
|
|
s.exited = true
|
|
}
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
s.cmd.Wait()
|
|
}
|
|
if s.tmpDir != "" {
|
|
os.RemoveAll(s.tmpDir)
|
|
s.tmpDir = ""
|
|
}
|
|
}
|
|
|
|
// execRange is one executable mapping of the debuggee.
|
|
type execRange struct {
|
|
lo, hi uint64
|
|
}
|
|
|
|
// execRanges is a stub on FreeBSD: there is no /proc/pid/maps to parse,
|
|
// and procfs(5) is not guaranteed to be mounted. The callers degrade
|
|
// gracefully: archReturnAddr falls back to the raw stack convention and
|
|
// the mapping scan is skipped.
|
|
func execRanges(pid int) []execRange { return nil }
|
|
|
|
// findRWXMapping is a stub on FreeBSD for the same reason: the codebase
|
|
// handshake file is the supported way the JIT region is located.
|
|
func findRWXMapping(pid int) uint64 { return 0 }
|