Repository navigation
Conversation
🦋 Changeset detectedLatest commit: 79ed36e The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
commit: |
ben-reitz
force-pushed
the
host-objects
branch
from
November 4, 2026 10:45
79a356b to
2c1a7de
Compare
ben-reitz
changed the base branch from
browser/module-hooks
to
browser/exec-options
November 4, 2026 10:47
A host module that holds state for a run, such as a browser session
it opens on the first call, had no way to learn when to let go of
it. A run waiting in `ctx.interrupt()` releases its isolate and later
starts again from the top, and a run that ends gives no signal to the
modules it called. Host modules are built once per connection, so
each author would have to keep per-run state keyed by id themselves.
`createScopedModule({ exports, start })` makes a host module with one
instance for each scope of a run. A scope lasts from when the run's
code starts in an isolate until it waits in `ctx.interrupt()` or
ends, so a run that resumes gets a new scope and a new instance. The
backend calls `start` with the run's id and metadata on the scope's
first call to the module, and calls made meanwhile share that start,
so it gets one signal for the scope, which aborts when the scope is
cancelled or released. A call answered from the run's journal
doesn't start an instance. The export names are listed up front
because the backend builds the module graph before any run, and a
call fails if the functions `start` returns don't match them. The
module is marked by `createScopedModule()` rather than recognized by
shape, since an object module's keys are its export names.
An instance closes if and only if it started. It closes with reason
"paused" when a waiting run has released its isolate and the calls it
was making have settled, before the run can resume, so an answer that
arrives meanwhile resumes it only after the close. It closes with the
run's terminal reason after its host calls settle and before its exit
is published. A run killed while paused has nothing open, so nothing
closes. Every instance of a scope closes at once under one
`maxHostCallMs` deadline, and a close that throws or runs late is
reported with `console.error` without changing how the run ends.
Only a terminal close gets `log()`, which appends to the run's
stderr. Logged text is saved in the same transaction as the run's
terminal state rather than appended as it arrives, so a run
interrupted between the two never resumes and replays against output
it didn't write. It counts toward `maxStdioBytes`, and text past the
limit is cut and followed by a line saying so.
Releasing a paused run now waits for its isolate to stop rather than
for its completion callbacks, which may be settling the run and wait
for the release in turn. A run that ends while it is being released
waits for that release, including the calls it drains, before its
exit is published.
Version 0.11 adds URL and exact typed array serialization, 0.11.1 keeps the published bundles ASCII-only, and 0.12 fixes argument leaks on failed calls. The one type change in 0.12, for methods declared to return Promise<RpcStub<T>>, isn't used anywhere in the repository.
A host module could only return JSON, so a module that gives code objects had to be two modules: flat host functions over IDs, and source in the isolate that built the objects back up. createCodemodeModule() makes that one module. It defines kinds of object with define(), adds shared context with context(), and makes the module with module(). An object's methods are host calls like any other. When a result holds a defined object, the bridge swaps it for a marker and lists it with a ref, its kind and its state. The ref names the module, the journal sequence of the call that made it, and its place in the result. The journal keeps the state and the isolate gets the ref, which the capabilities module turns into a proxy. Calling a method on the proxy calls host/<ref>.<method> through the same limits, journal and replay ordering as any other call, so the journal format doesn't change. A scope that only replayed an object builds it again from its state the first time it calls it live. A ref the bridge didn't issue or replay is refused, and so is an object from another module. context() adds a value to ctx, made once when the backend connects, or with scoped set, once per scope the first time a method reads it. That replaces createScopedModule(), which never shipped. Its lifecycle stays: started scoped contexts and live objects with a disposer go into the record's instances and close together when the scope ends, with the same reasons, deadline and log(). An object code releases early is disposed with the reason "disposed". Without an export list to keep in step with start(), the check that an instance's functions match its exports goes away. In a run that doesn't replay, a codemode module can also take callbacks and return streams and live stubs, which Workers RPC carries as they are. A replayable run refuses them, since the journal can't keep them. The runtime code the isolate loads changed, so the replay digest test is updated. REPLAY_VERSION stays: the new code only acts on responses that list objects, which no existing journal holds.
A replayable run's host calls had to take and return JSON, because the journal stored JSON. A codemode module couldn't return a Date or throw an error with a code and details, and have it look the same on replay. The journal now stores a value that plain JSON can't hold with Cap'n Web's serialize(), behind a prefix that starts with a NUL, which JSON text never does. Plain values keep the old format, and call keys for plain arguments don't change, so existing journals read and match as before. Cap'n Web refuses functions, stubs and streams, which is the rule a replayable run needs anyway. Only the encoding is used, not a Cap'n Web session. In a replayable run, codemode arguments, results and object state can now hold anything Cap'n Web 0.12 encodes and Workers RPC carries to the isolate: JSON plus Date, bigint, byte arrays, ArrayBuffer and the other typed arrays, Headers, and errors. URL is refused, since Workers RPC can't carry it. A codemode error keeps its standard name, such as RangeError, and its own properties that fit, live and on replay, and the isolate rebuilds it with the same constructor. Writing the tests turned up a bug from the change that added codemode modules: a byte array in a codemode result was copied as a plain object. It now crosses as it is. The isolate's error handling changed, so the replay digest is updated. REPLAY_VERSION stays, since a journal without the new fields replays as it did.
In a replayable run, every codemode result was measured with Cap'n
Web's serialize(), even plain JSON, which the journal stores as JSON
anyway. Two results that a plain host module takes then failed: JSON
nested past 256 levels ("Serialization exceeded maximum allowed
depth"), such as a 128-level DOM.getDocument result, and text whose
JSON escaping pushed it over the byte limit.
Results are now measured the way replayable requests already are:
plain data by its raw size, and only richer values by their encoding.
A value with anything richer in it is still stored whole with Cap'n
Web's encoding, so measuring also checks that the whole value
encodes. A request or result too deep for the journal is then the
call's error, rather than a failure when the journal stores it.
Only objects can be released early, so a scoped context's dispose is never told "disposed". Its end type still allowed it, so checking `end.reason !== "paused"` didn't narrow to the reasons that have log(), and the docs' example didn't compile. dispose now takes CodemodeContextScopeEnd, which leaves "disposed" out, and the test module drops its extra check.
A method that awaits a scoped context carried on after its call had timed out or been cancelled, if the context was slow to start. The caller had already been told the call failed, but the method could still act, such as writing through a session it never should have used. The scoped context's getter now checks the call's signal once the context has started, so the method's await throws instead.
ben-reitz
force-pushed
the
host-objects
branch
from
November 4, 2026 10:55
2c1a7de to
f635ada
Compare
…demode modules (#307) * computer: Count a replayed codemode result as its first attempt did A replayable run charges each response against its total response budget, live and again on replay. Live, a codemode result counts plain data as text and only richer values, such as a Date, by Cap'n Web's encoding. On replay, a result with any richer value counted wholly by that encoding, which grows escaped text, and its object descriptors didn't count. After a restart, a later call could exceed the budget where the first attempt hadn't. Both now use the same measurement. * computer: Label journaled calls that take a bigint The journal labels each call with its name and arguments, using JSON.stringify, which throws on a bigint. A replayable run allows bigint arguments, but a codemode call that took one failed before the journal recorded it. The label now shows a bigint as its digits and n. * computer: Say how codemode results treat undefined The docs listed undefined among the values a replayable run keeps. In results, it follows JSON's rules in every run: an undefined field is left out, and an undefined array item or result becomes null.
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.
Replaces #293. Closes the main part of #297. Based on #293's branch, so it includes Ben's stack until that lands.
Today a host module can only return JSON. To give code an object, like a job or a page, you write two modules: flat host functions over IDs, and source in the isolate that builds the objects back up.
createCodemodeModule()makes that one module. Its objects keep working after a pause or a deploy.Returning objects
Run({ id })builds the object and remembers{ id }. After a restart, the backend calls the same builder with{ id }to get it back. The one rule: an object is whatever its state rebuilds.Shared context
context(name, create)addsctx[name]to every function and method in the module. It's made once, when the backend connects.State for one run
Add
{ scoped: true }to get a fresh value for each scope of a run. It starts the first time a method reads it, soctx.sessionis a promise. It's disposed when the run pauses or ends.Objects read context when they're called, not when they're built. So after a resume,
Pagejust works against the new session.Richer values and errors
A codemode module can use
Date,bigint, byte arrays,Headersand errors in any run, including replayable ones. Errors keep their name and their own properties.Callbacks, streams and live stubs
In runs that don't replay, Workers RPC carries these as they are:
A replayable run refuses them, since the journal can't keep them yet.
Releasing early
Rules
(args, ctx).codemode:jobs#Run. Don't use variable names, since minifiers change them.Date,bigint, byte arrays, typed arrays,Headersand errors. NoURL,Map,SetorRegExp.module()twice throws, unless the builder has aname.Plain host modules and factories don't change. Codemode modules are experimental.
Implementation
No new transport. A method on an object is just a host call named
host/<ref>.<method>, likehost/codemode:jobs#3:0.start. It goes through the existingbridge.call(), with the same limits, journal and replay ordering as any other call.Refs and proxies. When a result holds a defined object, the bridge swaps it for a marker and lists it as
{ ref, kind, state }. A ref is the module, the journalseqof the call that made it, and its place in the result. The journal keeps the state. The isolate only gets the ref, and turns it into aProxywhose methods are host calls. A scope that only replayed an object rebuilds it from its state the first time it's called live. Refs the bridge didn't issue or replay are refused, and so are objects from another module.Journal values. Plain JSON keeps the journal's old format and call keys, so existing journals read and match as before. Anything richer is stored with Cap'n Web's
serialize(), behind a NUL-prefixed marker. We only use its encoding, not a session. Cap'n Web refuses functions, stubs and streams, which is the rule a replayable run needs. Workers RPC also has to carry the value to the isolate, which rules outURL.Lifecycle. This takes #293's scope lifecycle and drops its API. Started scoped contexts and live objects with a disposer go into the record's instances, and close together when the scope ends, with #293's reasons, deadline and
log().createScopedModule()never shipped, so it's removed, along with its export list and its "functions don't match exports" check.Also in this PR
^0.12.0inpackages/computerandpackages/rpc.REPLAY_VERSIONstays, since the new isolate code only acts on responses that list objects or carry error names and properties, which no older journal has.