Skip to content

computer: Add scoped host modules - #293

Closed
ben-reitz wants to merge 1 commit into
browser/exec-tool-metadatafrom
browser/module-hooks
Closed

ben-reitz wants to merge 1 commit into
browser/exec-tool-metadatafrom
browser/module-hooks

Conversation

@ben-reitz

@ben-reitz ben-reitz commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

A host module that holds something for a run, such as a browser session, can't tell when to let go of it. A run waiting in ctx.interrupt() gives up its isolate and later replays from the top, and a run that ends tells its modules nothing.

createScopedModule() adds a host module whose state lives for one scope of a run: from when the run's code starts in an isolate until it waits in ctx.interrupt() or ends. A run that resumes starts a new scope. The backend calls start on the scope's first call to the module and closes the instance when the scope ends:

"codemode:browser": createScopedModule({
  description: "A browser: `open(url)`.",
  exports: ["open"],
  async start(scope, host) {
    const session = await browsers.connect({ conversation: scope.metadata.conversation });
    return {
      functions: { open: ([url]) => session.open(String(url)) },
      async close(end) {
        await session.close();
        if (end.reason !== "paused") end.log("Closed the browser.\n");
      },
    };
  },
}),
A scoped instance lives for one scope of a run

The export names are listed up front because the backend needs them before any run starts, and a call fails if start returns different functions. Concurrent first calls share one start, so start gets its own signal, which aborts when the run is killed. Only a final close can log(), and that text is saved in the same transaction as the run's end, so a restart can never replay a run against output it didn't write. Plain modules and factories work as before.

Ending a run now also waits for a pause's release to finish, so killing a run as it pauses can't publish its exit while the isolate is still shutting down.

Unit and workerd tests cover each moment in the diagram, plus a shared first start, an exports mismatch, a kill during start, a close that throws, and output cut at maxStdioBytes. docs/17_isolate_javascript.md has a new section on scoped modules.

@changeset-bot

changeset-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ccef2c5

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

@github-actions github-actions Bot closed this Nov 2, 2026
@ben-reitz ben-reitz added the allow-pr Allow a PR to remain open. label Nov 2, 2026
@ben-reitz ben-reitz reopened this Nov 2, 2026
@ben-reitz
ben-reitz added this pull request to stack #294 November 2, 2026 17:27
@pkg-pr-new

pkg-pr-new Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ccef2c5

@ben-reitz
ben-reitz force-pushed the browser/exec-tool-metadata branch from 352353b to 8692445 Compare November 3, 2026 10:14
@ben-reitz
ben-reitz force-pushed the browser/module-hooks branch from f734aff to 5e689b0 Compare November 3, 2026 10:14
@ben-reitz
ben-reitz marked this pull request as ready for review November 3, 2026 10:25
devin-ai-integration[bot]

This comment was marked as resolved.

@ben-reitz
ben-reitz force-pushed the browser/exec-tool-metadata branch from 8692445 to eab3b67 Compare November 3, 2026 11:31
@ben-reitz
ben-reitz force-pushed the browser/module-hooks branch from 5e689b0 to b153a86 Compare November 3, 2026 11:31
@ben-reitz ben-reitz changed the title computer: Add execution hooks to host modules computer: Add run-scoped host modules Nov 3, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@cloudflare cloudflare deleted a comment from github-actions Bot Nov 3, 2026
@ben-reitz
ben-reitz force-pushed the browser/module-hooks branch from b153a86 to 5f995eb Compare November 3, 2026 11:50
@ben-reitz
ben-reitz force-pushed the browser/exec-tool-metadata branch from eab3b67 to 5cff4a5 Compare November 3, 2026 11:50
@ben-reitz
ben-reitz force-pushed the browser/module-hooks branch 2 times, most recently from a310cc5 to 96d3dea Compare November 3, 2026 14:12
@ben-reitz ben-reitz changed the title computer: Add run-scoped host modules computer: Add scoped host modules Nov 3, 2026
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.
@aron-cf
aron-cf force-pushed the browser/module-hooks branch from 96d3dea to ccef2c5 Compare November 3, 2026 14:17
@aron-cf

aron-cf commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

@ben-reitz Spent a bit of time reviewing this with an agent. The biggest bit for me is the addition of the new createScopedModule API. This feels like a smell, and we should probably look at the different uses of modules to try and unify a single interface. I'd like a single interface ideally. I've just opened #297 for which I need to look into reworking this. So my main question is how much this blocks the browser api today? Can we ship the other 4? Or do we need this in?

This is sorta where I got to in my session:


The module kinds differ mainly in how long their state lives:

Kind Lifetime Gets host services Cleanup
{ fn } object the backend's no —
Factory the connection's yes none today
createScopedModule() one scope yes close(end)
  1. Normalize host modules into one internal form in parseModules, for example:

    interface NormalizedHostModule {
      readonly exports: readonly string[];
      readonly lifetime: "backend" | "connection" | "scope";
      readonly description: string | (() => string);
      start(ctx: { host: WorkspaceModuleHost; scope?: WorkspaceModuleScope }):
        WorkspaceScopedModuleInstance | Promise<WorkspaceScopedModuleInstance>;
    }

    An object becomes lifetime: "backend", and its start returns { functions: obj }. A factory becomes "connection", started once per handle as it is now. A scoped module becomes "scope". The bridge would then take one (specifier, signal) => Promise<functions> lookup instead of #hostModuleOf plus #scopedModules, and #callHostModule would lose its start === undefined branch. The unsafeToRepeat check after the instance resolves would cover every kind, not just scoped modules. The public forms (object, factory, createScopedModule) stay as shorthand, so nothing changes for users.

    This doesn't need to happen in this PR. It could be a follow-up once scoped modules are no longer experimental.

  2. If factories need cleanup, add close when the connection is disposed, not per-run hooks. A factory's state lives for the whole connection, but nothing tells it when the connection ends. An optional close on what the factory returns would be the same instance-plus-close pattern at a different lifetime. I'd only add it once a module needs it.

  3. Put run-level observability on the backend, not on modules. If we want something like "tell me when a run ends" for metrics or auditing, an option such as onRunEnd(execution, status) on WorkerJavaScriptBackend fits better than every module subscribing to every run. That concern isn't about module state.

Smaller things in this PR

  • A slow start can leak. #closeInstances awaits started inside the maxHostCallMs race. If start resolves after the deadline, nobody closes the instance. Could late arrivals be closed with started.then((i) => i.close?.(end)), or should the docs say start must honour scope.signal?
  • closeOutput.write keeps the whole first chunk that goes over the limit (it checks bytes > room before pushing). Cutting at write time would cap memory at about room.
  • description is read once. Spreading options in createScopedModule runs any getter, and parseModules builds the line eagerly, while factory descriptions are read lazily. Maybe push a thunk the way factories do?
  • createScopedModule could check typeof options.start === "function". If start lives on a prototype, the spread drops it silently, and the first call then fails with "start is not a function".
  • The description says start's signal "aborts when the run is killed". It also aborts when the isolate is released for a pause, which the JSDoc gets right.

@ben-reitz

Copy link
Copy Markdown
Contributor Author

Replaced by #301, which now carries this commit.

@ben-reitz ben-reitz closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-pr Allow a PR to remain open.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants