feat(sandbox): record why a container stopped - #138
Conversation
A run whose container dies reports `ExecFailed: exec failed (exit -1): internal error` and nothing at all about the container. Every explanation offered for that class has now been measured and ruled out on the consumer that motivated it — peak memory 3.5 GiB of 11.9, disk 3.6 GB used of 12 free, and death times (137s, 647s, 1284s) shorter than the successes (2128s, 2176s). Nothing is scarce and no duration is safe, which leaves a question only the platform can answer. The platform does answer it, one layer below where anyone was looking. `@cloudflare/containers` parses the runtime's own message — `runtime signalled the container to exit: <n>`, or `container exited with unexpected exit code: <n>` — into the `exitCode` it hands `onStop`. A container something killed carries a signal there; one that ran to completion carries 0. Nothing in this repo was reading it. So the three DO classes override `onStop` and persist the number under `container-stops/<sandbox>/<ts>.json`, logged as well as written. `reason` is NOT the discriminator, despite its type. `StopParams.reason` is declared `'exit' | 'runtime_signal'`, but `runtime_signal` appears nowhere in `@cloudflare/containers@0.3.7` outside that declaration — both `callOnStop` sites pass `'exit'`. Read `exitCode`. The write is best-effort and logged: it runs while the container is going away, so a failure must not replace a stop we can explain with one we cannot — but a silent no-op would be indistinguishable from a deploy where the record never worked. The record function lives in its own module, free of the Sandbox SDK import, so it is testable outside the workers pool. UNVERIFIED until a real container dies: whether `ctx.id.name` is populated, and whether a killed container reports 137 or the bare signal. The first stop answers both; nothing here depends on which.
There was a problem hiding this comment.
AI code review — 💬 Comment
Risk tier: full · 0 critical · 1 warnings · 0 suggestions
Reviewers: security
1. ⚠️ Warning — Timeout does not cancel the underlying R2 upload
📍 apps/dispatcher/src/container-stop.ts:174-181
'Promise.race' stops waiting after 2 seconds but does not abort 'bucket.put'. When R2 is slow or unavailable, each stop can leave an in-flight upload running while subsequent stop callbacks start more uploads, so the timeout does not actually bound network, CPU, or memory consumption. Use an abortable request/signal if supported, or otherwise avoid spawning uncancellable uploads after the timeout.
|
Validated. The observation is correct and the suggested remedy is not available on this API.
What the timeout is for is preserved: it bounds how long |
Problem & Insight
A run whose container dies reports this, and nothing else:
Every explanation offered for that class has now been measured and ruled out on the consumer that motivated it. With the per-stage probe running: peak memory 3.5 GiB of 11.9, disk 3.6 GB used against 8.4 GB free, and death times (137s, 647s, 1284s) shorter than the successes (2128s, 2176s). Nothing is scarce and no duration is safe.
That leaves a question only the platform can answer — and the platform has been answering it all along, one layer below where anyone was looking.
@cloudflare/containersparses the runtime's own message —runtime signalled the container to exit: <n>, orcontainer exited with unexpected exit code: <n>— into theexitCodeit handsonStop. A container something killed carries a signal there; one that ran to completion carries 0. Nothing in this repo read it.Take
The three sandbox DO classes override
onStopand persist{sandbox, exitCode, reason, requested, observedAt}tocontainer-stops/<sandbox>/<ts>.json, logged as well as written.Three things about the SDK decide the shape, and each is a trap:
reasonis not the discriminator, despite its type.StopParams.reasonis declared'exit' | 'runtime_signal', butruntime_signalappears nowhere in@cloudflare/containers@0.3.7outside that declaration — bothcallOnStopsites pass'exit'. ReadexitCode.0is not proof of a clean exit.syncPendingStoppedEventshardcodesexitCode: 0when the container is gone but the DO state still readshealthy(container.js:1596). That is a value the SDK invented, and it is the shape an unexplained death takes. An earlier revision of this change filtered onexitCode !== 0to bound bucket growth; it would have discarded exactly the records this exists to produce.getExitCodeFromErrorcannot parse the runtime's message it callssetStopped(), a status matching neither branch ofsyncPendingStoppedEvents, socallOnStopis never reached and nothing is written.So the filter is whether we asked.
workflow.tsdestroys every run through anEffect.ensuring, and those teardowns are the volume; adestroy()override sets an intent thatonStopconsumes. Read-and-clear rather than set-once, becausedestroy()does not reachonStopinline — the alarm loop delivers it later — and a flag left set would swallow every subsequent stop that instance saw, including a genuine death after adestroy()that threw.An idle-timeout stop is correctly recorded as unrequested and is not routine volume:
onActivityExpiredreturns beforestop()unless the container is still running (container.js:748), and finalize has normally already destroyed it. So it fires only where finalize was skipped — Worker eviction, deploy mid-run — which is worth seeing.super.onStop()runs first, sincecallOnStopawaits the override before writing the DO's stopped state; the R2 put is bounded at 2s so a slow bucket cannot leave that state readinghealthywhile the container is gone. Failure degrades to a logged no-op — silent, it would be indistinguishable from a deploy where the record never worked.Reading the data
Keys are addressable forward only. The sandbox name is
previewSafeSandboxId(executionId), which truncates tohead18-sha12-digest8above 40 chars and is not invertible — a holder of an execution id can compute the prefix, the reverse does not work.requested: trueis reliable;falseis best-effort. A DO evicted betweendestroy()and the alarm reports a teardown we asked for as one we did not. It fails toward recording more, never toward hiding a death.observedAtis when the stop was seen, not when the container died —onStoparrives from the alarm loop, which can be a tick later than the event.Operational
CONTAINER_STOP_RECORDS: "off"invarsstops the durable writes; logging is unaffected. It does not avoid a deploy — what it buys is a reviewed one-line config change instead of a code revert.container-stops/. Volume is bounded by the filter — a healthy run writes nothing — but that is a bound, not an expiry. The lifecycle command is in the module header; set it before leaving this on indefinitely.onStopand records nothing, so this must be ported beforeSUBSTRATE_BACKENDflips to"on".Key actions
takeRequesteda plain read fails the test that pins itpnpm typecheckclean,pnpm lintclean,pnpm test174 files / 2255 passed / 1 skippedctx.id.nameis populated, and whether a kill reports137or a bare signal. Nothing in the code branches on either — it records what arrives, and the first stop settles both.