Skip to content

fix(supervisor): stop recovering a lost boundary after SIGTERM - #5342

Open
FrostGod wants to merge 2 commits into
mainfrom
fix/5025-supervisor-sigterm-exit/FrostGod
Open

FrostGod wants to merge 2 commits into
mainfrom
fix/5025-supervisor-sigterm-exit/FrostGod

Conversation

@FrostGod

@FrostGod FrostGod commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

After SIGTERM, the supervisor kept retrying a lost boundary connection for up to 30s on every shutdown call, so it outlived the runtime's stop timeout and was SIGKILLed. Once shutdown starts, a lost connection is now reported instead of recovered.

Related Issue

Closes #5025

Changes

  • openshell-sandbox-backend: add RunningBoundary::begin_stop(); after it, recover_after_unavailable returns an error instead of retrying reattach.
  • openshell-supervisor: call begin_stop() when SIGTERM wins the lifecycle select.
  • Un-ignore lifecycle::delete_is_prompt_when_workload_is_slow_to_handle_term.

Testing

  • cargo test -p openshell-sandbox-backend -p openshell-supervisor, clippy -D warnings, and fmt pass
  • Unit test added: a stopping client fails within 5s on a lost boundary instead of retrying (times out without the fix)
  • Podman 5.7.0 on Ubuntu 26.04 (same as the rust-podman-ci runner): the un-ignored test passed 24/24 with the fix vs 9/24 on main (15 runs had the supervisor SIGKILLed after 15s); full conformance suite 16/16
  • Docker and Kubernetes conformance lanes (CI, test:e2e)

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)

After SIGTERM the supervisor signals, waits for, and terminates the
boundary. When the boundary connection drops during that sequence, each
call retried reattach for up to 30s, so the supervisor outlived the
runtime's stop timeout and was killed. Once shutdown starts, report a
lost connection instead of recovering it.

Un-ignore the conformance test that covers slow-TERM deletes.

Signed-off-by: divesh <dgude@nvidia.com>
@FrostGod FrostGod added test:e2e Requires end-to-end coverage area:runtime labels Nov 6, 2026
@copy-pr-bot

copy-pr-bot Bot commented Nov 6, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Label test:e2e applied, but pull-request/5342 does not exist yet. A maintainer needs to comment /ok to test 235e738a5dc725d5d421ad32e5d77657ea73df6d to mirror this PR. Once the mirror exists, re-apply the label or re-run Branch E2E Checks from the Actions tab.

@FrostGod

FrostGod commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 235e738

@matthewgrossman matthewgrossman 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.

Thanks for tracking this down. The Podman numbers (24/24 vs 9/24) are convincing for the case where the boundary dies alongside the supervisor. I'm not convinced this fully fixes the supervisor half of #5025 yet, though.

The Docker E2E failure looks like the same bug class. docker-e2e / E2E (rust-docker) failed in ssh_host_identity_survives_restarts_and_changes_after_recreation. Every other StopSandbox in that run took 142-155 ms, including other sandboxes on the same e2e-python image. The one in this test took 5,167 ms. That's Docker's 5s stop timeout on the supervisor container (stop_container(...t(5)) in the Docker driver). Right after, the reconciler moved the sandbox to Error / ContainerExited and sandbox start failed. On Docker the driver stops the supervisor before the workload container, so the boundary is still alive during shutdown and the new stopping path shouldn't be what's slow. In other words, a supervisor still failed to exit within the runtime's stop timeout on this branch. I don't know yet whether main does the same. Could you get a baseline of that test on main?

Overall shutdown deadline. This fixes one way shutdown can hang (retrying reattach to a dead boundary), but nothing bounds the total time. Signal, the 5s wait, terminate, and TerminateBoundary can each still take up to their own timeouts. Would it be cleaner to put one deadline on the whole SIGTERM path, shorter than the runtime grace periods (Docker 5s, Podman stop timeout, K8s 30s), and abandon whatever is left when it expires? That would cover this case, whatever happened on Docker above, and future causes. The stopping flag could stay as a fast path inside it.

Comments inline on the scope of the flag and on how the supervisor handles the resulting error.

Comment thread crates/openshell-supervisor/src/lib.rs Outdated
));
}
() = &mut shutdown_requested => {
running.begin_stop();

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.

Two questions about the scope of this:

  1. shutdown_requested isn't only SIGTERM. For hosted instances in a shared supervisor it also fires on Departure::Stop from SandboxInstanceHost (sandbox_instance_host.rs:440, :685). In that case the supervisor process isn't exiting and the remote boundary is usually still up. Before this change, a transient connection drop during that stop was recovered, and the boundary got a clean TerminateBoundary. Now the instance reports Failed, TerminateBoundary is never sent, and boundary cleanup is left to the driver. Is that intended? If not, should begin_stop() be limited to the dedicated-process signal path, or use a short recovery window rather than none?

  2. The flag is client-wide and never cleared, so once it's set nothing on this client reconnects. That includes the workload's network mediation during the TERM grace window, which is exactly the "slow to handle TERM, running cleanup handlers" scenario the un-ignored conformance test describes. If the connection really does drop in that window, the workload's cleanup traffic fails instead of recovering. Probably rare, but it's a behavior change the PR description doesn't mention.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  1. Not intended. begin_stop() now only runs when a dedicated supervisor gets its own SIGTERM. Shared-mode Departure::Stop is back to recovering and sending TerminateBoundary as before.
  2. Fair point. In dedicated mode the flag still applies during the TERM grace window, so a connection drop there won't reconnect. I think that's acceptable since the process exits within 8s anyway, and I'll call it out in the PR description.

Comment on lines +2221 to +2225
if self.stopping.load(Ordering::Acquire) {
return Err(BackendError::Unavailable(
"boundary connection lost while stopping".to_string(),
));
}

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.

When this fires during the supervisor's shutdown, BoundaryProcess::wait() maps it to Terminated("boundary lost during wait: ..."). Then the .map_err(...)? after the 5s wait in run_sandbox_instance returns Err. The supervisor exits with an error and skips publish_main_exit, the OTLP relay flush, and the gateway exit report.

That's no worse than main, where it got SIGKILLed. But now that we fail fast, should the supervisor treat "boundary lost while stopping" as the workload having ended? It could record an exit status, skip TerminateBoundary, and continue through normal teardown. Otherwise every Podman/K8s delete where the boundary goes first ends with an error-level failure from the supervisor on a routine operation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. A lost boundary (Unavailable or Terminated) while stopping is now treated as the workload having ended, with exit code 143. The supervisor then skips TerminateBoundary and continues through publish_main_exit and the OTLP flush. The 8s deadline expiring is handled the same way, so a routine delete no longer ends in an error.

Stopping a dedicated sandbox now finishes within 8 seconds, including the
exit report and trace flush, while the workload keeps its 5 second TERM
grace. A boundary connection lost while stopping is reported as a
terminated workload instead of failing the shutdown.

Only a dedicated supervisor's own shutdown skips boundary recovery; a
shared supervisor removing one sandbox keeps recovering as before.

The Docker driver gave the supervisor 5 seconds, the same as its wait for
the workload, so stops ended in SIGKILL. It now allows 10 seconds.

Signed-off-by: divesh <dgude@nvidia.com>
@FrostGod

FrostGod commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Baseline on main: the test passes there too (9/9), but every supervisor stop ended in a Docker SIGKILL on both main and this branch (24/24). The default agent is an interactive bash that ignores SIGTERM. The supervisor waits 5s for it, and Docker only gave the supervisor 5s, so the CI failure was that existing race going the wrong way, not something this PR introduced.

Agreed on one deadline. The whole SIGTERM path is now capped at 8s: the agent gets its 5s TERM grace, and the exit report and OTLP flush fit in what's left. I also raised Docker's supervisor stop timeout to 10s so the cap fits under it. Kubernetes (30s) and Podman (45s) already had room. Locally that's 0/17 Docker SIGKILLs, and Docker conformance passes 16/16. The stopping flag stays as the fast path inside the deadline.

@FrostGod

FrostGod commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 91b6ccf

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:runtime test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(sandbox): on Kubernetes, deleting a sandbox waits out the pods' termination grace period

2 participants