fix(debug): handle mapping edges, stray traps and dying debuggees

Assisted-by: GLM 5.3
This commit is contained in:
2026-10-02 00:40:54 +02:00
parent dd1782c538
commit f57377abb9
19 changed files with 726 additions and 97 deletions
+50 -27
View File
@@ -91,6 +91,16 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s
if _, err := os.Stat(readyFile); err == nil {
break
}
// A debuggee that died before signalling readiness (unknown
// function, unparseable source) writes its failure notice to the
// handshake directory; read it and fail fast. The poll never
// waits on the child: a wait here could consume the SIGSTOP park
// that waitStopped below must receive, hanging the launch.
if err := s.deadReason(); err != nil {
cmd.Wait()
os.RemoveAll(tmpDir)
return nil, nil, err
}
time.Sleep(5 * time.Millisecond)
}
@@ -131,6 +141,18 @@ func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec s
return s, bufAddrs, nil
}
// deadReason reports the debuggee's own failure notice, the file its
// failure paths write before exiting. A debuggee killed without a notice
// (a crash, SIGKILL) surfaces through waitStopped after the poll instead,
// which is why the poll's budget stays finite.
func (s *Session) deadReason() error {
data, err := os.ReadFile(filepath.Join(s.tmpDir, "dead"))
if err != nil {
return nil
}
return fmt.Errorf("debug: debuggee failed before signalling readiness: %s", strings.TrimSpace(string(data)))
}
// 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
@@ -219,40 +241,41 @@ func (s *Session) Poke(addr, val uint64) error {
return nil
}
// ReadMemory reads len bytes from the debuggee's memory at addr.
// ReadMemory reads len bytes from the debuggee's memory at addr. The read
// covers exactly the requested range: the old word-at-a-time loop read a
// whole 8-byte word for the final partial word, so a request that ended
// inside the last mapped page failed whenever the following page was
// unmapped, even though every requested byte was readable.
func (s *Session) ReadMemory(addr uint64, length int) ([]byte, error) {
out := make([]byte, length)
for i := 0; i < length; i += 8 {
word, err := s.Peek(addr + uint64(i))
if err != nil {
return out[:i], err
}
for j := 0; j < 8 && i+j < length; j++ {
out[i+j] = byte(word >> (8 * j))
}
mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_RDONLY, 0)
if err != nil {
return out, fmt.Errorf("debug: open /proc/%d/mem: %w", s.pid, err)
}
defer mem.Close()
n, err := mem.ReadAt(out, int64(addr))
if err != nil {
return out[:n], fmt.Errorf("debug: read mem %#x: %w", addr, err)
}
return out, nil
}
// WriteMemory writes bytes to the debuggee's memory at addr.
// WriteMemory writes bytes to the debuggee's memory at addr. The write
// covers exactly the given bytes: /proc/pid/mem accepts writes of any
// length at any offset, so the word loop's read-modify-write of the final
// partial word (which read past the requested range and failed on an
// unmapped following page) is unnecessary.
func (s *Session) WriteMemory(addr uint64, data []byte) error {
for i := 0; i < len(data); i += 8 {
end := min(i+8, len(data))
var word uint64
for j := 0; j < end-i; j++ {
word |= uint64(data[i+j]) << (8 * j)
}
if end-i < 8 {
existing, err := s.Peek(addr + uint64(i))
if err != nil {
return err
}
mask := ^((uint64(1) << (8 * (end - i))) - 1)
word = (existing & mask) | word
}
if err := s.Poke(addr+uint64(i), word); err != nil {
return err
}
if len(data) == 0 {
return nil
}
mem, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", s.pid), os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("debug: open /proc/%d/mem: %w", s.pid, err)
}
defer mem.Close()
if _, err := mem.WriteAt(data, int64(addr)); err != nil {
return fmt.Errorf("debug: write mem %#x: %w", addr, err)
}
return nil
}