fix: surface sandbox exit code via proto3 optional exit_code presence - #144
Merged
Conversation
sandbox.returncode was always None: the old guard read a returncode
attribute that never existed on GetSandboxResponse, and mock-based tests
hid the field-name drift. The backend now serves the main-process exit
code as `optional int32 exit_code = 13` (aviato #1490, BSR commit
033ca7a80802).
- Re-vendor proto stubs from buf.build at the new pins (picks up
exit_code plus upstream volume/streaming proto changes).
- _apply_sandbox_info reads the code via HasField("exit_code") presence
for COMPLETED sandboxes observed by polling: 0 is meaningful and "not
reported" is a real state (older gateways, gateway-initiated stops,
containers that never ran), with a getattr fallback for stand-ins
lacking presence tracking.
- Bounded grace re-poll (EXIT_CODE_GRACE_POLLS=2) when a poll observes
COMPLETED without a code: the runner flushes exit codes on a batched
report (~5s cadence), so re-poll briefly before latching the terminal
state, which freezes returncode. The re-poll is strictly best-effort:
skipped for client-initiated stops and once a terminal state latches
concurrently; a failed bonus poll returns the in-hand terminal
response; each bonus poll is a single unretried Get on a short
timeout, never the primary poll's retry envelope; and a waiter whose
deadline expires mid-grace latches the in-hand terminal response
instead of raising a spurious SandboxTimeoutError.
- Tests: real-proto presence coverage (TestApplySandboxInfoRealProto)
so field renames fail loudly; grace re-poll coverage incl. failure,
status-flip, stop-path guards, and deadline-during-grace on both wait
paths; mock helpers primed with per-status presence semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
joseph-shih-wandb
requested review from
a team,
brandonrjacobs and
djenriquez
as code owners
July 29, 2026 18:18
nicholaspun-wandb
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
sandbox.returncodewas alwaysNone: the old guard read areturncodeattribute that never existed onGetSandboxResponse, and mock-based unit tests carried a truthyreturncodeattribute that hid the field-name drift. The backend now serves the main-process exit code asoptional int32 exit_code = 13onGetSandboxResponse(coreweave/aviato#1490, merged; BSR commit033ca7a80802), so this PR wires it through: re-vendored stubs, presence-checked read, and a bounded best-effort grace re-poll for the runner's batched report lag. Fixes SBX-PROD-BUG-009 / e2e SBX-LIFE-003.What changed
scripts/update-protos.sh). Purely mechanical; also picks up unrelated additive upstream proto changes (StartSandboxRequest.volumes, streaming stub-generator format churn). The SDK never sets the new fields, so requests to current backends are unchanged in meaning._exit_code_from_info):HasField("exit_code")distinguishes "exited 0" from "not reported"; getattr fallback for non-proto stand-ins. Read only forsource == "poll"andstatus == COMPLETED, preserving the existing returncode contract._grace_repoll_for_exit_code,EXIT_CODE_GRACE_POLLS=2× 2s): the runner flushes exit codes on a ~5s batched report, so a Get can observe COMPLETED before the code lands; once the poll loop latches the terminal state, returncode is frozen. The re-poll is strictly best-effort:_stop_owned/_is_stopping) and once a terminal state latches concurrently (_is_done, re-checked after the sleep);_get_sandbox_once) on a dedicated short timeout (EXIT_CODE_GRACE_RPC_TIMEOUT_SECONDS=2s) — never the primary poll's 15s/30s retry envelope, never_poll_until_stable's transient-status loop;CWSandboxErrorfrom a bonus poll returns the already-in-hand terminal response instead of failing the wait (debug-logged);_grace_pending_responsefor the duration of the window, and both waiterTimeoutErrorhandlers latch it and resolve through the normal terminal policy — a waiter deadline expiring mid-grace can never convert an already-observed completion intoSandboxTimeoutError. The slot is cleared identity-guarded, so a cancelled sibling grace window cannot wipe the other poll task's published rescue response. The waiter-vs-shared-task double-latch is idempotent via_apply_sandbox_info's terminal guard.TestApplySandboxInfoRealProto) so a field rename fails loudly; grace re-poll coverage including transient-error no-retry (exactly 2 Gets), dedicated-timeout pin, stale-response non-adoption, mid-grace status flip, both stop-guard halves, and deadline-during-grace on both wait paths; mock helpers primed with per-status presence semantics matching the backend contract.Settled design decisions (do not re-raise)
optional; 0 is meaningful, absent is a legitimate permanent state (older gateways, gateway-initiated stops, containers that never ran). Clients must check presence — henceHasFieldwith a documentedreturncode is Nonecontract.wait()resolves,returncodeis settled (the primary consumer doeswait()then readsreturncode). The alternative — latch immediately, backfill returncode on_Terminalafterwards — would retire the residualget_status()-during-grace race and is the recorded follow-up direction if FAILED codes are ever surfaced, but mutating a latched terminal state afterwait()returns changes the "returncode is final after wait" contract and is out of scope here._apply_sandbox_info) is only emitted by backends that predate exit codes, so a grace window for it could never produce one (documented at the gate).SandboxDefaultsknobs: the merge gate above was the kill switch (now satisfied); a config seam for a transitional window was judged not worth the permanent API surface._exit_code_from_infois deliberate defensive compat for test stand-ins; the stubs are vendored in-package so prod skew is impossible — cheap insurance, not a deployment scenario.Known limitations / accepted trade-offs
get_status()during the grace window can latch a query-source terminal state (returncode omitted by contract) and permanently forfeit the exit code. Pre-existing microsecond race widened to the bounded window; retired by the backfill follow-up (decision 4).self._statestill reads RUNNING, so a concurrentstop()can send a Stop RPC for an already-completed sandbox and await_until_complete(raise_on_termination=True)waiter then seesSandboxTerminatedErrorfor a natural completion. Pre-existing race; the stop RPC is benign server-side. Astop()landing mid-grace also waits out the current ≤2s step (guards run at loop boundaries).returncode=Nonepermanently. Constants are deliberately conservative; tune after measuring the real flush-lag distribution.Follow-ups (out of scope, recorded)
_Terminal(retires theget_status()forfeit and the waiter-latch/FAILED-correction divergence; prerequisite for surfacing FAILED exit codes).GetSandboxResponsemessages; pin the "returncode frozen after wait()" contract and post-latch RPC suppression.returncode is Nonewith real-proto tests; cover the ValueError fallback branch.started_at_timetruthiness guard yields epoch-0 instead of None on real protos (pre-existing; fix isHasField("started_at_time")+ real-proto assertions)._SandboxInfoLikeback to fields all real inputs share; type the exit-code read against the poll-path response.update-protos.sh(--require-hashes,--only-binary=:all:).streaming_pb2.pyidropped the mypy-protobuf-documented streaming wire contract still referenced by_sandbox.pyand the CHANGELOG — regenerate with mypy-protobuf or move the contract docs to a durable home.sandbox.returncodeassertions once the gateway deploy lands.Verification
HasFielddistinguishes explicit 0 from unset.🤖 Generated with Claude Code