fix(runner): release a container without needing one (DEV-2556) - #212
Open
demtario wants to merge 2 commits into
Open
fix(runner): release a container without needing one (DEV-2556)#212demtario wants to merge 2 commits into
demtario wants to merge 2 commits into
Conversation
Sentry DEMOS-1 caught two `DELETE /api/session/:id` calls, 328 ms apart,
throwing "Maximum number of running container instances exceeded" out of
`sandbox.destroy()` and into a 500. The ticket's premise that each failed
DELETE leaks a session is backwards: an instance that cannot be allocated
is not occupying one of the five slots, so a refused teardown is a symptom
of a full pool and never a contributor to one. Nothing leaked — and nobody
read the 500 either, since `deleteSession()` is a fire-and-forget
`keepalive` fetch from `pagehide` that discards the response.
What was actually wrong is an asymmetry: that route held the only
`destroy()` in index.ts that was not best-effort, while both of its
siblings already swallow. So a platform hiccup on the one path whose job
is release became a thrown request.
- Recognised platform refusals ("maximum number of running container
instances exceeded", "container service is unreachable", "container is
not running") become a log line and a 204. Anything unrecognised still
throws to the outer catch and keeps today's status and today's Sentry
event, so a real teardown regression stays visible. The predicate is a
message match against strings workerd raises, so a Cloudflare rewording
degrades to today's behaviour — noisy, never silent.
- The tombstone gains a second state. It already existed before the
sandbox RPC, but written by that same request, so the obvious "skip when
tombstoned" rule would skip a first DELETE's own teardown and would
never retry one whose destroy had just failed. `"1"` (unchanged on the
wire, so a rolling deploy and legacy markers keep working) now means
"teardown attempted"; `"destroyed"` is written only after `destroy()`
resolves, and only that value lets a repeat DELETE answer from KV
instead of booting a container in order to destroy it.
`closedWhileCreating()` deliberately never skips — its premise is that
the create built a second container after the DELETE — but it does
record the confirmation, which is what makes the client's follow-up
DELETE on the create/delete race free.
- The create half: a capacity failure on `POST /api/session` handed the
visitor the platform's own words ("try configuring a higher value for
max_instances") inside a 500. It is now a 503 `{ error: "at_capacity" }`
with a sentence written for the person reading it, passed through
unwrapped by `sessionStartMessage` like the budget refusals — and
phrased to miss the App.tsx connectivity heuristic that would otherwise
tell a production visitor to install Docker (DEMOS-9).
Deliberately not here: `max_instances` stays at 5 (a spend decision, and
two capacity events in 90 days is not yet evidence for it); no absolute
session lifetime for a visible-but-abandoned tab; no retry policy for a
refused anonymous visitor; and no timed retry of a failed destroy, which
would re-create the dependency on the resource being released — the
tombstone plus `sleepAfter = 5m` is already the backstop.
Both decisions live in a new import-free `session-lifecycle.ts` (erasable
syntax only) because index.ts is a Worker entrypoint that cannot be loaded
under `--experimental-strip-types`; `pipeline/session-lifecycle.test.mjs`
is therefore the only seam either is testable through. It pins the
regression the naive fix would have introduced — an attempted marker must
not skip the RPC — and the degrade direction of both classifiers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up on the DEV-2556 teardown fix. The swallow is right -- a 204 is the correct answer to a fire-and-forget `keepalive` fetch from `pagehide` that discards the response -- but it is only defensible if the failure moves somewhere durable, and it did not. The change replaced the thrown 500 (and its Sentry event) with a `console.warn`. `observability.head_sampling_rate` is 0.1 in workers/api/wrangler.jsonc, so nine of ten of those lines are never retained. At two capacity events in 90 days the expected number of surviving log lines is a fraction of one: the signal the swallow was traded for would not have existed. A declined teardown now also files a `warning`-level Sentry event under its own `tier2-teardown-declined` fingerprint. Fingerprinted for the reason the preview-boot capture is: the outer catch reports bare and this project groups on the culprit `Object.fetch(index)`, so an unfingerprinted event lands straight back in the DEMOS-1 grab-bag. `beforeSend` (rehomeBudgetAlert) only re-homes `context: budget-alert` and drops nothing, so a warning arrives. This matters most for `container service is unreachable`, the weakest member of `isExpectedTeardownFailure`: unlike the other two it does NOT imply no slot is held, so a swallowed one can leave a container billing until sleepAfter. Same defect on the create side, client-side. `reportRuntimeError` in apps/authoring/src/App.tsx fingerprints on `["tier2-session-start", status]`, so the new `at_capacity` 503 -- named in the Worker comment as the surviving capacity evidence -- shared one issue with an envelope-less platform 503, same status and an unrelated cause. The server's machine-readable code now joins the fingerprint when it sent one. Appended rather than substituted, so uncoded failures keep the fingerprint they have today and no live issue regroups. Verified: pnpm build, pnpm typecheck (4/4 projects), pnpm test (584/584), pnpm --filter @handsontable/demo-authoring build, and playwright on an own --strictPort port 4291 via E2E_BASE_URL (166 expected, 0 unexpected, 0 flaky). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The root cause, inverted
The ticket's headline claim is backwards, and correcting it is most of the change. An instance the platform refuses to allocate is not occupying one of the five slots, so a failed
DELETE /api/session/:idcannot leak a session — a refused teardown is a symptom of a full pool and never a contributor to one. Nothing leaked in the Sentry events behind this ticket.The real defect was an asymmetry.
await sandbox.destroy()in theDELETE /api/session/:idhandler (runner/workers/api/src/index.ts:876before this change) was the only unguarded destroy in the file, while both siblings —closedWhileCreatingatindex.ts:635and the closed-tier teardown atindex.ts:494— already swallowed. So a platform hiccup on the one path whose entire job is release became a thrown 500 that no caller could read:deleteSession()inrunner/packages/runtime/src/container.ts:434isvoid fetch(…, { keepalive: true }).catch(() => {}), fired frompagehide. The browser discards the response and the e2e helper ignores the status, so the 500 bought a Sentry event and nothing else.What changed
A declined teardown is now classified rather than blanket-caught.
runner/workers/api/src/session-lifecycle.tsholds the two decisions —isExpectedTeardownFailure(three recognised platform messages) andisAtCapacityFailure(only the capacity message) — in a module free of Cloudflare imports and written in erasable syntax, sopipeline/can import the.tsdirectly under--experimental-strip-types.index.tsis a Worker entrypoint and cannot be imported that way at all, which is why both decisions are lifted out of it: this module is the only seam either one can be tested through. Anything the predicate does not recognise still rethrows and keeps today's status and today's Sentry event, so a Cloudflare rewording degrades to noisy, never to silent.The tombstone marker gained a second state.
session-tombstone:<id>now carries"1"(a teardown was attempted, outcome unknown) or"destroyed"(we watcheddestroy()resolve), and only the second lets a later DELETE answer from KV without a sandbox RPC. The"1"literal is kept byte-identical so a rolling deploy and any legacy marker still in KV keep working. The confirmation is written only afterdestroy()resolves, so the skip can never be reached for a teardown that did not complete.On the create side, a capacity failure on
POST /api/sessionnow answers 503{ error: "at_capacity", message }instead of letting the platform's own words — "…try configuring a higher value for max_instances", an instruction to us — reach a visitor inside a 500.sessionStartMessageincontainer.ts:214passes that code through unwrapped like thebudget_*refusals, and the sentence is checked against thedescribeRuntimeErroralternation inapps/authoring/src/App.tsx:130(/failed to fetch|networkerror|load failed|session start failed|fetch/i) so a production visitor is not told to install Docker and run a local worker.The review finding: the swallow had traded its own signal away
The thing worth being sceptical about on this ticket is whether the change fixes the fault or merely stops the symptom being visible. The honest answer is a split, and the review caught one half of it failing.
The teardown swallow is symptom suppression, and it is the right call — 204 is the correct answer to a caller that discards the response. But suppression is only defensible if the signal moves somewhere durable, and as originally written it did not: the thrown 500 and its Sentry event were replaced by a
console.warn.observability.head_sampling_rateis0.1inrunner/workers/api/wrangler.jsonc:14, so nine of ten of those lines are never retained. At two capacity events in 90 days the expected number of surviving log lines is a fraction of one — the signal the swallow was traded for would effectively not have existed. A declined teardown now also files awarning-level Sentry event under its owntier2-teardown-declinedfingerprint (index.ts), alongside the log line rather than instead of it. The fingerprint is load-bearing: the outer catch atindex.ts:1699reports bare and this project groups on the culpritObject.fetch(index), so an unfingerprinted event would land straight back in the DEMOS-1 grab-bag it is meant to be readable apart from.beforeSend(rehomeBudgetAlert) only re-homescontext: "budget-alert"and never returns null, so a warning arrives; taggedcontext: "tier2-teardown"it stays in the main environment rather than being routed tobudget-alerts, which is right — a declined teardown is not a spend alert.This matters most for
container service is unreachable, the weakest member ofisExpectedTeardownFailure. Unlike the other two it does not imply that no slot is held: a container may well be running and billing and the teardown simply could not reach it. That is bounded by thesleepAfterbackstop and is exactly today's outcome (a 500 nobody retries either), so it is not a regression — but it is the case a reviewer should expect to see turning up in the new issue.The same defect appeared once more, client-side.
reportRuntimeErrorinapps/authoring/src/App.tsx:205fingerprinted on["tier2-session-start", status], so the newat_capacity503 — named in the Worker comment as the surviving create-side capacity evidence — shared a single Sentry issue with an envelope-less platform 503 ("The sandbox service is unavailable right now"), same status and a completely unrelated cause. The server's machine-readable code now joins the fingerprint when it sent one. It is appended rather than substituted, so uncoded failures keep the exact fingerprint they have today and no live issue regroups.What the skip actually covers
The
destroyConfirmedskip is a genuine fix, but narrower than "release a container without needing one" implies, and it is worth saying plainly. ThepagehideDELETE is sent once, so the skip is only reachable on the create/delete-race arms —container.ts:434(a failed create tears down by the local id) andcontainer.ts:444(adispose()that raced container creation sends a second DELETE). The two events in the Sentry group were DELETEs for two different session ids, 328 ms apart, so they never touched this path at all. What the skip buys is that the race arms stop asking for a container slot in order to destroy something that is not there.Capacity numbers
No limit was raised:
max_instances: 5atrunner/workers/api/wrangler.jsonc:149is untouched. Two real capacity events in 90 days, 328 ms apart on 2026-08-07 14:00:24, both DELETEs. NoPOST /api/sessionhas produced this message in that window — and since this project groups on the culpritObject.fetch(index), a create that hit the cap would have landed in the same group, so on the evidence only the teardown path has surfaced the cap. The pool genuinely was saturated then (three different visitors gotsession start failed (504)at 13:56, and a preview reported "container is not listening" at 13:59). Two events in 90 days does not yet justify paying for more instances; the honest 503 and the new fingerprinted issue are what will tell us if that changes.Sentry issue DEMOS-1
Do not close this with
Fixes DEMOS-1. That group is a grab-bag, not four capacity events: it also holds a 2026-07-27 "TEMP-VERIFY sentry smoke error" fired from a localwrangler devrun (itslocationis a.wrangler/tmppath on a developer machine) and an unrelated "container service is unreachable" event. Closing it by keyword would resolve those too. It should be resolved manually after the deploy. The underlying cause — every unexpected throw from the API Worker's fetch handler collapsing into one issue, because the outer catch atindex.ts:1699captures bare — is worth its own change;sentry-gate.tsalready owns thebeforeSendseam afingerprintwould go through.ClickUp
https://app.clickup.com/t/86cb68q78 (DEV-2556).
Verified
pnpm buildthenpnpm typecheck— clean across all four projects (packages/runtime,workers/api,packages/editor-shell,apps/authoring).pnpm test— 584/584,# fail 0in the raw TAP.pnpm --filter @handsontable/demo-authoring build, then playwright against a preview on my own--strictPortport 4291 viaE2E_BASE_URL(the config hardcodes 4173 withreuseExistingServer, which silently tests whatever another worktree left running): the bind was confirmed withlsof, the served bundle hash was matched against the build output, and the run was 166 expected / 0 unexpected / 0 flaky. Server confirmed dead afterwards; 4173 never bound. All commands run raw, not through anrtkfilter.One thing is deliberately untested and should not be read as covered by the 584: the App.tsx fingerprint itself has no test, because
App.tsxis not importable frompipeline/and building a harness for a two-line Sentry grouping change is not worth it. What is pinned is the fingerprint's only input —session-start-failure.test.mjsassertserr.code === "at_capacity"survives the round trip, andsession-lifecycle.test.mjspins the sentence, the envelope code, and that the sentence trips none of thedescribeRuntimeErroralternation.Found and deliberately not fixed
DELETE /api/session/<invented-id>runs an unauthenticated, un-budget-gatedsandbox.destroy(), and that RPC is itself slot-consuming — the resurrection gate atindex.ts:775coversparts.length >= 4only, and the session DELETE is length 3 and explicitly outside it. This is the ticket's own headline sentence applied to the one case where it is fully avoidable, so it is tempting. The obvious gate — skip the RPC unless a meter or a tombstone exists — introduces a worse leak than it removes: a newly written meter key is not guaranteed visible in another colo for up to 60 seconds, and a successful read of an unpropagated key returnsnullrather than an error, sohasSessionMeter's fail-open on KV errors (budget.ts:249) does not cover it. A tab closing within the first minute of its session after an anycast reroute would have its real destroy skipped and leak a slot for the fullsleepAfter. That is a new leak from a fix whose whole purpose is not leaking slots, against a DoS surface that has produced zero observed events. It wants its own ticket.Absolute session lifetime, recorded and not implemented. A visible-but-abandoned tab holds a container indefinitely: the client pings
/statusevery 60s whilevisibilityState !== "hidden", each ping is the meter's tick and keeps the container awake,sleepAfteris 5m and there is no absolute cap. One open-and-forgotten tab occupies 1 of 5 slots for as long as the machine is awake, and five of them are a permanently full pool with nobody using it. This is the most plausible mechanism behind the 13:56–14:00 saturation, more so than genuine concurrent demand. A cap needs a product decision on what a visitor sees when their still-open tab is reclaimed — the 410 tombstone path already exists and the client handles it, so the machinery is there.At-capacity retry for anonymous visitors, recorded and not implemented. At ≥80% of budget the guardrail already restricts live sessions to signed-in users (
anon_blocked), so a capacity refusal and a budget refusal now land on the same shape (503, envelope, a sentence written for the user) with different codes. Whether an anonymous visitor should be offered a retry, told to sign in, or queued is a product call. What ships is honest and terminal, with the error card's existing "Restart preview" button as the only retry affordance.Pre-existing and not fixed here:
meterSession(env, id, { final: true })runs before the destroy attempt and deletes the meter key, so a failed teardown closes the awake window on a container that may still be billing. It is bounded byMAX_UNSEEN_AWAKE_SECONDSand overwritten nightly byreconcileBillingwith Cloudflare's own figures. Moving it after the destroy would risk skipping the meter entirely on a throw, which is the worse trade. Also unchanged:startSessionMeterstays open on a create that throws, which is true on today's 500 path too and is closed by the client's follow-up DELETE atcontainer.ts:434.Unverified, stated plainly
I could not prove which platform call raises the capacity message during a teardown. In
@cloudflare/sandbox0.12.3,doDestroy()is DO-storage work pluscontainer.destroy(), whose only container-HTTP path is caught internally. Eitherctx.container.destroy()itself needs an instance, or a duplicate destroy re-enters a container path. Confirming it needs awrangler devreproduction with the pool held full. The fix is correct either way: teardown must not fail the request, and a confirmed-destroyed session must never touch the sandbox again.🤖 Generated with Claude Code