Skip to content

Fix for race condition in RPC connection#799

Open
aron-cf wants to merge 24 commits into
mainfrom
rpc-stub-race
Open

Fix for race condition in RPC connection#799
aron-cf wants to merge 24 commits into
mainfrom
rpc-stub-race

Conversation

@aron-cf

@aron-cf aron-cf commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #794

Concurrent sandbox startup (e.g. creating many workspaces from a snapshot at once) intermittently failed, and the errors callers saw were misleading. This PR fixes two related problems on the RPC transport: an idle-disconnect race, and the way container capacity/startup failures are surfaced.

1. Idle-disconnect race disposing the main stub

The RPC client tracks busy/idle by polling capnweb session stats. During concurrent startup those stats can briefly look like the idle baseline (imports=1, exports=1) while a method promise is still in flight, so the client could arm the idle-disconnect timer and dispose the capnweb main stub before the pending call resolved — surfacing as:

RPC session was shut down by disposing the main stub

Fix: pending RPC method calls are now part of the client's busy/idle state via an activeCalls counter. A wrapped method increments it before invoking, marks the session busy, and only decrements on settle. The session transitions to idle (and arms disconnect) only when activeCalls === 0 and capnweb stats are at the idle threshold — preserving the stats-based behavior for long-lived stream exports.

2. Capacity/startup failures were masked

When the Containers platform could not admit a container ("There is no container instance…", "Maximum number of running container instances exceeded"), the failure reached callers as a generic utils.createSession interruption — or, later, as a raw RPC session was shut down… string — with no actionable signal.

Fixes:

  • Explicit, bounded start. On the RPC transport the control connection now starts the container explicitly (via startAndWaitForPorts) before opening the WebSocket, under a short instance-get budget. Each attempt fails fast under capacity pressure and hands back to the connection's own retry loop (exponential backoff within the startup budget) instead of one attempt blocking ~30s and letting the DO be evicted mid-wait. Port readiness still uses the full timeout so a genuinely-booting app isn't cut short.
  • Typed errors, never raw internals. The start hook always throws a typed SandboxError: a retryable ContainerUnavailableError for capacity/admission failures (carrying the real platform message and a reasonno_container_instance_available vs max_container_instances_exceeded, via the new exported ContainerUnavailableReason), or an INTERNAL_ERROR-coded error carrying the real cause otherwise. Detection is case-insensitive and realm-safe.
  • Honest interruption semantics. A queued RPC call is only reported as OPERATION_INTERRUPTED when the session actually established a connection to a running container and was then interrupted. If the container never started, the underlying typed error is surfaced instead of a masked interruption.
  • HTTP path parity. Sandbox.containerFetch emits a structured CONTAINER_UNAVAILABLE 503 (preserving the original platform message) for these failures.

Caveat

This does not change the platform reality that under max_instances pressure some concurrent starts can't get an instance. Those now surface as clean, retryable ContainerUnavailableErrors across the retry budget rather than misleading interruptions or raw transport strings.

@changeset-bot

changeset-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 66773f6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/sandbox Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/sandbox@799

commit: 66773f6

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🐳 Docker Images Published

Variant Image
Default cloudflare/sandbox:0.0.0-pr-799-66773f6b
Python cloudflare/sandbox:0.0.0-pr-799-66773f6b-python
OpenCode cloudflare/sandbox:0.0.0-pr-799-66773f6b-opencode
Musl cloudflare/sandbox:0.0.0-pr-799-66773f6b-musl

Usage:

FROM cloudflare/sandbox:0.0.0-pr-799-66773f6b

Version: 0.0.0-pr-799-66773f6b


📦 Standalone Binary

For arbitrary Dockerfiles:

COPY --from=cloudflare/sandbox:0.0.0-pr-799-66773f6b /container-server/sandbox /sandbox
ENTRYPOINT ["/sandbox"]

Download via GitHub CLI:

gh run download 28790266763 -n sandbox-binary

Extract from Docker:

docker run --rm cloudflare/sandbox:0.0.0-pr-799-66773f6b cat /container-server/sandbox > sandbox && chmod +x sandbox

aron-cf added 21 commits July 1, 2026 12:20
The Containers platform throws "no container instance" / "max instances
exceeded" during startup, which capnweb masked as a generic
utils.createSession interruption. Detect these platform errors, convert
them to typed ContainerUnavailableError, and retry them within the
existing startup budget.
Extract the container-unavailable reason union into a named, exported
type so callers can distinguish no_container_instance_available from
max_container_instances_exceeded without hardcoding string literals.
Make platform detection case-insensitive and realm-safe so lower-case
container-library messages are retried, classify plain-text 503
no-instance bodies, and prefer captured connection errors by structure
(any CONTAINER_UNAVAILABLE-coded value) rather than only same-realm
SandboxError instances. Add a pool-based reproduction driving a real
Sandbox DO over RPC transport.
The no-instance startup branch returned a generic INTERNAL_ERROR 503
with a rewritten message, so the RPC control connection could not
classify it and callers saw rpc_upgrade_failed after the retry budget
drained. Emit a CONTAINER_UNAVAILABLE 503 preserving the platform
message and classify max-instances capacity failures the same way, so
callers receive a typed ContainerUnavailableError with an actionable
reason.
Drive startAndWaitForPorts from the control connection's own retry loop
instead of triggering start as a side effect of the upgrade fetch. The
platform's no-instance / max-instances error now throws where we can
classify it directly into a typed ContainerUnavailableError, giving one
retry authority and removing the dependency on round-tripping the
failure through a 503 upgrade-response body.
Gate the interrupted-operation mapping on whether the capnweb session ever
connected to a running container. When the container never started, a queued
RPC that rejects with a generic capnweb disposal error now surfaces the real
transport/connection error directly instead of being masked as a runtime
interruption. Also scope the per-attempt connect timeout to the WebSocket
upgrade only, letting explicit container start run under its own budget so
the classifiable no-instance error can surface.
Run the RPC transport's explicit container start under a small instance-get
budget so each attempt fails fast under capacity pressure and hands back to
the control connection's retry loop, instead of one attempt blocking ~30s and
letting the DO be evicted mid-wait. Port readiness keeps the full timeout so a
booting app is not cut short. The start hook now always throws a typed
SandboxError: a retryable ContainerUnavailableError for capacity failures, or
an INTERNAL_ERROR carrying the real cause otherwise, so the caller never sees
a raw capnweb transport string.
Rename startContainerForRpc -> startContainerForRPC and the ContainerRpc
interface -> ContainerRPC per the uppercase-acronym rule. Short-circuit
tryConvertPlatformUnavailable for values that are already SandboxError so a
typed hook error isn't rebuilt. Shorten the container-unavailable changeset to
one line.
When the session never established a live connection and a queued RPC rejects
with a teardown-family transport error (disposed / connection failed / peer
closed), synthesize a clean retryable ContainerUnavailableError instead of
leaking the raw capnweb "RPC session was shut down by disposing the main stub"
string. This covers DO eviction mid-startup under capacity pressure, where no
structured connection cause was captured. Non-teardown transport errors still
surface as RPCTransportError.
Destroying a sandbox whose container never started (e.g. no instance available
under capacity pressure) threw the platform no-instance error from the base
container.destroy(), producing a confusing second failure on the cleanup path.
Treat that specific error as an idempotent no-op success. Also give the
never-connected teardown fallback an accurate reason (container_unreachable)
and drop the misleading "may be starting" wording.
Prevent a lifecycle disconnect (e.g. the alarm firing onStop) from tearing
down a connection while it is still attempting to connect: disconnect() now
defers teardown until the in-flight attempt settles and stamps the cause up
front. Stamp the first container-admission failure onto the transport as soon
as startContainer throws, and thread a typed cause through
disconnect()/destroyConnection() so queued RPC calls reject with the real
reason (sandbox stopping / lifetime change / transport switch) instead of the
generic capnweb disposal string.

Add spans for the RPC call (rpc.call), the connect/disconnect lifecycle
(rpc.connect / rpc.disconnect), and each upgrade attempt (rpc.connect.attempt),
stamping error and error.stack attributes per the Cloudflare trace-UI
convention.
Rename the RPC trace spans to sandbox.rpc.call / sandbox.rpc.connect /
sandbox.rpc.connect.attempt / sandbox.rpc.disconnect, and stamp sandbox.id
(the DO ctx.id), sandbox.name (the user-provided name), and sandbox.rpc.port
on every span so RPC traces are attributable to a specific sandbox.
Replace the static sandboxId/sandboxName options with a getSandboxInfo
callback queried at span time, so a sandbox name set (or changed) after the
RPC client is built is reflected in sandbox.id / sandbox.name span attributes.
A lifecycle teardown (onStop firing runtime_replaced) was overwriting the
authoritative container-admission cause the connect attempt had already
stamped, so a queued RPC surfaced "container stopped" while the connect.attempt
span showed the real "no container instance". Track whether lastConnectionError
came from an actual connect-attempt failure (authoritative) vs a teardown
(weak); weak causes only stamp when nothing authoritative was captured. The
connection no longer fires onConnectionError on disconnect; the client stamps
the teardown cause weakly before disposing the stub.
Previously only the first upgrade attempt's span attached to the trace: the
retry loop's setTimeout backoff resumes in a detached async context that no
longer carries the request trace context, so later attempts were invisible and
the connect flow looked far shorter than the RPC call awaiting it. Wrap the
entire doConnect retry loop in a single sandbox.rpc.connect span so its
duration covers every attempt, with per-attempt spans nested inside.
andreykrupskii pushed a commit to andreykrupskii/cloudflare-sandbox-sdk-issues that referenced this pull request Jul 7, 2026
Follow-up to aron-cf's f553148 on cloudflare/sandbox-sdk#799:

- Bump preview SDK to the f553148 build (pkg.pr.new @799) which stamps
  the container exit code / stop reason onto onStop().
- Forward the SDK's full error `context` (not cherry-picked fields):
  the create_workspace span JSON-stamps it and promotes each primitive
  to a filterable `ctx.*` attribute; the HTTP error response returns it
  and the client prints `context={...}` on FAIL lines.
- Set SANDBOX_LOG_LEVEL=debug so the DO emits the "Sandbox stopped"
  { exitCode, reason } log — the exit code does not survive the
  DO->Worker RPC boundary, so the DO-side debug log is the only place
  it is observable (via wrangler tail / Workers Logs).
- Truncate non-JSON error bodies in the client so a transient 404 page
  during deploy rollout no longer dumps HTML into the output.

Kept the JWT auth layer and the local Dockerfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184EGvwYGjSB25BZsaXu7fc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent sandbox creation fails with "disposing the main stub" and leaves orphaned Running containers

1 participant