Skip to content

fix(opensandbox): report a command that hit its timeout as a timeout - #5157

Merged
gwarmstrong merged 4 commits into
NVIDIA-NeMo:mainfrom
gwarmstrong:georgea/opensandbox-exec-timeout
Oct 29, 2026
Merged

gwarmstrong merged 4 commits into
NVIDIA-NeMo:mainfrom
gwarmstrong:georgea/opensandbox-exec-timeout

Conversation

@gwarmstrong

@gwarmstrong gwarmstrong commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

exec passes timeout_s to execd, which kills the command when it is reached. execd reports that kill like any other signal death: exit code -1 and signal: killed, with no timeout flag, on both the foreground and the background_exec path. The provider passed that through, so a timed-out command came back as exit code -1 with no error_type. Callers that check error_type == "timeout", such as sandbox_session.py and the Terminal-Bench-Science oracle (#4879), saw a failed command instead of a timeout. This provider's own session path (exec_with_background_services) already returns 124 with error_type="timeout", and Docker returns 125 with error_type="timeout".

The provider now returns 124 with error_type="timeout" for a signal death that ran for its whole timeout. It keeps execd's stderr and adds a note: "Command timed out after {timeout_s}s; OpenSandbox execd killed it". The command is timed by execd's own stamps: the init and error events of a foreground run, and started_at and finished_at of a background one. Without them the client times it, from the init event's arrival to the error event's in the foreground, and from the submit that ran to the status that showed it finished in the background. Submit retries, connection waits, request latency and log reads therefore never count. The allowance for execd's timer starting a moment before its stamps is 0.25 s or a tenth of the timeout, whichever is smaller; on a live cell a kill at the deadline read at most 1 ms short.

The elapsed time is the only way to tell the two cases apart. A command that SIGKILLs itself one second in returns exactly the same exit code and message as one killed at its timeout. So a command killed before its deadline, for example by the OOM killer, keeps exit code -1, and so does a signal death when no timeout was set (0 enforces none). Exit codes other than -1 are unchanged.

No issue: found while validating #4879's oracle fix; the repro is below.

Validation

  • tests/unit_tests/test_opensandbox_provider.py: 187 passed. The foreground cases replay execd's stamps from live runs (3, 0.5 and 0.2 s timeouts), each with and without a 0.35 s wait before execd starts the command, as when the transport waits out a 429. In the background, two slow 502 submits and a slow log read leave an immediate kill at -1, and a command without stamps that is still running at its deadline is a timeout. Further cases cover the allowance: 1 ms under a 0.1 s timeout stays -1, 29.8 s under 30 s is a timeout. test_sandbox.py, test_opensandbox_compose.py and test_sandbox_session.py pass; their fakes of commands.run now accept the handlers the provider passes.

  • Live on an OpenSandbox cell, with a 3 s timeout_s:

    Command background_exec main This PR
    sleep 30 on -1, error_type None 124, timeout
    sleep 30 off -1, error_type None 124, timeout
    sleep 1; kill -9 $$ on and off -1 -1
    exit 7 on and off 7 7
  • Live at short timeouts, after the bot's review (2026-10-28). The previous head (0f9053e) reported a command that killed itself at once as a timeout; execd's stamps put its run at 0 to 1 ms:

    kill -9 $$ timeout_s 0f9053e This PR
    foreground 0.5 124, timeout -1
    foreground 0.2 124, timeout -1
    background 0.2 124, timeout -1

    sleep 30 at the same timeouts stays 124, timeout on both paths (execd's stamps: 499 and 201 ms in the foreground, 500.1 and 200.1 ms in the background), and the 3 s rows above are unchanged.

Rollouts

N/A (provider error handling).

Compatibility and benchmark impact

No settings change. A command killed at its timeout_s now returns 124 with error_type="timeout" instead of -1, the same code as the session path. Callers that treat any error_type as a failed command still count it as failed, and callers that check for timeouts now see them.

execd kills a command that reaches the timeout the provider passes it and reports the kill like any signal death: exit code -1 and "signal: killed", with no timeout flag. exec therefore returned -1 with no error_type, so callers that look for error_type="timeout" (sandbox_session.py, the Terminal-Bench-Science oracle) saw a failed command instead. Docker and the provider's own session path already report rc 124 with error_type="timeout".

A signal death now counts as a timeout when the command ran for its whole timeout: by execd's started_at/finished_at on the background path, and by the client's clock for the attempt that ran on the foreground path, whose execution carries no start time. A command killed before its deadline, e.g. by the OOM killer, keeps -1.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 27, 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.

@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test 0f9053e

@nemo-automation-bot
nemo-automation-bot Bot requested review from a team October 27, 2026 22:10
@svcnvidia-nemo-ci svcnvidia-nemo-ci added area:sandbox Sandbox providers, isolation, and sandbox runtime contracts bug Something isn't working needs-review PR is ready for code review and waiting on a reviewer labels Oct 27, 2026
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

@svcnvidia-nemo-ci svcnvidia-nemo-ci left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SHIP WITH CARE — three RISK findings in timeout classification.

Reviewed all three changed files and traced submission retries, transport behavior, result consumers, and neighboring timeout paths. The findings concern early signal deaths being incorrectly reported as timeouts.

Static inspection only; tests were not executed. The consumer/documentation topic was checked directly after its delegated report was unavailable.

Review generated by codex. This was a full review. Request a full review with /review reset=true.

if note is not None:
stderr_parts.append(note)
# The execution carries no start time, so the client's clock times it.
if _killed_at_timeout(return_code, loop.time() - started, timeout_s):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RISK — foreground request latency is not command runtime

Failure: With timeout_s=3, a buffered submission can receive HTTP 429 with Retry-After: 3, then successfully submit a command that immediately kills itself. The default transport sleeps and retries internally, without re-entering run_command() to reset started. This branch therefore observes at least three seconds and changes the immediate signal death from (-1, None) to (124, "timeout"). Connection-pool waiting likewise contributes to this clock. The expected classification depends on time the command ran, not time awaiting submission. This affects foreground executions encountering submission delays; consumers such as SandboxSession.execute distinguish these outcomes.

Correction: Could the foreground branch obtain execution-side duration/status, or separate pre-execution waits and post-exit response draining before invoking _killed_at_timeout? Resetting the provider-level factory alone cannot exclude retries inside the transport. Preserve transport retries and their existing submission eligibility. The mounted tree does not contain the SDK implementation, so the exact lifecycle-timing integration needs verification rather than a speculative patch. The invariant is that unrelated request latency cannot establish that execd reached the command deadline.

Acceptance — proposed, not executed: Add a foreground case with a three-second pre-execution 429 retry followed by an immediate signal death; expect return_code=-1, error_type=None, and unchanged partial output/error text. The same retry followed by a command genuinely running to its three-second deadline must return 124/timeout. Retain the existing foreground assertions: deadline kill becomes timeout, early kill stays -1, exit 7 stays 7, unset timeout stays unclassified, and partial stdout/stderr survive. Also preserve zero-timeout behavior and recovery after provider-level 502 retries.

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.

Fixed in 8101c4c. The foreground path no longer times from the submit. It passes ExecutionHandlers and times the command from execd's init stamp to its error stamp (ms, both execd's). Without the stamps it uses the times those two events reached the client, so a transport retry or pool wait before init never counts. On a live cell, init to error was 2999 ms for sleep 30 under a 3 s timeout and 0 ms for kill -9 $$.

Tests: each live case runs with and without a 0.35 s wait inside commands.run before init (the 429 Retry-After case). An immediate kill stays (-1, None) with its output and error text, and a run to the deadline is 124/timeout. The existing foreground assertions, zero and unset timeouts, and the 502 retry tests pass.

if started_at is not None and finished_at is not None:
elapsed_s = (finished_at - started_at).total_seconds()
else:
elapsed_s = loop.time() - submitted

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RISK — exclude known non-execution work from the background fallback

Failure: When either server timestamp is absent, this clock includes log retrieval after the process is already known to have finished. For example, use background_exec=True, timeout_s=3, and request_timeout_s=5; return an immediate terminal status with exit_code=-1, then delay the logs response by three seconds. The new branch returns 124/timeout although the process died immediately; it should retain -1 with no error type. It also includes 502 submission backoffs, because submitted is initialized outside the retried factory. These are false timeout classifications in the explicitly supported missing-timestamp fallback.

Correction: Could _exec_background reset the start for each provider-level submission attempt and freeze the terminal-status observation before reading logs? A suggested patch for these known extraneous intervals is:

         submitted = loop.time()
+
+        def run_command() -> Awaitable[Any]:
+            nonlocal submitted
+            submitted = loop.time()
+            return handle.raw.commands.run(command, opts=RunCommandOpts(**background_opts))
+
         execution = await self._submit_command(
-            lambda: handle.raw.commands.run(command, opts=RunCommandOpts(**background_opts)),
+            run_command,

At the terminal-status branch:

             if not status.running:
+                observed_finished = loop.time()
                 break

Then replace the fallback with:

elapsed_s = observed_finished - submitted

This restores the invariant that known pre-submission retry waits and post-completion log work cannot trigger classification. Server timestamps must remain authoritative; local submission/poll timing remains an estimate. Please verify the suggestion before adoption.

Acceptance — proposed, not executed: An immediate timestamp-less -1 status followed by delayed or retried logs must remain (-1, None) with original output/diagnostics. Three backend-connect 502s followed by an immediate killed command under a one-second timeout must also remain (-1, None). A timestamp-less execution observed running through its deadline must still return 124/timeout. Retain the timestamped cases: deadline kill → timeout, early kill → -1, ordinary exit → 7, zero timeout → unclassified, with stdout="partial output". Preserve bounded read retries and deleted-output diagnostics without resubmitting the command.

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.

Applied in 8101c4c as suggested: submitted resets in the submit factory, and the fallback stops at the status read that showed the command finished, before the logs are read. execd's started_at and finished_at stay authoritative.

Tests, without timestamps: two 0.15 s 502 submits and a 0.3 s log read leave an immediate kill at (-1, None), with partial output and signal: killed (it was 124/timeout before). A command still running at its 0.25 s deadline is 124/timeout. The timestamped cases are unchanged.

bool(timeout_s)
and return_code == -1
and elapsed_s is not None
and elapsed_s >= float(timeout_s) - EXEC_TIMEOUT_SLACK_S

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RISK — fixed slack consumes short deadlines entirely

Failure: _killed_at_timeout(-1, 0.001, 0.1) returns true because the threshold is 0.1 - 0.25 = -0.15. Consequently, every nonnegative-duration signal death under a deadline of at most 250 ms is labeled 124/timeout, including a process killed after only 1 ms. Expected: this far-before-deadline failure remains (-1, None). Fractional timeouts are accepted by the public provider contract, and both execution paths use this predicate.

Correction: Could the allowance be bounded relative to a positive timeout so it cannot consume the whole deadline? The invariant is that near-immediate signal deaths remain ordinary failures even for short supported timeouts. Simply clamping the threshold to zero does not fix this: all nonnegative durations would still match. The precise fractional allowance needs validation against execd's timestamp skew, so no arbitrary replacement constant is proposed.

Preservation and acceptance — proposed, not executed: Add deterministic public-path cases for timeout_s=0.1: a -1 result after 0.001 seconds must retain (-1, None) and its original output/diagnostics, while a genuine 0.1-second deadline kill must return (124, "timeout"). Retain the long-deadline allowance: a 30-second deadline measured at 29.8 seconds should still classify as timeout, while a one-second kill under that deadline stays -1. Preserve the existing background and foreground assertions that ordinary exit 7 is unchanged and zero/None timeouts remain unclassified.

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.

Fixed in 8101c4c: the allowance is 0.25 s or a tenth of the timeout, whichever is smaller. On a live cell, a kill at the deadline read at most 1 ms short in execd's stamps (2999 ms of 3 s, 499 of 0.5 s and 201 of 0.2 s in the foreground; 3000.7, 500.1 and 200.1 ms in the background). Before the fix, kill -9 $$ under a 0.5 s or 0.2 s timeout came back 124/timeout live; it now returns -1 on both paths.

Tests: 1 ms under a 0.1 s timeout stays -1; 0.1 s and 0.095 s are timeouts; under 30 s, 29.8 s is a timeout while 29.7 s and 1 s are not.

Foreground commands are timed from execd's init event to its error event, so submit retries, pool waits and request latency no longer count. Without timestamps, the background path times the submit that ran up to the status that showed the command finished. The timeout allowance is at most a tenth of the timeout, so an early death under a short timeout keeps exit code -1.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test 8101c4c

@gwarmstrong
gwarmstrong added this pull request to the merge queue Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40971943120

Merged via the queue into NVIDIA-NeMo:main with commit 5c86a65 Oct 29, 2026
127 of 145 checks passed
@gwarmstrong
gwarmstrong deleted the georgea/opensandbox-exec-timeout branch October 29, 2026 23:53

This branch was previously deployed

1 inactive deployment
public — 8101c4c8 Deployed Oct 28, 2026 by copy-pr-bot[bot] via release / finalize / notify #7996
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:sandbox Sandbox providers, isolation, and sandbox runtime contracts bug Something isn't working needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants