// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause //go:build linux && amd64 package debug import ( "fmt" "os" "runtime" "syscall" "unsafe" "sourcedock.dev/petrbalvin/gasm-devkit/asm" "sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/verify" ) // RunTarget is the debuggee entry point (gasm debug --target). It // assembles the file, maps the JIT code, registers itself for ptrace, // stops, and then executes the named function. The parent debugger // controls execution from there. func RunTarget(asmPath, funcName, argsFile, tmpDir string) error { // Parse and assemble. src, err := os.ReadFile(asmPath) if err != nil { return fmt.Errorf("debug target: %w", err) } file, errs := parser.Parse(asmPath, string(src)) if len(errs) > 0 { return fmt.Errorf("debug target: parse: %v", errs[0]) } img, err := asm.AssembleFile(file) if err != nil { return fmt.Errorf("debug target: assemble: %w", err) } // Find the function. var fl *asm.FuncLayout for i := range img.Funcs { if img.Funcs[i].Name == funcName { fl = &img.Funcs[i] break } } if fl == nil { return fmt.Errorf("debug target: function %q not found", funcName) } // Map the entire image RWX (we need write access for breakpoints). code := img.Bytes() exec, err := mapRWX(code) if err != nil { return fmt.Errorf("debug target: mmap: %w", err) } // Write the code base address for the parent. codeBase := uintptr(unsafe.Pointer(&exec[0])) if err := os.WriteFile(tmpDir+"/codebase", []byte(fmt.Sprintf("%d", codeBase)), 0o644); err != nil { return fmt.Errorf("debug target: write codebase: %w", err) } // Write function metadata (offset, size, args) for the parent. meta := fmt.Sprintf("%d %d %d", fl.Offset, fl.Size, fl.Args) os.WriteFile(tmpDir+"/funcmeta", []byte(meta), 0o644) // Write label table for breakpoint resolution. labelsFile, _ := os.Create(tmpDir + "/labels") if labelsFile != nil { for label, off := range fl.Labels { fmt.Fprintf(labelsFile, "%s %d\n", label, off) } labelsFile.Close() } // Read the argument block. args, err := os.ReadFile(argsFile) if err != nil { return fmt.Errorf("debug target: read args: %w", err) } if len(args) < fl.Args { padded := make([]byte, fl.Args) copy(padded, args) args = padded } // Lock this goroutine to the current OS thread so the parent's // ptrace (attached to this thread) controls the JIT execution. runtime.LockOSThread() // Request tracing by the parent, then stop. PTRACE_TRACEME makes // the subsequent SIGSTOP a ptrace-stop (not a group-stop), giving // the parent full control from the start. if _, _, errno := syscall.Syscall(syscall.SYS_PTRACE, uintptr(syscall.PTRACE_TRACEME), 0, 0); errno != 0 { return fmt.Errorf("debug target: PTRACE_TRACEME: %v", errno) } os.WriteFile(tmpDir+"/ready", []byte("ok"), 0o644) syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) // --- Execution resumes here after the parent continues us --- // Prepare the ABI0 stack and call the function. fnAddr := codeBase + uintptr(fl.Offset) stackArgs := make([]byte, fl.Args) copy(stackArgs, args) _, callErr := verify.Call(fnAddr, stackArgs) if callErr != nil { // The function returned an error (shouldn't happen for valid code). os.Exit(1) } os.Exit(0) return nil } // mapRWX maps code into a read-write-execute region (needed for // breakpoint patching via ptrace POKETEXT, though ptrace can write // to any mapping regardless of permissions). func mapRWX(code []byte) ([]byte, error) { const pageSize = 4096 size := (len(code) + pageSize - 1) &^ (pageSize - 1) mem, err := syscall.Mmap(-1, 0, size, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC, syscall.MAP_PRIVATE|syscall.MAP_ANON) if err != nil { return nil, err } copy(mem, code) return mem, nil }