Skip to content

computer: Restart or replay interrupted isolate JavaScript runs - #238

Open
scuffi wants to merge 18 commits into
mainfrom
computer/durable-js-exec
Open

scuffi wants to merge 18 commits into
mainfrom
computer/durable-js-exec

Conversation

@scuffi

@scuffi scuffi commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Right now a deploy or crash kills every isolate JavaScript run in flight, and the next connection marks it failed. Now that runs can go on for a while, that's the main way they die: after a deploy, the old instance gets shut down about five minutes later.

This adds an experimental onInterrupt option. With "restart", an interrupted run starts again from the top. With "replay", it starts again but gets everything it already did answered from a journal, so it catches up to where it was without repeating side effects, then carries on live. The code being run doesn't change. There's no step() or anything like it.

Changes

onInterrupt goes on the backend and can be overridden per run. "fail" is still the default.

new WorkerJavaScriptBackend({ loader: env.LOADER, onInterrupt: "replay" });

await workspace.runtime.exec(source, { onInterrupt: "fail" });

A replayable run saves what it needs to start over when it starts: its module graph, input, limits and a random seed. The next time the backend connects, it relaunches any run still marked running, four at a time, since a Durable Object only gets ten Dynamic Workers. The next runtime call or ready() triggers that connection, or the host can call the new workspace.runtime.recover() from its own alarm.

The journal. Filesystem calls, host module calls and, in a replayable run, fetch all go through the host bridge, so that's where the journal sits. Each response is written to SQLite before the isolate sees it, keyed by call name and arguments, and a filesystem change commits in the same transaction as its entry. On replay:

flowchart LR
  call[call from isolate] --> match{in journal?}
  match -- "recorded response" --> answer[answer from journal]
  match -- "was in flight" --> live[run it again]
  match -- "no, journal empty" --> live2[run live, journal it]
  match -- "no, entries left" --> fail[fail: took a different path]
Loading

Keeping the order. Replaying responses isn't enough on its own. The replay gets them all at once, so a Promise.race or a timer racing a call could go differently. While a run is live, the isolate logs the order responses arrive and timers fire in, and sends that log along with its next call or output. On replay, it holds each response and timer back until the log says it's their turn.

sequenceDiagram
  participant I as Isolate
  participant H as Host
  Note over I,H: First attempt
  I->>H: call A, call B
  H-->>I: B (ordinal 1)
  Note over I: timer 1 fires
  H-->>I: A (ordinal 2)
  I->>H: call C, log [1, -1, 2]
  Note over I,H: Replay
  I->>H: call A, call B
  H-->>I: A and B from the journal at once
  Note over I: releases B, fires timer 1, releases A
Loading

I tried timestamps first, but the Workers clock doesn't move while code computes, so a timer set after some work looks due earlier than it actually fired. There's a test for that case.

Everything else that varies. A replayable run gets a virtual clock for Date and performance.now(), and seeded randomness for Math.random(), crypto.getRandomValues() and crypto.randomUUID(). Its fetch goes through the host's egress policy and is journaled like any other call. Output is compared byte for byte with the first attempt, and any difference counts as divergence. A replay that gets stuck before reaching where it was interrupted fails after a second rather than hanging until its timeout.

Calls that shouldn't run twice. A call in flight at the interruption runs again, since the code never saw its result. Host module calls now get a context.callId that stays the same across that rerun, so it works as an idempotency key. A function that can't deduplicate can be wrapped in unsafeToRepeat(), and an interruption during it fails the run instead:

modules: {
  "ws:billing": {
    charge: unsafeToRepeat(async ([order, cents]) => billing.charge(order, cents)),
    refund: async ([order], { callId }) => billing.refund(order, { idempotencyKey: callId }),
  },
}

Guards. A run gets three attempts, all within its original timeout, and only resumes on the same runtime: the same generated modules, compatibility date and flags. Anything else fails with a message saying why.

restart and replay are marked experimental in the package's status table and the docs, so the journal format and behavior can keep changing.

Trying it

const backend = new WorkerJavaScriptBackend({ loader: env.LOADER, onInterrupt: "replay" });

// In the Durable Object's alarm, to resume runs without waiting for traffic:
await this.workspace.runtime.recover();

Start a run, redeploy, wait for the old instance to go (about five minutes), then make any runtime call or call recover().

Testing

The end-to-end tests run the real runner in workerd and restart the object with ctx.abort() mid-run. They cover replay and restart, response order under Promise.race, timers racing calls (including after a stretch of computation), fetch not being sent again, random values repeating, a replay that calls something different or prints something different, unsafeToRepeat(), a second interruption during a replay, and the default fail policy. The scheduler also has its own tests in a node:vm realm, including the ChaCha20 stream against the RFC 8439 test vector. Each replay test was checked to fail with its feature turned off.

I also ran it in production with ctx.abort() mid-run on compatibility date 2026-10-12:

Run Result
Five rounds of computing, then racing a 300 ms timer against a 100 ms fetch, crashed at 4 s Same output, 5 requests total
Six rounds racing two fetch calls and a timer, crashed at 6 s Same winners, 12 requests total
Five random prices fetched 2.5 s apart, crashed at 6 s Same prices, 5 requests total
Random numbers and randomUUID() printed each second, crashed at 3.5 s Same values
fetch in a loop until Date.now() passes 8 s, no crash Stopped at 8 s, after 175 requests

And an earlier version through a real redeploy, with a run that ticked every 5 seconds for 12 minutes, each tick incrementing a counter file. The recovery path hasn't changed since:

Result
Old instance after the deploy Kept running about 5 minutes, then shut down
First request to the new version Resumed the run at attempt 2, with 157 journaled calls
Replay of the first 78 ticks About 4 seconds, same clock readings as the first attempt
Final result Completed, counter 140 for 140 ticks, 140 lines of output
Same run under restart Ran again from the top, 220 lines of output (78 repeated)

Caveats

  • It's experimental: a run interrupted across an upgrade of this package fails rather than resuming. Nothing changes for anyone on the default fail, except that host module call contexts now have a required callId.
  • Keys from crypto.subtle.generateKey(), node:crypto randomness, node:timers and cloudflare:sockets aren't covered. A run that prints their results or passes them to a call diverges on replay.
  • A replayable run's fetch is buffered rather than streamed, each body has to fit in maxCapabilityBytes (1 MiB by default), and it's slower, since every request goes through the Durable Object: a sequential loop got through about 40% as many requests as under fail. It doesn't count toward the capability budgets, and at most six requests are open at once, with the rest queued. A request in flight at the interruption is sent again. Aborting a request stops the code waiting for it, but the host's request runs on until it finishes or hits the host call deadline, holding one of the six slots.
  • Nothing wakes a Durable Object after a deploy. A caller polling a run or reconnecting to its output resumes it; otherwise the host's alarm has to call recover(). If that happens after the run's timeout, the run fails. The Workspace doesn't set an alarm itself, since an object only gets one.

Follow-ups

  • Let runs survive upgrades of this package by loosening the runtime check.
  • Cover node:timers and node:timers/promises in replay.
  • Pass a fetch abort through to the host's request.
  • Show the attempt count on results, so a host can tell a resumed run from a fresh one.
  • Resume container commands by reattaching to their computerd execution.

@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 7b96e04

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

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

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 Oct 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@238

commit: 7b96e04

@scuffi
scuffi force-pushed the computer/durable-js-exec branch from 9c57075 to 656da63 Compare October 15, 2026 09:30
Base automatically changed from computer/long-running-js-exec to main October 15, 2026 13:57
scuffi added 15 commits October 15, 2026 15:11
A run can now say what should happen to it if the Durable Object
restarts while it runs, through an onInterrupt option on exec that
module backends receive with the rest of the execution input. The
policy is fail, restart, or replay. Backends that can't resume a run
ignore it, so nothing changes until a backend acts on it.

WorkspaceRuntime.recover() connects every module backend and asks each
to settle the runs a restart interrupted. A backend that does this when
it connects needs no more than that, so recover() gives a host a way to
resume runs from its own alarm instead of waiting for the next runtime
call.
A deploy or crash ended every isolate JavaScript run in flight, and the
next connection marked it failed. WorkerJavaScriptBackend now takes an
onInterrupt option, overridable per run, that can start such a run
again instead.

A run under restart or replay saves its module graph, input, context
and limits when it starts. When the backend next connects, it finds the
runs still marked running and launches each again from that saved
state, within the run's original timeout and for at most three
attempts. A run whose generated runtime modules, compatibility date or
flags changed in between fails instead, since it would resume on
different code.

Restart runs the code again from the top. Replay does the same, but
answers the calls the run already made from a journal. Every capability
call already passes through the host bridge, so the bridge journals
each one by name and arguments, writing the response to SQLite before
the isolate sees it. On replay a matching call gets its recorded
response without running again, in whatever order the calls arrive. A
call still in flight at the restart runs again. A call the journal
doesn't hold while recorded calls remain, or a run that finishes
without making them, fails the run with a message saying it took a
different path. Output already recorded is skipped as the replay
produces it again.

To keep a replay on the same path, a replayable run gets a seeded
Math.random and a virtual clock that reads the time the run started,
the time each journaled call completed, and a timer's due time when it
fires. That makes the clock deterministic without freezing a run that
waits on timers. While journaled responses remain, timers fire at once,
so a replay doesn't spend the time left in its timeout waiting out
sleeps it already waited. The user's code doesn't change. Network
requests are not journaled and run again on replay.
Explain the onInterrupt policies, when interrupted runs resume, what a
replay journals and how it detects a run taking a different path, the
seeded random numbers and virtual clock that keep replays on the same
path, and the limits: three attempts, the original timeout, the same
runtime code, and network requests that run again.
A replay that diverged named only the calls involved, so two writes to
different paths read as "it called fs.writeFile, but the next call it
made before was fs.writeFile". The journal now labels each entry with
the call's name and a short form of its arguments, with byte arrays
summarized by length and long values cut, and the divergence message
uses that label for both calls.
A replayed run used to get its journaled responses as fast as the host
could send them, in the order it asked for them. Code whose path depends
on which call finishes first, such as Promise.race, could take a
different branch on replay than it did the first time. Timers fired at
once while replaying, so a timer that lost a race against a call the
first time could win it on replay. The clock moved only when a
journaled call completed, so a loop polling the network until a
deadline never reached it, and fetch wasn't journaled at all.

A replayable run now logs the order it sees its events in: each
response by an ordinal the host assigns when it commits it, and each
timer by the order it was set in. A new generated module,
workspace-durable.js, keeps the log and sends it with the next
capability call or output frame. Each chunk says where in the log it
goes, since calls and output reach the host separately, and the host
saves it in the same transaction as the call or output it came with.
Whatever the run did after the last thing it sent, nobody saw, so the
log covers everything a replay has to repeat.

On replay the host answers journaled calls at once, and the isolate
holds each response and timer back until the log reaches it. Once the
log runs out, responses it never placed are released and pending timers
get their remaining real delay. Ordering by recorded events rather than
by comparing timestamps matters because the Workers clock doesn't move
while code computes: a timer set after a stretch of computation is due
earlier by the clock than it fires in fact, and a timestamp comparison
puts it ahead of responses it lost to.

The clock is virtual: it moves to each response's commit time and to a
timer's due time when it fires. Math.random, crypto.getRandomValues and
crypto.randomUUID draw from a ChaCha20 stream over a 256-bit seed saved
with the run, and performance.now and AbortSignal.timeout follow the
virtual clock. A replayable run's fetch goes through the bridge as a
net.fetch call. The host makes the request through the backend's egress
policy and buffers the response within the per-call payload limit, so
the journal holds it like any other response.

Replayed output is now compared byte for byte against what the first
attempt recorded instead of being skipped by length, and a mismatch
counts as divergence. A replay that waits for an event it never reaches
is reported to the host after a second with nothing else to do, rather
than hanging until its timeout.
A replayable run interrupted while a host module call was in flight runs
that call again when it resumes, since its response never reached the
code. That suits reads and idempotent writes, but a function that
charges a card or sends a message did it twice, and had no way to tell
the second call from a new one.

Every host module call now gets a callId in its context. A journaled
call keeps the same id when it runs again, built from the execution id
and its journal position, so a function can pass it to an external
service as an idempotency key. Calls outside a journal get a unique id.

A function that can't deduplicate can be wrapped in unsafeToRepeat().
The journal flags a call it's running again after an interruption, and
the bridge fails the run when that call goes to a marked function,
instead of making it a second time.
A Durable Object runs at most ten Dynamic Workers at once. After a
restart, the backend relaunched every interrupted run as soon as it
connected, so a Workspace with many replayable runs in flight could hit
that limit, fail some of the relaunches, and leave no room for new
executions.

Interrupted runs now wait in a queue, and at most four restart at once.
Each one that settles lets the next start. Checks that don't need a
Worker, such as the attempt cap and the runtime fingerprint, still run
at once, so a run that can't resume fails without waiting its turn. A
queued run's remaining timeout is computed when it starts, and its
attempt counter moves only then, so time spent waiting counts against
its timeout but waiting doesn't use up an attempt. Killing a queued
run settles it without starting it.
The interrupted-executions section described replay as answering calls
in any order, with timers firing at once and fetch going out again, and
listed network requests among the things a replay repeats. Replays now
follow the order the run saw its events in, on a virtual clock, with
seeded random numbers and journaled fetch, so the section says so.

It also covers what a run can't count on: crypto.subtle keys,
node:crypto, node:timers and sockets stay nondeterministic, and a fetch
response is buffered within the call size limit. It documents callId
and unsafeToRepeat(), the four-at-a-time restart, and that a filesystem
change and its journal entry commit together.
WorkspaceModuleCallContext now requires a callId, so the test helpers
that build a context by hand stopped typechecking, and the rlm example's
build failed on its capability test. Give each helper a fixed callId.
A replayable run's fetch goes through the bridge so it can be
journaled, and that made it count toward every capability budget: 256
calls, 8 MiB of responses, 8 MiB of requests and 32 calls at once by
default. None of those apply to fetch in a run that isn't replayable,
so switching a run to replay could make it fail partway through, and a
long agent session would hit the call or response budget easily.

fetch now skips those budgets. Instead of refusing a call past the
concurrency limit, the bridge keeps at most six requests open and queues
the rest, as a Worker does with its connections, which also bounds how
many buffered bodies the object holds at once. Each request and
response body still has to fit within maxCapabilityBytes.

A journaled response larger than 256K characters is now split across
rows. A Durable Object's SQLite holds at most 2 MB in a row, so raising
maxCapabilityBytes would otherwise have made the journal write fail.
A replayable run's event log rode only on its next capability call or
output frame, so a run that set many timers between them kept the whole
log in memory until then. Past 65,536 events the host refused the chunk
as malformed and failed a run that had done nothing wrong.

Once 4,096 events are waiting, the isolate now sends them on their own
through a new recordLog bridge method. Each chunk says where in the log
it goes, as before, so it doesn't matter whether it arrives before or
after the call or output that follows it.
The interrupted-executions section said a host could call recover()
from an alarm, without saying when that matters. A caller polling a run
or reconnecting to its output resumes it by connecting, so most runs
need nothing more. A run nobody is waiting on stays interrupted after a
deploy until something calls the object, and fails if its timeout
passes first. Say so, and why the Workspace leaves the alarm to the
host.
A replayable run swaps in its own setTimeout, Date and fetch, and each
differed from the real one in a way code could trip over. Under
nodejs_compat, the global timers hand back Node Timeout objects, so code
calling unref() or refresh() on one worked in an ordinary run and threw
in a replayable one. A class extending Date lost its prototype, since
the replacement constructor ignored new.target. fetch dropped the
redirect mode, so a request made with redirect: "manual" silently
followed the redirect.

The timers now return Timeout-like handles when the runtime's own do,
detected once at install, and clearTimeout maps a handle back to its
timer. The Date replacement constructs through Reflect.construct, and
fetch passes the redirect mode to the host's request.
Every end-to-end test interrupted a run once. A run interrupted while
it replays resumes from a journal and event log written across two
attempts, which only reasoning covered. The test runner's host module
gains hold(), a gate a restart opens by name, so a test can restart the
object once at the first gate and again at a second. The run completes
with its output once, and each call ran once except the two that were
in flight at an interruption.
The journal format, event log and fingerprint check will change as
replay develops, and a run interrupted across such a change fails
rather than resuming. WorkerJavaScriptBackend and workspace.runtime are
now listed as stable, so give resuming its own experimental row in the
package's status table, and say so in the docs, the onInterrupt JSDoc
and the changeset. The changeset also notes the new required callId on
host module call contexts.

A comment on the durable tables records why a change to their shape
should rename them rather than migrate: every backend creates them when
it connects, and CREATE TABLE IF NOT EXISTS leaves an older table as it
was.
@scuffi
scuffi force-pushed the computer/durable-js-exec branch from 7dd6fd8 to 67ce22f Compare October 15, 2026 14:17
Say what was added and leave the details to the docs.
@scuffi
scuffi marked this pull request as ready for review October 15, 2026 15:21

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Devin Review

Comment thread packages/computer/src/runtime/bridge.ts
Comment thread packages/computer/src/backends/worker-javascript/durable-runtime.ts
Comment thread packages/computer/src/runtime/bridge.ts
Comment thread packages/computer/src/backends/worker-javascript/durable-runtime.ts Outdated
Comment thread packages/computer/src/runtime/egress.ts
A call whose response would take the run past its cumulative response
budget gets a short budget error instead, and that error isn't counted.
The journal records the error, and a replay counted it, so later live
calls saw less budget than the first attempt had and could fail where
it succeeded. A replay now recognizes the budget error and skips it.

A comment at the journal commit also records why a filesystem change
and its entry can't come apart: the change is synchronous SQL on the
same database with no I/O before the commit, so both land in the same
implicit transaction.
The replayable scheduler.wait took only a delay, so an aborted wait
still resolved and the code after it ran. It now rejects with the
signal's reason when the signal is already aborted or aborts while it
waits, as the runtime's own does. The abort comes from a timer or a
response the event log already orders, so it replays the same way.
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