Skip to content

workspace: improve container lifecycle - #9

Merged
aron-cf merged 11 commits into
mainfrom
container-keepalive
Jun 16, 2026
Merged

workspace: improve container lifecycle#9
aron-cf merged 11 commits into
mainfrom
container-keepalive

Conversation

@aron-cf

@aron-cf aron-cf commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

The container backend used to assume that once connect() had succeeded the WebSocket session to wsd would either work or close cleanly. Neither half holds in practice. A wedged capnweb session, a wsd health probe failing, or a container that died without sending a clean close frame all leave the Workspace holding a cached BackendHandle whose underlying RPC stub is dead. Every subsequent push, pull, or shell.exec reuses that stub. The existing handle.closed watcher only fires on a clean close, so the failure modes that matter in production never trigger a reconnect.

This change makes the cache react to transport failures wherever they surface and gives the container backend first-class signals for pre-flight readiness and live container exits. A workspace operation that hits a dead transport now produces a single classified error, drops the stale cached handle, and the next operation reconnects against a fresh container generation.

sequenceDiagram
    autonumber
    participant W as Workspace
    participant B as ContainerBackend
    participant H as WorkspaceContainerAPI
    participant C as Container
    rect rgba(200, 50, 50, 0.08)
        Note over W,C: Before — single connect path, no recovery
        W->>B: connect()
        B->>H: start(env)
        B->>H: waitForPort (250ms loop)
        B->>H: POST /connect
        B-->>W: BackendHandle (cached)
        Note over W,C: container crashes mid-session
        C--xB: WebSocket frames stop
        W->>B: push() / shell.exec()
        B-->>W: rejects with dead RPC stub
        W->>B: next push() / shell.exec()
        B-->>W: same dead RPC stub, again
    end
    rect rgba(50, 150, 50, 0.08)
        Note over W,C: After — classified failures + monitor() + bounded restart
        W->>B: connect()
        B->>H: exitInfo()
        B->>H: start(env)
        H->>C: container.monitor()
        Note over H,C: armed per generation
        B->>H: probeWsdHealth (bounded restart loop)
        alt readiness fails
            B->>H: restart(env)
            H->>C: destroy() + start()
            B->>H: probeWsdHealth
        end
        B->>H: POST /connect
        B-->>W: BackendHandle (cached)
        Note over W,C: container crashes mid-session
        C-->>H: monitor() rejects → exitInfo recorded
        W->>B: push() / shell.exec()
        B->>H: fetchPort → WorkspaceTransportError("container exited: ...")
        B-->>W: classified failure
        W->>W: drop cached handle + shell
        W->>B: next push() / shell.exec()
        B->>H: fresh start(), fresh monitor, fresh session
    end
Loading

Workspace cache invalidation is driven by an isWorkspaceTransportFailure classifier that walks the cause chain and matches against three signals: a WorkspaceTransportError tag class for failures the workspace stack raises itself, the .name field on a cloned error (which survives a Workers RPC structured-clone hop even when the subclass identity does not), and a small list of patterns for capnweb session shutdown, WebSocket close, container-port unreachable, and the container-exited short-circuit. The classifier is applied at three boundaries: Workspace.push(), Workspace.pull(), and the WorkspaceShell router's exec() / get() paths. The router also rewraps the returned ExecHandle.result() so that a long-running command that loses its transport mid-stream — the realistic failure mode — drops the cached handle when result() rejects, not when the original exec() call returns.

Startup readiness is no longer a private setTimeout loop hitting host.fetchPort directly. The shared probeWsdHealth helper takes the host, port, path, and per-probe timeout, and AbortSignal.timeout aborts a probe that the runtime accepts but never answers. Within connect() it runs in a backoff loop bounded by a per-attempt budget; a failed attempt calls host.restart(env) while restart attempts remain. Failures throw a stage-tagged error that names the stage, port, attempt number, restart count, overall timeout, and the last underlying error. When host.exitInfo() reports a prior container exit, the error carries the prior reason as an attribute too.

Container exits become a first-class signal through container.monitor(). WorkspaceContainerAPI arms the monitor on every successful start(), tags each armed monitor with a generation counter, and snapshots the expected-exit state in a per-generation slot so a destroy that fires while a different generation is in flight cannot mis-classify the wrong generation's exit. The monitor handler bails out if its generation is no longer the current one, so a late-resolving monitor from a torn-down generation cannot overwrite a fresh generation's clean state. Exits log through a single structured console.warn (unexpected) or console.info (expected) call so Cloudflare Logs picks up the fields. fetchPort short-circuits once an exit has been recorded, throwing a WorkspaceTransportError("container exited: <reason>") that the classifier picks up.

Run the workspace tests from the repository root:

npm test --workspace @cloudflare/workspace
npm run typecheck --workspace @cloudflare/workspace
npx biome check .

The new behavior is exercised by 443 vitest cases across thirty-one files. The lifecycle module is unit-tested with an in-process container fake that mirrors the real monitor() contract (rejects on destroy() because the platform uses SIGKILL; resolves only on a clean code-zero exit). The fake exposes per-generation resolve and reject controls so a test can fire the first generation's settle frame after a second generation has been armed — the scenario the production code is defending against. The successful WebSocket round trip still cannot run under the Node test runner because WebSocketPair is a workerd global, and the example container app at examples/container remains the smallest end-to-end check for that path.

The container backend section of packages/workspace/README.md is unchanged; the new behaviors fit inside the existing public surface. One small contract relaxation on ExecHandle: the result and kill property descriptors are now configurable: true so the Workspace router can redefine result() to wire its invalidation path. The handle's id slot stays non-configurable.

Two follow-ups remain. A workerd-backed test under vitest.config.worker-backend.ts would close the unit-test gap on the successful connect path — WebSocketPair is a workerd global and the Node runner cannot reach it. And container.setInactivityTimeout is not wired up, so a brief Durable Object hibernation between operations still risks reclaiming the container; with the recovery path in place the cost is a fresh start() rather than a stale-handle crash, but a small inactivity timeout would close that window cheaply.

aron-cf added 11 commits June 16, 2026 20:16
A CloudflareContainerBackend whose WebSocket wedges without producing
a clean close signal used to leave the Workspace holding a dead
BackendHandle. Subsequent push, pull, and shell.exec calls reused
the same broken RPC stub forever.

Add a conservative classifier, isWorkspaceTransportFailure, that
matches a WorkspaceTransportError tag class plus a short list of
phrases that consistently mean the transport is gone: capnweb
session shutdown, WebSocket close, container-port unreachable. The
classifier walks the cause chain so wrappers like 'watermark sync
failed: <inner>' still classify when the inner error qualifies.

Workspace.push and Workspace.pull now route through an invalidation
helper that drops the cached handle by identity when an RPC fails
this classifier; the shell router does the same for exec and get.
Non-transport failures, including normal shell exits and EROFS, are
left alone so a single bad command does not force a reconnect.
CloudflareContainerBackend.connect() used to probe the container
port through a private #waitForPort loop that issued HEAD /health
directly through host.fetchPort. A shared helper makes the
boundary between "probe the port" and "decide what to do about
the result" explicit, and lets other call sites that need the
same health signal reach it through one entry point.

Lift the probe into a tiny helper that takes the host, port, path,
and per-probe timeout. Drives an AbortSignal.timeout so a wedged
wsd that accepts the connection but never answers still surfaces
as a rejection instead of hanging the caller. Drains the response
body so the underlying connection can be released.

#waitForPort now composes the helper inside its existing retry
loop; the timeout error message is unchanged so the existing
'container port did not open' test stays green.
CloudflareContainerBackend.connect() used to trust a single
host.start() and then poll the container port until the connect
deadline elapsed. If wsd never came up \u2014 the container booted into
a bad state, PID 1 wedged, the network stack failed \u2014 the whole
connect attempt burned the budget on a dead generation and the
caller had to ride out the next ready() pass to retry.

Add restart() and status() to IWorkspaceContainerAPI.
WorkspaceContainerAPI implements restart() as destroy() then
start(); status() returns container.running for diagnostics only.
The probe stays the authoritative readiness signal.

connect() now runs the shared probe in a backoff loop bounded by a
per-attempt budget. A failed attempt with restarts remaining calls
host.restart(env) and tries again. The default is one restart; set
restartAttempts to 0 to disable. Failures throw a stage-tagged
error that names the stage (start, health, restart, connect, ws),
the port, the attempt number, the restart count, the overall
timeout, and the last underlying error.

The two existing error-path tests are adjusted to match the new
stage=health and stage=ws formatting; the /connect non-2xx test
still matches its old substring.
The container backend used to find out that its container had died
only when the next operation failed against it. The runtime
already exposes container.monitor() \u2014 a promise that resolves
when the container exits, with the rejection carrying the
abnormal-exit reason. Wiring it up turns a stale-handle stumble
into a fast, classifiable failure.

Add a small container-lifecycle module that owns the per-DO
monitor state in a module-level WeakMap keyed by ctx. The state
records the most recent exit (timestamp + reason) and tracks an
expectingExit flag so an intentional teardown driven by
WorkspaceContainerAPI.restart() does not log as a crash. The
helpers are pure and have no cloudflare:workers imports, so they
run under the node-based vitest runner against an in-process
container fake.

WorkspaceContainerAPI arms the monitor on every successful
start() and uses destroyContainerExpectingExit() in restart() so
the teardown logs cleanly. fetchPort() short-circuits with a
WorkspaceTransportError when an exit has been recorded; the
transport-failure classifier picks that up and the Workspace
drops its cached handle so the next operation reconnects against
a fresh generation. The interface gains exitInfo(); status()
grows an exit field for diagnostics.

CloudflareContainerBackend.connect() consults host.exitInfo()
before host.start(). When readiness fails, the stage-tagged
error carries the prior exit reason so the resulting log line
attributes the failure to the crash that preceded it.

Exit lines land in Cloudflare Logs via a single structured
console.{warn,info} call so the workers logging stack picks up
the fields. console.warn for unexpected exits (crash, OOM),
console.info for the expected exits driven by restart().
WorkspaceTransportError extended Error and set a name field that
nothing read. After a Workers RPC structured-clone hop the
subclass identity is dropped, so a cross-DO caller's instanceof
check returns false and the error escapes classification.

Read .name in the classifier loop \u2014 it survives the hop intact \u2014
so a cross-DO WorkspaceTransportError still gets recognized as a
transport failure and the cached handle is invalidated.

Two pattern fixes follow from grepping node_modules/capnweb:

  - replace /rpc session was closed/i (matches no capnweb output)
    with /rpc stub after it has been disposed/i (matches the
    actual post-shutdown message);
  - add /container exited/i so the container-host fetchPort
    short-circuit classifies on the message alone, even in the
    pathological case where neither name nor instanceof survives.
Three failure modes the original lifecycle code had, all exposed
when the fake monitor() inverts its settle direction to match the
platform (real container.monitor() rejects on a non-zero exit;
destroy() is SIGKILL, so the rejection is the common path):

1. A late-settling monitor from a torn-down generation could
   overwrite a fresh generation's clean exit state, causing
   fetchPort to short-circuit against a healthy container.
2. expectingExit was cleared in destroyContainerExpectingExit's
   finally block before the monitor's then-handler ran, so an
   intentional destroy logged at warn as if it were a crash.
3. WorkspaceContainerAPI.start() guarded the recovery path with
   !this.#container.running, which can lag after a destroy()
   resolves; the start could be skipped and a monitor armed
   against the carcass.

Switch the lifecycle to a generation-keyed model: every arm
bumps a counter and captures its generation in the handler
closure; the handler bails out if its generation no longer
matches the live one. destroyContainerExpectingExit writes the
current generation into expectedExitGeneration instead of
flipping a global boolean; the handler reads the slot when it
fires and consumes the mark, so a destroy that fails and leaves
the mark on a dead generation cannot mis-classify a later real
crash on the new one.

WorkspaceContainerAPI.start() takes the prior-exit branch
unconditionally when a previous generation has died: destroy the
carcass, then start a fresh generation without consulting
container.running.

Fake container in the lifecycle test now exposes per-generation
resolve/reject controls so a test can fire the first
generation's settle frame AFTER the second generation has been
armed \u2014 the actual stale-monitor scenario the production code
is defending against. The prior fake's single live closure
variable made that scenario impossible to express.
Three small cleanups around the container backend.

health-probe.test.ts cast `as unknown as IWorkspaceContainerAPI`
masked three missing interface methods. probeWsdHealth only
reaches fetchPort, so type the fake against a structural
Pick<IWorkspaceContainerAPI, "fetchPort"> and let the cast
narrow the surface honestly.

cloudflare-container.test.ts fake-host cast `as IWorkspaceContainerAPI`
is now `satisfies IWorkspaceContainerAPI`, so a future interface
addition fails the build instead of slipping through.

#readyWithRestarts split the connect budget evenly across attempts
with a 1ms floor. Bump the floor to 250ms so a readiness check
near the connect deadline still has room to dispatch one real
probe rather than collapsing into a string of immediate timeouts.
WorkspaceShellRouter.exec/get wrapped the dispatch call in a
transport-failure catch, but the dispatch only fails when the
WebSocket is already gone at the moment of the call. The common
case is a long-running command that loses its transport mid-run:
exec() returns a handle, the event stream errors partway through,
and result() rejects with the transport error. The dispatch catch
never fires; the cached backend handle stays stuck.

Make the ExecHandle's result/kill property descriptors
configurable so the router can redefine result(). On every
returned handle the router wraps result() with a try/catch that
routes transport-classified rejections through the same
invalidation path push/pull and exec dispatch already use.

The wrap is opaque to callers \u2014 they see the same ExecHandle
shape, and consumers reading the underlying ReadableStream
directly are untouched. id stays non-configurable; nothing
should ever rewrite that.

The contract change \u2014 result/kill configurable instead of
locked \u2014 is fine: there are no external consumers yet, and the
new flexibility is what unlocked the fix.
Two naming nits surfaced in review. The verb 'arm' reads like a
weapon metaphor and obscures what the helper actually does \u2014 it
installs a per-generation handler against container.monitor() so
the lifecycle module can record the exit. 'install' is plainer
and matches the read-the-method-name test.

The test helper makeCtx had a similar problem: the abbreviation
saved three letters at the cost of one of the worst conventions
in the codebase (ctx vs context). makeContext is what the rest of
the file already calls the value it returns.

No behavior change; the renamed identifiers are local to the
container backend's lifecycle module, the host shim that consumes
it, and the lifecycle module's own tests.
WorkspaceContainerAPI.restart() destroys the current container
and immediately starts the next one. installContainerMonitor then
runs against the new generation, bumping currentGeneration.

The platform settles container.monitor() asynchronously relative
to container.destroy(): destroy can return before the monitor
promise rejects with the abnormal-exit reason. When that happens,
the monitor handler from the OLD generation only runs after
installContainerMonitor has already bumped the counter, so
recordExit sees a generation that no longer matches and drops the
write as stale. The expected-exit log line for the destroy is
silently lost.

Track each generation's monitor-then wrapper on the lifecycle
state as currentMonitorSettled. destroyContainerExpectingExit
captures the wrapper before the destroy call and awaits it after
destroy resolves, so the handler runs to completion before the
caller continues. The next installContainerMonitor then reassigns
currentMonitorSettled with the fresh generation's wrapper.

The new test uses real timers and setTimeout(0) inside its
container fake so destroy() and the monitor rejection straddle a
macrotask boundary \u2014 microtask-only settling (queueMicrotask,
Promise.resolve) drains before the awaiter resumes and would
mask the race. Confirmed the test fails against the prior
behavior: info called 0 times, no expected-exit log emitted.
WorkspaceShellRouter's #onShellError previously looked up the
current cached handle for the backend id and immediately passed
it to #invalidateHandle. The identity check inside
#invalidateHandle (this.#handles.get(id) !== handle) was a
tautology against the value just fetched from that same map, so
the comparison always trivially failed and invalidation always
fired.

The window the bug opens: a long-running exec dispatches against
handle A, A's WebSocket dies, A's closed promise fires, the
Workspace drops A and the next operation rebuilds against handle
B. Some time later A's event stream finally rejects with a
transport error. The wrap around A's ExecHandle calls onError;
without the identity check, B's slot is cleared, and the next
operation pays a spurious reconnect against a still-good handle.

Capture the BackendHandle at exec/get dispatch time, thread it
through #wrapHandle into the error callback, and identity-check
THAT handle against the live cache entry. A late rejection from
a torn-down connection now sees A != B and no-ops.

#shellFor now returns { shell, handle } together so the router
gets both in one lookup; the WorkspaceShell stays cached by id
and is always paired with the live handle for that id because
#invalidateHandle clears both caches together.

The push/pull invalidation path was already identity-checked
correctly through #runWithInvalidation; only the shell path was
broken.
@aron-cf
aron-cf marked this pull request as ready for review June 16, 2026 20:04
@aron-cf
aron-cf merged commit 312a396 into main Jun 16, 2026
9 checks passed
@aron-cf
aron-cf deleted the container-keepalive branch June 16, 2026 20:57
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.

1 participant