Repository navigation
systrap: fail-stop sentry on stuck context - #14201
Conversation
|
@EtiennePerot @konstantin-s-bogom gentle ping. we hit this exact freeze in production again today (sentry unresponsive, kubelet KillPodSandbox DeadlineExceeded, pod stuck Terminating ~1.5h until manual kill -9 on the node). if the fail-stop direction is wrong say so and il rework it, can also add tests if that helps |
|
Filed #14408 with the full production forensics for the freeze this PR addresses (two releases affected, node-side signature, why stacks are unobtainable). |
|
Similar to issue #14405 , Sentry hang causes containerd and kubelet memory to grow unbounded, eventually the whole node OOM. |
|
@tanyifeng im sure this is the same incident as #14405. your dump shows the task goroutine parked in sleepOnState on the ThreadContext.State futex after a single missed SIGUSR1, with Kernel.Pause() wedged behind it so runsc kill --all never returns. thats exactly what we see from the outside in #14408: sentry alive, control RPCs hang (runsc debug --stacks included, which is why we never got the stacks you did), kubelet KillPodSandbox DeadlineExceeded forever, and later kills queueing behind the shims Init.mu, same as your 45k blocked goroutines. same loop, observed from two sides. your dump also changed my fix: a single lost SIGUSR1 is recoverable, so im updating this PR to resend the interrupt on every checkup wakeup and only fail-stop when repeated interrupts prove the stub is gone, plus dumping all goroutine stacks before killing the sentry so the next occurrence self-documents what you had to capture by hand. would love your review on it once i push, hopefully today |
|
Thank you for updating the fix based on the evidence from #14405. Resending SIGUSR1 and dumping the stacks before exiting are both helpful improvements. I’m not a reviewer, but I’m personally concerned that terminating the entire sentry after 30 seconds may be too aggressive. A context remaining unchanged for 30 seconds does not necessarily mean that the stub is unrecoverable; it could also be caused by host performance issues or CPU scheduling delays. I would prefer a staged approach: first retry SIGUSR1 and collect diagnostic information, then terminate the sentry only if the stub is confirmed to be gone, repeated interrupts fail to make progress, or the sandbox is already being torn down. Would it also be possible to add separate timeouts for We have enabled This issue is rare, but its impact is severe when it occurs. @ayushr2 , could you please take a look and help move the fix forward? |
|
@tanyifeng good points, thanks for the push. on the staged approach, the updated diff already does most of it: SIGUSR1 is resent on every 5s checkup, state is rechecked after every futex wakeup so a stub that makes any progress escapes, all goroutine stacks are dumped before the kill, and ESRCH fail-stops immediately since there the stub is confirmed gone. the one stage i cant implement is "confirm the stub is gone" beyond ESRCH.. thats exactly what #14408 shows is unobservable from outside a wedged sandbox, the sentry is alive and every control RPC hangs. on 30s being too aggressive, fair. the gate is stricter than it looks, fail-stop needs zero state change through the whole window with repeated interrupts already sent, and even a hard cfs-throttled stub gets scheduled every 100ms period so it escapes on the next wakeup. but its still a heuristic, so il decouple the fail-stop deadline from the 30s stuck-warning deadline and give it more headroom, say a few minutes like the watchdog's stuck-task default. can also put it behind a flag if maintainers prefer opt-in. shim-side timeouts on Stats/Kill i agree with as damage control but its a different layer, il file a follow-up issue rather than grow this PR. one caveat there: a timed-out Kill returning an error to kubelet is the same DeadlineExceeded loop we already see, so the win is bounded shim memory, not recovery. the --panic-signal tip is good, thats how you got the stacks manual kill never gave us. the TracebackAll in this PR is basically that, automated at the moment it matters. does a longer fail-stop default address your concern, or do you think sentry termination should be opt-in? |
Thanks, that addresses most of my concern. I would prefer a longer, configurable fail-stop deadline that remains enabled by default, rather than making termination opt-in. A few minutes, similar to the watchdog’s stuck-task default, gives transient host pressure more time to recover while still preventing the multi-day failure and node-level memory growth we observed. |
| timeout := unix.NsecToTimespec(contextPreemptTimeout.Nanoseconds()) | ||
| interruptsSent := 0 | ||
| deadline := time.Now().Add(stuckTimeout) | ||
| failStop := func() error { |
There was a problem hiding this comment.
Instead of having this killSentry callback within a callback, how about just having sleepOnStateWithTimeout return a new err type, and have sleepOnState call call sighandling.KillItself along with the logging.
There was a problem hiding this comment.
Wrote this before my req to change the KillItself, but point still stands, remove the callbacks.
There was a problem hiding this comment.
Done. sleepOnStateWithTimeout now returns errStuckContext and sleepOnState does the traceback + kill, no callbacks.
There was a problem hiding this comment.
Done, callbacks removed.
| // Dump all goroutine stacks first: once the sentry is gone there is | ||
| // nothing left to debug, and stacks are unobtainable from outside a | ||
| // wedged sandbox (control RPCs hang, see #14408). |
| if interruptsSent > 0 { | ||
| return failStop() | ||
| } | ||
| log.Warningf("Systrap task goroutine has been waiting on ThreadContext.State futex too long. ThreadContext: %v", sc) |
There was a problem hiding this comment.
This log is basically dead code after this change
| if errno == 0 { | ||
| continue | ||
| } |
There was a problem hiding this comment.
Lost in refactoring? This continue should still be here in case we got an interrupt.
There was a problem hiding this comment.
Done, restored. The escalation paths also recheck the state so a context that recovered during the futex wait is never treated as stuck (a test caught exactly that race).
| // shared memory between reads. | ||
| threadID := atomic.LoadUint32(&sc.shared.ThreadID) | ||
| if threadID == invalidThreadID { | ||
| return |
There was a problem hiding this comment.
Return an error, do a TracebackAll in sleepOnStateWithTimeout in case we see it.
There was a problem hiding this comment.
Done. interruptStub returns errNoStubThread / errStubThreadGone; the loop logs and continues on the first, escalates on the second.
| if !ok { | ||
| // This is either an invalidThreadID or another garbage value; either way we | ||
| // don't know which thread to interrupt; best we can do is mark the context. | ||
| return |
There was a problem hiding this comment.
Return an error, do a TracebackAll in sleepOnStateWithTimeout in case we see it.
There was a problem hiding this comment.
Done, same as above.
| timeout := unix.Timespec{ | ||
| Sec: 0, | ||
| Nsec: contextPreemptTimeoutNsec, | ||
| return sc.sleepOnStateWithTimeout(state, stuckContextTimeout, contextCheckupTimeout, sighandling.KillItself) |
There was a problem hiding this comment.
I think calling KillItself here is an over-reaction. If there's one stuck subproc but the others are fine, we'd end up killing everything. Like in NotifyInterrupt, the first action should still be to kill the subprocess.
There was a problem hiding this comment.
Done. Escalation now kills only the stuck subprocess through the same path NotifyInterrupt uses (mark dead, ContextStateUnexpectedDeath, kill syscall thread), extracted into killSubprocess and shared by both.
| // A single interrupt can be lost: #14405 captured a task goroutine | ||
| // wedged forever after one missed SIGUSR1. Resend on every checkup | ||
| // wakeup until the context recovers or the deadline expires, and | ||
| // fail-stop only when repeated interrupts prove the stub is gone. |
There was a problem hiding this comment.
Please review comments, we don't need to redescribe the code in comment form.
There was a problem hiding this comment.
Done, dropped them.
| if err := killSentry(); err != nil { | ||
| panic(fmt.Sprintf("failed to kill sentry with stuck systrap context: %v", err)) | ||
| } | ||
| // KillItself doesn't return on success. This return keeps the path testable. |
|
@konstantin-s-bogom all comments addressed, pushed as abd26a0. escalation now kills only the stuck subprocess via the NotifyInterrupt path, no callbacks, typed errors, comments dropped. also raised the stuck deadline to 3 minutes to match the watchdog stuck-task default, per @tanyifeng's concern that 30s is too aggressive under transient host pressure. systrap_test passes 20/20 runs and runsc builds. |
| contextPreemptTimeout = 10 * time.Millisecond | ||
| contextCheckupTimeout = 5 * time.Second | ||
| // stuckContextTimeout matches the watchdog's stuck-task default. | ||
| stuckContextTimeout = 3 * time.Minute |
There was a problem hiding this comment.
If we were killing the entire sentry, I would agree that having a timeout this long would make sense. But now that we're killing just the stuck subproc, this is too long. I don't see anything wrong with 30 seconds as it was before. 30 seconds is still ages in terms of responding to a signal in time.
There was a problem hiding this comment.
Done, back to 30s.
| func (sc *sharedContext) sleepOnStateWithTimeout(state sysmsg.ContextState, stuckTimeout, checkupTimeout time.Duration) error { | ||
| timeout := unix.NsecToTimespec(contextPreemptTimeout.Nanoseconds()) | ||
| interruptsSent := 0 | ||
| sawNoStubThread := false |
There was a problem hiding this comment.
Need to use this variable to exit early. If we didn't see a stub thread once, we will not see it again.
There was a problem hiding this comment.
Done. The loop now exits on the first errNoStubThread (traceback, then the wrapper kills the subprocess), so the flag is gone entirely.
konstantin-s-bogom
left a comment
There was a problem hiding this comment.
Looks good, please squash your commits though
A systrap task could wait in sleepOnState forever when its stub stopped responding: one interrupt was sent, then the loop only logged warnings. Container teardown then blocked indefinitely in WaitExited, wedging the sandbox and its control RPCs (pods stuck Terminating for days, see google#14408 and google#14405). A single interrupt can be lost (google#14405), so resend it on every checkup wakeup. If the stub stays unresponsive through the 30s stuck deadline, or is already gone, dump all goroutine stacks and kill only the stuck subprocess through the path NotifyInterrupt already uses (mark dead, ContextStateUnexpectedDeath, kill the syscall thread). The task goroutine returns instead of blocking Kernel.Pause() forever, and healthy subprocesses are unaffected. Fixes google#14408 Assisted-by: Claude Code
91fc347 to
d119f5c
Compare
|
@konstantin-s-bogom squashed to a single commit, thanks for the reviews |
A systrap task can remain in `sleepOnState` forever when its stub stops responding. The existing path sends a single interrupt and logs repeated warnings, but it has no retry and no escape. Container teardown can then block indefinitely in `WaitExited`, leaving the sandbox and its control RPCs stuck. We hit this in a production Kubernetes cluster. Multiple gVisor pods remained Terminating for days, on both release-20260427.0 and release-20260714.0. In one affected sandbox, both `runsc kill` and `runsc debug --stacks` hung while the sentry stayed alive. Systrap `[exe]` stubs and node load continued to accumulate even though CPU use remained low. Killing the sentry process tree cleared the pod and returned node load to normal immediately. Full forensics in #14408. #14405 is an independent production report of the same hang, with the sentry stacks we could not capture: a task goroutine parked 7,818 minutes in `sleepOnState` on the ThreadContext.State futex after one missed SIGUSR1, `Kernel.Pause()` blocked behind it so the URPC handler for `runsc kill --all` never returns. On the shim side 45,646 goroutines piled up on `Init.mu` behind the hung kill, growing containerd and kubelet memory until node OOM. The fix, staged from least to most disruptive: 1. Because #14405 shows a single interrupt can simply be lost, the interrupt is resent on every checkup wakeup instead of once. A lost signal recovers the workload with no visible damage. 2. If the stub stays unresponsive through repeated interrupts for the full stuck-context deadline (30 seconds), all goroutine stacks are dumped via `log.TracebackAll`. Stacks are unobtainable from outside a wedged sandbox, so the escalation self-documents what #14405 had to capture by hand. 3. Only the stuck subprocess is then killed, through the same path `NotifyInterrupt` already uses when a stub is gone (mark dead, `ContextStateUnexpectedDeath`, kill the syscall thread). Healthy subprocesses in the sandbox are unaffected, and the task goroutine returns instead of blocking `Kernel.Pause()` forever. `sleepOnStateWithTimeout` returns a typed error and stays side-effect free; `sleepOnState` does the traceback and the kill. A stub that is already gone takes the quiet `NotifyInterrupt` path (warn + kill subprocess, no stack dump). Resent interrupts do not extend the stuck deadline; a regression test covers that. Tests: - `bazel test //pkg/sentry/platform/systrap:systrap_test` - `bazel build //runsc:runsc` - Stuck, stub-gone, repeated-interrupt, and recovered-context cases covered on the typed error value. Fixes #14408. Related to #14405 and #12209. Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14201 from NahumLitvin:fix/systrap-stuck-context d119f5c PiperOrigin-RevId: 974017442
A systrap task can remain in `sleepOnState` forever when its stub stops responding. The existing path sends a single interrupt and logs repeated warnings, but it has no retry and no escape. Container teardown can then block indefinitely in `WaitExited`, leaving the sandbox and its control RPCs stuck. We hit this in a production Kubernetes cluster. Multiple gVisor pods remained Terminating for days, on both release-20260427.0 and release-20260714.0. In one affected sandbox, both `runsc kill` and `runsc debug --stacks` hung while the sentry stayed alive. Systrap `[exe]` stubs and node load continued to accumulate even though CPU use remained low. Killing the sentry process tree cleared the pod and returned node load to normal immediately. Full forensics in #14408. #14405 is an independent production report of the same hang, with the sentry stacks we could not capture: a task goroutine parked 7,818 minutes in `sleepOnState` on the ThreadContext.State futex after one missed SIGUSR1, `Kernel.Pause()` blocked behind it so the URPC handler for `runsc kill --all` never returns. On the shim side 45,646 goroutines piled up on `Init.mu` behind the hung kill, growing containerd and kubelet memory until node OOM. The fix, staged from least to most disruptive: 1. Because #14405 shows a single interrupt can simply be lost, the interrupt is resent on every checkup wakeup instead of once. A lost signal recovers the workload with no visible damage. 2. If the stub stays unresponsive through repeated interrupts for the full stuck-context deadline (30 seconds), all goroutine stacks are dumped via `log.TracebackAll`. Stacks are unobtainable from outside a wedged sandbox, so the escalation self-documents what #14405 had to capture by hand. 3. Only the stuck subprocess is then killed, through the same path `NotifyInterrupt` already uses when a stub is gone (mark dead, `ContextStateUnexpectedDeath`, kill the syscall thread). Healthy subprocesses in the sandbox are unaffected, and the task goroutine returns instead of blocking `Kernel.Pause()` forever. `sleepOnStateWithTimeout` returns a typed error and stays side-effect free; `sleepOnState` does the traceback and the kill. A stub that is already gone takes the quiet `NotifyInterrupt` path (warn + kill subprocess, no stack dump). Resent interrupts do not extend the stuck deadline; a regression test covers that. Tests: - `bazel test //pkg/sentry/platform/systrap:systrap_test` - `bazel build //runsc:runsc` - Stuck, stub-gone, repeated-interrupt, and recovered-context cases covered on the typed error value. Fixes #14408. Related to #14405 and #12209. Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14201 from NahumLitvin:fix/systrap-stuck-context d119f5c PiperOrigin-RevId: 974017442
This is the shim half of #14548. When the sandbox control server stopped answering (#14405, #14408), each `runsc kill` ran with no limit and the shim held the container lock across the call. In one production dump 45,646 goroutines sat behind that lock, most of them cadvisor `Stats` calls whose clients had long since timed out, 610MB shim RSS after 5 days, and kubelet retried into it for days. Two changes: - `Kill`, `Stats` and `Status` each run under a 30s context, so a wedged sandbox releases `Init.mu` instead of holding it for good - runsc commands get `WaitDelay`, so a killed command cannot leave `Wait` blocked on fds a grandchild inherited The sentry-side fix for the original hang merged in #14201. This covers the shim for when a sandbox finds a new way to stop answering. Not covered: `Init.Update` and `Init.Delete` hold `p.mu` across a runsc call with no bound too, same shape as `Status` and `Stats`. The pileup can come back through either. I left them out to keep this reviewable. On the number: `kill`'s own retry loop already gives up after 1s, 10s under `FuseAbort`, so 30s sits well clear of anything a healthy sandbox does. It is there to catch the sandbox that never answers, not to set a latency target. An earlier revision escalated to SIGKILL on the sandbox process once the 30s expired. That is out, per review: `killAllLocked` also runs from the exit handler when an init process exits, so the escalation would have taken healthy containers in the same sandbox with it. #14548 tracks it instead. Tests: `init_kill_test.go` covers a wedged `runsc` for Kill, KillAll, Stats and Status, and checks that the sandbox process survives KillAll. `runsc_test.go` covers the fd case: a fake runsc that exits while a child still holds the pipe `cmdOutput` handed it, which leaves `Wait` stuck without `WaitDelay`. Updates #14548 Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14549 from NahumLitvin:fix/shim-bounded-kill 9d1aa3e PiperOrigin-RevId: 987096412
This is the shim half of #14548. When the sandbox control server stopped answering (#14405, #14408), each `runsc kill` ran with no limit and the shim held the container lock across the call. In one production dump 45,646 goroutines sat behind that lock, most of them cadvisor `Stats` calls whose clients had long since timed out, 610MB shim RSS after 5 days, and kubelet retried into it for days. Two changes: - `Kill`, `Stats` and `Status` each run under a 30s context, so a wedged sandbox releases `Init.mu` instead of holding it for good - runsc commands get `WaitDelay`, so a killed command cannot leave `Wait` blocked on fds a grandchild inherited The sentry-side fix for the original hang merged in #14201. This covers the shim for when a sandbox finds a new way to stop answering. Not covered: `Init.Update` and `Init.Delete` hold `p.mu` across a runsc call with no bound too, same shape as `Status` and `Stats`. The pileup can come back through either. I left them out to keep this reviewable. On the number: `kill`'s own retry loop already gives up after 1s, 10s under `FuseAbort`, so 30s sits well clear of anything a healthy sandbox does. It is there to catch the sandbox that never answers, not to set a latency target. An earlier revision escalated to SIGKILL on the sandbox process once the 30s expired. That is out, per review: `killAllLocked` also runs from the exit handler when an init process exits, so the escalation would have taken healthy containers in the same sandbox with it. #14548 tracks it instead. Tests: `init_kill_test.go` covers a wedged `runsc` for Kill, KillAll, Stats and Status, and checks that the sandbox process survives KillAll. `runsc_test.go` covers the fd case: a fake runsc that exits while a child still holds the pipe `cmdOutput` handed it, which leaves `Wait` stuck without `WaitDelay`. Updates #14548 Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14549 from NahumLitvin:fix/shim-bounded-kill 9d1aa3e PiperOrigin-RevId: 987096412
This is the shim half of #14548. When the sandbox control server stopped answering (#14405, #14408), each `runsc kill` ran with no limit and the shim held the container lock across the call. In one production dump 45,646 goroutines sat behind that lock, most of them cadvisor `Stats` calls whose clients had long since timed out, 610MB shim RSS after 5 days, and kubelet retried into it for days. Two changes: - `Kill`, `Stats` and `Status` each run under a 30s context, so a wedged sandbox releases `Init.mu` instead of holding it for good - runsc commands get `WaitDelay`, so a killed command cannot leave `Wait` blocked on fds a grandchild inherited The sentry-side fix for the original hang merged in #14201. This covers the shim for when a sandbox finds a new way to stop answering. Not covered: `Init.Update` and `Init.Delete` hold `p.mu` across a runsc call with no bound too, same shape as `Status` and `Stats`. The pileup can come back through either. I left them out to keep this reviewable. On the number: `kill`'s own retry loop already gives up after 1s, 10s under `FuseAbort`, so 30s sits well clear of anything a healthy sandbox does. It is there to catch the sandbox that never answers, not to set a latency target. An earlier revision escalated to SIGKILL on the sandbox process once the 30s expired. That is out, per review: `killAllLocked` also runs from the exit handler when an init process exits, so the escalation would have taken healthy containers in the same sandbox with it. #14548 tracks it instead. Tests: `init_kill_test.go` covers a wedged `runsc` for Kill, KillAll, Stats and Status, and checks that the sandbox process survives KillAll. `runsc_test.go` covers the fd case: a fake runsc that exits while a child still holds the pipe `cmdOutput` handed it, which leaves `Wait` stuck without `WaitDelay`. Updates #14548 Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14549 from NahumLitvin:fix/shim-bounded-kill 9d1aa3e PiperOrigin-RevId: 987096412
This is the shim half of #14548. When the sandbox control server stopped answering (#14405, #14408), each `runsc kill` ran with no limit and the shim held the container lock across the call. In one production dump 45,646 goroutines sat behind that lock, most of them cadvisor `Stats` calls whose clients had long since timed out, 610MB shim RSS after 5 days, and kubelet retried into it for days. Two changes: - `Kill`, `Stats` and `Status` each run under a 30s context, so a wedged sandbox releases `Init.mu` instead of holding it for good - runsc commands get `WaitDelay`, so a killed command cannot leave `Wait` blocked on fds a grandchild inherited The sentry-side fix for the original hang merged in #14201. This covers the shim for when a sandbox finds a new way to stop answering. Not covered: `Init.Update` and `Init.Delete` hold `p.mu` across a runsc call with no bound too, same shape as `Status` and `Stats`. The pileup can come back through either. I left them out to keep this reviewable. On the number: `kill`'s own retry loop already gives up after 1s, 10s under `FuseAbort`, so 30s sits well clear of anything a healthy sandbox does. It is there to catch the sandbox that never answers, not to set a latency target. An earlier revision escalated to SIGKILL on the sandbox process once the 30s expired. That is out, per review: `killAllLocked` also runs from the exit handler when an init process exits, so the escalation would have taken healthy containers in the same sandbox with it. #14548 tracks it instead. Tests: `init_kill_test.go` covers a wedged `runsc` for Kill, KillAll, Stats and Status, and checks that the sandbox process survives KillAll. `runsc_test.go` covers the fd case: a fake runsc that exits while a child still holds the pipe `cmdOutput` handed it, which leaves `Wait` stuck without `WaitDelay`. Updates #14548 Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14549 from NahumLitvin:fix/shim-bounded-kill 9d1aa3e PiperOrigin-RevId: 987096412
This is the shim half of #14548. When the sandbox control server stopped answering (#14405, #14408), each `runsc kill` ran with no limit and the shim held the container lock across the call. In one production dump 45,646 goroutines sat behind that lock, most of them cadvisor `Stats` calls whose clients had long since timed out, 610MB shim RSS after 5 days, and kubelet retried into it for days. Two changes: - `Kill`, `Stats` and `Status` each run under a 30s context, so a wedged sandbox releases `Init.mu` instead of holding it for good - runsc commands get `WaitDelay`, so a killed command cannot leave `Wait` blocked on fds a grandchild inherited The sentry-side fix for the original hang merged in #14201. This covers the shim for when a sandbox finds a new way to stop answering. Not covered: `Init.Update` and `Init.Delete` hold `p.mu` across a runsc call with no bound too, same shape as `Status` and `Stats`. The pileup can come back through either. I left them out to keep this reviewable. On the number: `kill`'s own retry loop already gives up after 1s, 10s under `FuseAbort`, so 30s sits well clear of anything a healthy sandbox does. It is there to catch the sandbox that never answers, not to set a latency target. An earlier revision escalated to SIGKILL on the sandbox process once the 30s expired. That is out, per review: `killAllLocked` also runs from the exit handler when an init process exits, so the escalation would have taken healthy containers in the same sandbox with it. #14548 tracks it instead. Tests: `init_kill_test.go` covers a wedged `runsc` for Kill, KillAll, Stats and Status, and checks that the sandbox process survives KillAll. `runsc_test.go` covers the fd case: a fake runsc that exits while a child still holds the pipe `cmdOutput` handed it, which leaves `Wait` stuck without `WaitDelay`. Updates #14548 Assisted-by: Claude Code FUTURE_COPYBARA_INTEGRATE_REVIEW=#14549 from NahumLitvin:fix/shim-bounded-kill 9d1aa3e PiperOrigin-RevId: 987096412
sleepOnStateWithTimeout only resends the interrupt when other contexts are waiting in the queue. If the sentry interrupts a running context (signal delivery, kill) and that one signal is lost while the queue is empty, nothing resends it and the stuck deadline never starts, so the task goroutine waits indefinitely. The fix in google#14201 only covered the busy-queue case. Track a sentry-private interruptPending flag, set in NotifyInterrupt and cleared with the shared Interrupt flag, and resend while it is set. The shared Interrupt field is not used for this because the stub can write it. An idle context with nothing pending still waits as before. The stuck-context traceback now also logs the context's shared state and the stub thread's sysmsg state, error and line, so the next report shows whether the stub ever saw the signal. Both gaps were pointed out by Mor Kalfon reading google#14201. Assisted-by: Claude Code Signed-off-by: Nahum Litvin <nahuml@wix.com>
sleepOnStateWithTimeout only resends the interrupt when other contexts are waiting in the queue. If the sentry interrupts a running context (signal delivery, kill) and that one signal is lost while the queue is empty, nothing resends it and the stuck deadline never starts, so the task goroutine waits indefinitely. The fix in google#14201 only covered the busy-queue case. Track a sentry-private interruptRequested flag, set in NotifyInterrupt and cleared with the shared Interrupt flag, and resend while it is set. The shared Interrupt field is not used for this because the stub can write it. An idle context with nothing pending still waits as before. The stuck-context traceback now also logs the context's shared state and the stub thread's sysmsg state, error and line, so the next report shows where the stub thread was when it stopped answering. Both gaps were pointed out by Mor Kalfon reading google#14201. Assisted-by: Claude Code Signed-off-by: Nahum Litvin <nahuml@wix.com>
sleepOnStateWithTimeout only resends the interrupt when other contexts are waiting in the queue, and the stuck deadline only fires after a resend. If the sentry interrupts a running context (signal delivery, kill) and that one signal is lost while the queue is empty, nothing resends it and the task goroutine waits indefinitely. google#14201 only covered the busy-queue case. Track a sentry-private interruptRequested flag, set in NotifyInterrupt and cleared with the shared Interrupt flag, and resend while it is set. The shared Interrupt field is not used for this because the stub can write it. A context running with nothing requested still waits as before. Pointed out by Mor Kalfon reading google#14201. Assisted-by: Claude Code Signed-off-by: Nahum Litvin <nahuml@wix.com>
The stuck-context traceback only printed the context ID, so a report showed the sentry side and nothing about the stub that stopped answering. Also log the context's shared state and the stub thread's sysmsg state, error and line, read atomically. Pointed out by Mor Kalfon reading google#14201. Assisted-by: Claude Code Signed-off-by: Nahum Litvin <nahuml@wix.com>
google#14201 kills the subprocess when a context stays stuck through the 30s deadline. A stub in a slow host page fault cannot take the interrupt signal until the fault returns, so it looks stuck while it is healthy. On a host with a slow FUSE server a fault can take more than 30s, and the kill takes down a subprocess that would have recovered. The kill does not help a lost signal either: the resend every checkup is what recovers it. Log the stack dump once when the deadline passes, then keep waiting and resending, with a warning every 30s after that. A stub thread that no longer exists is still killed. sleepOnStateWithTimeout only resends the interrupt when other contexts are waiting in the queue. If the sentry interrupts a running context and that one signal is lost while the queue is empty, nothing resends it. Track a sentry-private interruptRequested flag, set in NotifyInterrupt and cleared with the shared Interrupt flag, and resend while it is set. The shared Interrupt field is not used for this because the stub can write it. The stuck-context traceback only printed the context ID. Also log the context's shared state and the stub thread's sysmsg state, error and line, read atomically. The dispatcher only moves waiting contexts to the slow path after two deep sleep timeouts without processing anything, and any completion resets that timer. Under steady churn a context whose stub stopped answering is polled forever and never reaches sleepOnStateWithTimeout, where interrupts are resent. Hand a context to the slow path once it has waited longer than contextPreemptTimeout (10ms). The empty-queue resend and the stub-state logging were pointed out by Mor Kalfon reading google#14201. Assisted-by: Claude Code Signed-off-by: Nahum Litvin <nahuml@wix.com>
A systrap task can remain in
sleepOnStateforever when its stub stops responding. The existing path sends a single interrupt and logs repeated warnings, but it has no retry and no escape. Container teardown can then block indefinitely inWaitExited, leaving the sandbox and its control RPCs stuck.We hit this in a production Kubernetes cluster. Multiple gVisor pods remained Terminating for days, on both release-20260427.0 and release-20260714.0. In one affected sandbox, both
runsc killandrunsc debug --stackshung while the sentry stayed alive. Systrap[exe]stubs and node load continued to accumulate even though CPU use remained low. Killing the sentry process tree cleared the pod and returned node load to normal immediately. Full forensics in #14408.#14405 is an independent production report of the same hang, with the sentry stacks we could not capture: a task goroutine parked 7,818 minutes in
sleepOnStateon the ThreadContext.State futex after one missed SIGUSR1,Kernel.Pause()blocked behind it so the URPC handler forrunsc kill --allnever returns. On the shim side 45,646 goroutines piled up onInit.mubehind the hung kill, growing containerd and kubelet memory until node OOM.The fix, staged from least to most disruptive:
log.TracebackAll. Stacks are unobtainable from outside a wedged sandbox, so the escalation self-documents what systrap: sleepOnState() never returns when a stub thread is unresponsive, permanently hangs Kernel.Pause() #14405 had to capture by hand.NotifyInterruptalready uses when a stub is gone (mark dead,ContextStateUnexpectedDeath, kill the syscall thread). Healthy subprocesses in the sandbox are unaffected, and the task goroutine returns instead of blockingKernel.Pause()forever.sleepOnStateWithTimeoutreturns a typed error and stays side-effect free;sleepOnStatedoes the traceback and the kill. A stub that is already gone takes the quietNotifyInterruptpath (warn + kill subprocess, no stack dump). Resent interrupts do not extend the stuck deadline; a regression test covers that.Tests:
bazel test //pkg/sentry/platform/systrap:systrap_testbazel build //runsc:runscFixes #14408. Related to #14405 and #12209.
Assisted-by: Claude Code