Skip to content

systrap: fail-stop sentry on stuck context - #14201

Merged
copybara-service[bot] merged 1 commit into
google:masterfrom
NahumLitvin:fix/systrap-stuck-context
Aug 31, 2026
Merged

copybara-service[bot] merged 1 commit into
google:masterfrom
NahumLitvin:fix/systrap-stuck-context

Conversation

@NahumLitvin

@NahumLitvin NahumLitvin commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

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 systrap: sleepOnState() never returns when a stub thread is unresponsive, permanently hangs Kernel.Pause() #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 systrap: sleepOnState() never returns when a stub thread is unresponsive, permanently hangs Kernel.Pause() #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

@NahumLitvin

NahumLitvin commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor Author

@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

@NahumLitvin

Copy link
Copy Markdown
Contributor Author

Filed #14408 with the full production forensics for the freeze this PR addresses (two releases affected, node-side signature, why stacks are unobtainable).

@tanyifeng

tanyifeng commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Similar to issue #14405 , Sentry hang causes containerd and kubelet memory to grow unbounded, eventually the whole node OOM.

@NahumLitvin

Copy link
Copy Markdown
Contributor Author

@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

@NahumLitvin
NahumLitvin requested a review from nixprime as a code owner August 26, 2026 05:47
@tanyifeng

Copy link
Copy Markdown
Contributor

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 Stats and Kill in the shim to prevent unbounded goroutine and memory growth?

We have enabled --panic-signal=12 by default in our environment. If this happens again, we can send the configured signal to the sentry to trigger a panic and capture the full stack dump, even if runsc debug --stacks is unavailable.

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?

@NahumLitvin

Copy link
Copy Markdown
Contributor Author

@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?

@tanyifeng

Copy link
Copy Markdown
Contributor

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrote this before my req to change the KillItself, but point still stands, remove the callbacks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. sleepOnStateWithTimeout now returns errStuckContext and sleepOnState does the traceback + kill, no callbacks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, callbacks removed.

Comment on lines +262 to +264
// 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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Useless comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, removed.

if interruptsSent > 0 {
return failStop()
}
log.Warningf("Systrap task goroutine has been waiting on ThreadContext.State futex too long. ThreadContext: %v", sc)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log is basically dead code after this change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, removed.

Comment on lines -252 to -254
if errno == 0 {
continue
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lost in refactoring? This continue should still be here in case we got an interrupt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return an error, do a TracebackAll in sleepOnStateWithTimeout in case we see it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return an error, do a TracebackAll in sleepOnStateWithTimeout in case we see it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, same as above.

timeout := unix.Timespec{
Sec: 0,
Nsec: contextPreemptTimeoutNsec,
return sc.sleepOnStateWithTimeout(state, stuckContextTimeout, contextCheckupTimeout, sighandling.KillItself)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +292 to +295
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review comments, we don't need to redescribe the code in comment form.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@NahumLitvin

Copy link
Copy Markdown
Contributor Author

@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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to use this variable to exit early. If we didn't see a stub thread once, we will not see it again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 konstantin-s-bogom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@NahumLitvin
NahumLitvin force-pushed the fix/systrap-stuck-context branch from 91fc347 to d119f5c Compare August 31, 2026 18:52
@NahumLitvin

Copy link
Copy Markdown
Contributor Author

@konstantin-s-bogom squashed to a single commit, thanks for the reviews

copybara-service Bot pushed a commit that referenced this pull request Aug 31, 2026
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
copybara-service Bot pushed a commit that referenced this pull request Aug 31, 2026
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
@copybara-service
copybara-service Bot merged commit b0adda2 into google:master Aug 31, 2026
8 checks passed
copybara-service Bot pushed a commit that referenced this pull request Sep 24, 2026
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
copybara-service Bot pushed a commit that referenced this pull request Sep 24, 2026
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
copybara-service Bot pushed a commit that referenced this pull request Sep 24, 2026
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
copybara-service Bot pushed a commit that referenced this pull request Sep 24, 2026
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
copybara-service Bot pushed a commit that referenced this pull request Sep 24, 2026
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
NahumLitvin added a commit to NahumLitvin/gvisor that referenced this pull request Sep 30, 2026
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>
NahumLitvin added a commit to NahumLitvin/gvisor that referenced this pull request Sep 30, 2026
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>
NahumLitvin added a commit to NahumLitvin/gvisor that referenced this pull request Sep 30, 2026
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>
NahumLitvin added a commit to NahumLitvin/gvisor that referenced this pull request Sep 30, 2026
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>
NahumLitvin added a commit to NahumLitvin/gvisor that referenced this pull request Oct 6, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

systrap: sentry unresponsive with stuck contexts; pod wedged in Terminating, only SIGKILL of sentry tree recovers

3 participants