From e36905e1a011622a03feb0c225d0eded51b8c54d Mon Sep 17 00:00:00 2001 From: Ivan Velichko Date: Wed, 7 Oct 2026 09:02:08 +0000 Subject: [PATCH] sensor/ptrace fix: don't re-inject kernel-generated SIGSTOPs into tracees The collect loop re-injected every non-SIGTRAP stop signal on resume, including the SIGSTOP the kernel queues on a freshly auto-attached thread (PTRACE_O_TRACECLONE). Delivering it puts the whole thread group into a group-stop. Traced threads get resumed by the next PTRACE_SYSCALL, but untraced kernel threads in the group (io_uring iou-sqp-*/iou-wrk-* used by Node.js/libuv) stay stopped forever, so the app never starts listening and the HTTP probe fails or hangs. Use PTRACE_GETSIGINFO to tell a real stop signal (sent via kill) from the auto-attach SIGSTOP and group-stop notifications, and only inject the real one. Also resume the stopped tracee after a syscall handler error instead of the previous pid, and finish collection on wait4 ECHILD instead of spinning. Fixes the hang with ghcr.io/iximiuz/labs/mintoolkit/nuxt-web-app:v1.0.0. Co-Authored-By: Claude Fable 5.1 --- pkg/monitor/ptrace/ptrace.go | 130 ++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/pkg/monitor/ptrace/ptrace.go b/pkg/monitor/ptrace/ptrace.go index 673eb12c..96f6358b 100644 --- a/pkg/monitor/ptrace/ptrace.go +++ b/pkg/monitor/ptrace/ptrace.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "syscall" + "unsafe" "github.com/armon/go-radix" log "github.com/sirupsen/logrus" @@ -724,9 +725,13 @@ func (app *App) collect() { wpid, err := syscall.Wait4(waitFor, &ws, syscall.WALL, nil) if err != nil { if err.(syscall.Errno) == syscall.ECHILD { - logger.Debug("wait4 ECHILD error (ignoring)") - doSyscall = false - continue + // No tracees left to wait for (we missed their exit events + // or they were reaped elsewhere). Nothing else will ever + // show up here, so finish instead of spinning on wait4. + logger.Debugf("[%d/%d]: wait4 ECHILD - no more tracees (tracked=%d) - done", + app.cmd.Process.Pid, app.pgid, len(pidSyscallState)) + app.collectorDoneCh <- 0 + return } logger.Debugf("wait4 error - %v (errno=%d)", err, err.(syscall.Errno)) @@ -777,6 +782,9 @@ func (app *App) collect() { eventCode = ws.TrapCause() } else { callSig = statusCode + if isStopSignal(callSig) && !app.shouldInjectStopSignal(wpid, callSig) { + callSig = 0 + } } } @@ -836,6 +844,10 @@ func (app *App) collect() { if err != nil { logger.Debugf("[%d/%d]: wpid=%v onSyscall error - %v", app.cmd.Process.Pid, app.pgid, wpid, err) + // Still need to resume wpid (not the previous callPid), + // otherwise it stays stopped forever. + doSyscall = true + callPid = wpid continue } @@ -861,6 +873,10 @@ func (app *App) collect() { if err := onSyscallReturn(wpid, cstate); err != nil { logger.Debugf("[%d/%d]: wpid=%v onSyscallReturn error - %v", app.cmd.Process.Pid, app.pgid, wpid, err) + // Still need to resume wpid (not the previous callPid), + // otherwise it stays stopped forever. + doSyscall = true + callPid = wpid continue } } @@ -953,6 +969,52 @@ func (app *App) collect() { } // eof: main for loop } +// shouldInjectStopSignal decides whether a stop signal (SIGSTOP, SIGTSTP, +// SIGTTIN, SIGTTOU) reported by wait4 for a stopped tracee must be delivered +// to the tracee when it's resumed. +// +// A tracee reports a stop with one of these signals in three situations: +// +// 1. A newly cloned/forked tracee reports its initial, kernel-generated +// SIGSTOP (PTRACE_O_TRACECLONE/FORK/VFORK auto-attach). +// 2. The thread group is in a group-stop and every traced thread +// reports it (PTRACE_GETSIGINFO fails with EINVAL in this case). +// 3. Some process actually sent the signal (e.g., `kill -STOP`). +// +// Only (3) is a real signal that should be delivered. Re-injecting it in +// (1) or (2) delivers a SIGSTOP the target never asked for and puts +// the whole thread group into a group-stop. The traced threads get resumed +// by the next PTRACE_SYSCALL (group-stops aren't honored without PTRACE_SEIZE), +// but the untraced threads in the group - e.g., the kernel's io_uring +// SQPOLL/worker threads (iou-sqp-*, iou-wrk-*) that Node.js/libuv relies on - +// stay stopped until a SIGCONT arrives, and the app hangs forever waiting for them. +func (app *App) shouldInjectStopSignal(pid int, sig int) bool { + logger := app.logger.WithField("op", "shouldInjectStopSignal") + + si, err := ptraceGetSigInfo(pid) + if err != nil { + if err == syscall.EINVAL { + logger.Tracef("[%d/%d]: pid=%v sig=%v - group-stop notification - not injecting", + app.cmd.Process.Pid, app.pgid, pid, sig) + } else { + logger.Debugf("[%d/%d]: pid=%v sig=%v - PTRACE_GETSIGINFO error - %v - not injecting", + app.cmd.Process.Pid, app.pgid, pid, sig, err) + } + + return false + } + + if si.Code == siUser && si.Pid == 0 { + logger.Tracef("[%d/%d]: pid=%v sig=%v - initial ptrace attach stop - not injecting", + app.cmd.Process.Pid, app.pgid, pid, sig) + return false + } + + logger.Debugf("[%d/%d]: pid=%v sig=%v - real stop signal (si_code=%d si_pid=%d si_uid=%d) - injecting", + app.cmd.Process.Pid, app.pgid, pid, sig, si.Code, si.Pid, si.Uid) + return true +} + func (app *App) startSignalForwarding() context.CancelFunc { logger := app.logger.WithField("op", "startSignalForwarding") logger.Debug("call") @@ -1512,3 +1574,65 @@ var sigEnums = [...]string{ syscall.SIGXCPU: "SIGXCPU", syscall.SIGXFSZ: "SIGXFSZ", } + +/////////////////////////////////// + +// siUser is the si_code value (SI_USER) for signals sent by kill(2)/raise(3). +// The kernel also uses it for the SIGSTOP it queues on a freshly auto-attached +// tracee (PTRACE_O_TRACECLONE/FORK/VFORK), but with si_pid == 0 in that case. +const siUser = 0 + +// ptraceSigInfo is the subset of the kernel siginfo_t the tracer cares about. +type ptraceSigInfo struct { + Signo int32 + Errno int32 + Code int32 + Pid int32 // si_pid - sending process (0 when generated by the kernel) + Uid uint32 // si_uid - sending process' real UID +} + +// ptraceGetSigInfo returns the siginfo for the signal that stopped the tracee. +// It fails with EINVAL if the tracee is in a group-stop rather than +// in a signal-delivery-stop (see ptrace(2)). +func ptraceGetSigInfo(pid int) (*ptraceSigInfo, error) { + // siginfo_t is 128 bytes on every Linux architecture. + var buf [128]byte + _, _, errno := unix.Syscall6( + unix.SYS_PTRACE, + unix.PTRACE_GETSIGINFO, + uintptr(pid), + 0, + uintptr(unsafe.Pointer(&buf[0])), + 0, 0) + if errno != 0 { + return nil, errno + } + + // Generic siginfo_t layout: si_signo, si_errno, si_code (3 x int32), + // followed by the _sifields union aligned to the native word size + // (offset 16 on 64-bit targets, 12 on 32-bit ones). + // For SI_USER the union starts with si_pid (int32) and si_uid (uint32). + fieldsOff := 12 + if unsafe.Sizeof(uintptr(0)) == 8 { + fieldsOff = 16 + } + + return &ptraceSigInfo{ + Signo: *(*int32)(unsafe.Pointer(&buf[0])), + Errno: *(*int32)(unsafe.Pointer(&buf[4])), + Code: *(*int32)(unsafe.Pointer(&buf[8])), + Pid: *(*int32)(unsafe.Pointer(&buf[fieldsOff])), + Uid: *(*uint32)(unsafe.Pointer(&buf[fieldsOff+4])), + }, nil +} + +// isStopSignal reports whether sig is one of the job control signals +// whose default action is to stop the whole thread group. +func isStopSignal(sig int) bool { + switch syscall.Signal(sig) { + case syscall.SIGSTOP, syscall.SIGTSTP, syscall.SIGTTIN, syscall.SIGTTOU: + return true + } + + return false +}