Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/resolve-loopback-caller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@mcp-b/do-runtime": patch
---

Add `ActorContainer.resolveLoopback()` so in-realm actor bindings use the raw instance only for exact self-calls. Other calls enter the target gate and resume through the exact current, transformed, or structurally supplied caller, including separately bundled runtime copies.

Verify await-transform coverage against final build modules and warn on development fail-open paths. Reject failed critical sections with `BrokenActorError`, expose queued-entry cancellation, align the `cloudflare-workers` declaration path with its JavaScript entry, and remove the ineffective scheduler WAL pragma.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,23 @@ The lifecycle:

1. `await createActorContainer(options)`.
2. `container.start((ctx, env) => new ActorClass(ctx, env))` once, under boot semantics (input gate held for the constructor, deletion receipts replayed first).
3. Expose `container.entry(instance)` to callers. Its `ActorEntry<T>` type makes every method return a promise because each call is one gated event.
4. Use `container.run(fn)` for events that are not method calls: a WebSocket frame, a host callback.
3. Expose `container.entry(instance, signal?)` to callers. Its `ActorEntry<T>` type makes every method return a promise because each call is one gated event. The optional signal is bound to the proxy and cancels only calls still queued for admission.
4. Use `container.run(fn, signal?)` for events that are not method calls: a WebSocket frame, a host callback. Its signal likewise stops only a queued event, not one already running.
5. Reach the platform through `container.globals` (or install it with `installActorScope`). For a host-provided promise an actor must await, wrap it once in `container.awaitIo()`.
6. Watch `container.onBroken`; dispose the placement; recreate it on the next event over the same storage.
6. Watch `container.onBroken`; dispose the placement; recreate it on the next event over the same storage. A failed `blockConcurrencyWhile()` rejects its caller with `BrokenActorError` and breaks the placement with that same error.

For a standard Durable Object binding, call
`createDurableObjectNamespace(uniqueKey, channel)` and put the result in `env`
and `ctx.exports`. The channel maps each routed id to a placed `Fetcher`; that
binding works directly with Agents SDK `routeAgentRequest()` and
`getAgentByName()`. When an actor uses the binding to call another actor, wrap
the transport promise with the caller's `container.awaitIo()` so its continuation
re-enters the owning input gate. The extension example shows both the external
router binding and the per-container actor binding.
`getAgentByName()`. For an in-realm binding, pass the raw and entered call
thunks to the target's `container.resolveLoopback()`; it invokes the raw
instance only when the exact caller is that target and otherwise owns the callee
entry and caller `awaitIo`. Current slices and transformed continuations resolve
automatically; pass the still-lock-holding structural caller as the third
argument from untransformed post-await code. For an external transport, wrap
its promise with the caller's `container.awaitIo()` so the continuation
re-enters the owning input gate.

### Storage

Expand All @@ -217,6 +221,8 @@ On workerd every awaitable thing is an io-context primitive, so "resuming from a

`container.globals` is the complete gated set, bound to that container: `setTimeout`/`clearTimeout`/`setInterval`/`clearInterval` capture the critical section when armed and re-enter when fired; `scheduler.wait()` and `scheduler.yield()` resume under the actor; `fetch()` waits for output locks and releases the input gate while in flight; `crypto` re-enters on async completion; accepted WebSocket frames enter through the captured context. Install it as the worker's globals (`installActorScope`) when one worker hosts one root, or hand it to application code explicitly when it must not.

Actor bundles can also install `doRuntimeAwaitTransform()` from `@mcp-b/do-runtime/vite`. A production build checks the final module graph and fails with transformed/total counts for any included module with an uncovered await; the development transform warns once per module if a transformed await reaches its fail-open path without an actor lock.

## What is not supported

The browser cannot reproduce every workerd facility. Where it cannot, the runtime **fails closed**: the API exists, throws a named error that the conformance suite asserts on every lane, and never silently does less.
Expand Down
33 changes: 20 additions & 13 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ Internal `_cf_` names remain reserved to the runtime.

`blockConcurrencyWhile()` uses a real nested `CriticalSection`. It blocks peer
entries, inherits through supported reentry callbacks, has the workerd
deadline, and permanently breaks the input gate on failure.
deadline, and permanently breaks the input gate on failure. Workerd discards
the caller with its isolate; this same-realm runtime rejects it with the same
typed `BrokenActorError` used to break and abort the placement.

### §1.6 Abort and breakage

Expand Down Expand Up @@ -113,10 +115,11 @@ MessagePort session.
### §2.1 One application-entry boundary

External methods enter through `ActorContainer.entry()` and non-method events
through `run()`. A genuine self-loopback reuses the current slice
(`isCurrentSlice()`). Cross-actor calls pass through the callee's entry
surface and actor-owned I/O. There is no serialized event tail, method-family
dispatch table, or off-tail exception list.
through `run()`. In-realm bindings use `resolveLoopback()`, which invokes the
raw instance only when the exact caller is the target; every other actor call
passes through the callee's entry and the exact current, transformed, or
structurally supplied caller's `awaitIo`. There is no serialized event tail,
method-family dispatch table, or off-tail exception list.

### §2.2 Worker placement is not actor identity

Expand All @@ -130,8 +133,9 @@ Timers, fetch, crypto, and host I/O are reached through the owning actor's
scope — `container.globals`, installed ambiently only where one realm hosts
one root (`installActorScope`). A raw host promise that application code must
await is wrapped once at its shared owner with `awaitIo`; it is not repaired
at every caller or by patching the realm. No mutable global or ambient field
may select an actor across an `await`.
at every caller or by patching the realm. The await transform's tokenized
continuation marker is the sole exception: it republishes the context captured
from the exact slice, and readers ignore it once that context's lock is gone.

### §2.4 Storage contract

Expand Down Expand Up @@ -176,20 +180,23 @@ owns only placement and physical storage operations.
3. Use workerd-shaped `CriticalSection` semantics rather than a mutex or
queue.
4. Implement `blockConcurrencyWhile()` with that critical section and its
deadline.
deadline; reject its reachable same-realm caller with `BrokenActorError` when
the section breaks the actor.
5. Use a promise-chain output gate and wait before externally observable I/O.
6. Treat a broken gate as fatal to the current container; rebuild from
committed storage.
7. Bound implicit transactions by the same hand-off that releases the input
gate.
8. Carry explicit actor scope through application-owned code and route raw
host promises through `awaitIo`. Do not restore a one-slot async-context
shim, patch the realm with zones, or infer an actor from a realm-wide
global that can mean more than one.
host promises through `awaitIo`. Do not add a generic async-context shim or
patch the realm with zones. The compile-time await transform may republish
only the exact context it captured, tokenized for one checkpoint and valid
only while that context still holds an input lock.
9. No stream pump or generic remote-facet protocol in the runtime. Facet
placement is the host's; direct actor hops use native capabilities.
10. Store per-connection host bridges by connection id. Do not save and
restore an ambient across an `await`.
10. Store per-connection host bridges by connection id. Never use the
transform's actor marker to select a connection or save and restore a host
bridge ambient across an `await`.
11. Run one conformance suite against real workerd, the Node backend, and the
browser backend; assert unavailable substrate behavior rather than
skipping it.
Expand Down
33 changes: 21 additions & 12 deletions docs/gating-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ enumerates it. Every row is one of:
| `scheduler.wait()` / `scheduler.yield()` | scoped `Scheduler` over the same timer path | `api/global-scope.ts` |
| `crypto.subtle.*` | every method's promise gated; sync members pass through | `api/global-scope.ts` |
| `WebSocket` | frames each take a fresh input lock at the `accept()` loop; `send` carries its own output-gate promise (§1.8) | `api/web-socket.ts` |
| storage / `sql` / alarms / `blockConcurrencyWhile` / `awaitIo` / `makeReentryCallback` / entry dispatch | the runtime's own primitives | `io/io-context.ts`, `server/actor-container.ts` |
| storage / `sql` / alarms / `blockConcurrencyWhile` / `awaitIo` / `makeReentryCallback` / entry and loopback dispatch | the runtime's own primitives | `io/io-context.ts`, `server/actor-container.ts` |

## Transform

Expand All @@ -46,18 +46,27 @@ Vite plugin for actor-bundled modules. It rewrites every `await value` to route
through `@mcp-b/do-runtime/gate`, and wraps every `for await` source so
`next()`, `return()`, and `throw()` settlements re-enter the owning actor.

The gate helper fails open outside actor code. Inside an actor it publishes each
continuation through a fresh input-gated slice, including awaits of plain values.
The gate helper fails open outside actor code. A development transform supplies
the module id and warns once if that path is reached; production keeps the helper
silent. Inside an actor it publishes each continuation through a fresh
input-gated slice, including awaits of plain values.
The slice preserves a surrounding `blockConcurrencyWhile` critical section so
the section can await its own continuation without deadlocking. The actor identity
exists only for that continuation's microtask and is cleared before another
publication. Publications are serialized across actors so two promises settling
in the same checkpoint cannot overwrite each other's identity.

The transform covers every syntactic await in modules selected by the consumer's
include policy, including top-level await and async generators. It does not cover
bare `.then()` chains on foreign promises or code outside the filter. The runtime
itself is excluded: its internal promise machinery must keep using raw awaits.
the section can await its own continuation without deadlocking. The tokenized
actor identity is realm-shared so separately bundled actor and host copies agree.
It exists only while the captured context still holds its input lock, the
synchronous current slice always wins, and the marker clears at that context's
checkpoint boundary. Publications are serialized across actors so two promises
settling in the same checkpoint cannot overwrite each other's identity.

At build end the plugin reads the final Rollup module graph, after later
transforms, and compares fully wrapped awaits with total awaits per included
module. It logs the aggregate and fails the build with each incomplete module's
transformed/total count. `await using` is counted as uncovered rather than
silently claimed. The transform covers ordinary syntactic awaits selected by the
consumer's include policy, including top-level await and async generators. It
does not cover bare `.then()` chains on foreign promises, null-byte virtual
modules, or code outside the filter. The runtime itself is excluded: its
internal promise machinery must keep using raw awaits.
Every seam row above remains defense in depth for untransformed consumers and for
promise continuations that do not pass through syntax the transform can rewrite.

Expand Down
7 changes: 1 addition & 6 deletions examples/extension/tsconfig.worker.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,7 @@
"noUnusedParameters": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
// `cloudflare:workers` is a platform specifier no resolver knows. `vite.config.ts`
// aliases it for the build; this is the same statement for the checker.
"paths": {
"cloudflare:workers": ["../../src/api/cloudflare-workers.ts"]
}
"noEmit": true
},
"include": ["src/worker/**/*.ts", "src/protocol.ts"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"import": "./dist/backends/node-sqlite.js"
},
"./cloudflare-workers": {
"types": "./dist/src/api/cloudflare-workers.d.ts",
"types": "./dist/cloudflare-workers.d.ts",
"import": "./dist/cloudflare-workers.js"
},
"./gate": {
Expand Down
4 changes: 4 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ for (const required of [
"CHANGELOG.md",
"dist/index.js",
"dist/src/index.d.ts",
"dist/cloudflare-workers.d.ts",
"dist/backends/node-sqlite.js",
"dist/backends/sqlite-wasm.js",
"dist/gate.js",
Expand Down Expand Up @@ -62,6 +63,9 @@ const vite = modules.get("./vite");
if (typeof runtime.createActorContainer !== "function") {
throw new Error("packed root entry does not export createActorContainer");
}
if (typeof runtime.BrokenActorError !== "function" || typeof runtime.CanceledError !== "function") {
throw new Error("packed root entry does not export its actor lifecycle errors");
}
if (typeof nodeBackend.createNodeSqlProvider !== "function") {
throw new Error("packed Node backend does not export createNodeSqlProvider");
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/fix-declaration-imports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ for (const name of await readdir(dist, { recursive: true })) {

if (fixed !== source) await writeFile(file, fixed);
}

await writeFile(
new URL("cloudflare-workers.d.ts", dist),
'export * from "./src/api/cloudflare-workers.js";\n',
);
2 changes: 1 addition & 1 deletion src/api/global-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* "wrap it in awaitIo" would be wrong three ways:
*
* - **Timers** capture the critical section at the ARMING call and re-enter
* through `ctx.run(callback, cs)` when they fire. Not `awaitIo`, deliberately
* through `ctx.run(callback, { input: cs })` when they fire. Not `awaitIo`, deliberately
* — see `TimeoutManager` in `io/io-context.ts` for upstream's own reason.
* - **`fetch`** is `awaitIo` (`http.c++` has ten of them and zero
* `awaitIoWithInputLock`), preceded by an output-gate wait so nothing departs
Expand Down
2 changes: 1 addition & 1 deletion src/api/web-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class AcceptedWebSocket extends EventTarget {
// so both are called, exactly as `WebSocketFacade` does for the same reason.
const handler = this[`on${type}`] as ((event: Event) => void) | null;
handler?.(delivered);
}, this.#criticalSection),
}, { input: this.#criticalSection }),
);
}

Expand Down
77 changes: 75 additions & 2 deletions src/gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { __gate, __gateAsyncIterable, __gateAwait, __resumeAwait } from "./gate";
import { InputGate, OutputGate } from "./io/io-gate";
import { IoContext, requireInputLock, type Actor, type Timer } from "./io/io-context";
import {
BrokenActorError,
IoContext,
requireInputLock,
type Actor,
type Timer,
} from "./io/io-context";

const timer: Timer = {
now: () => Date.now(),
Expand Down Expand Up @@ -53,6 +59,25 @@ describe("__gate", () => {
expect(__gate(thenable)).toBe(thenable);
});

test("warns once only when a development transform reaches a lockless continuation", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const value = Promise.resolve("done");
try {
expect(__gateAwait(value)).toBe(value);
const context = newContext();
await context.run(async () => {
__resumeAwait(await __gateAwait(value, "/actor-with-a-lock.js"));
});
await portHop();
expect(__gateAwait(value, "/actor-with-a-gap.js")).toBe(value);
expect(__gateAwait(value, "/actor-with-a-gap.js")).toBe(value);
expect(warn).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("/actor-with-a-gap.js"));
} finally {
warn.mockRestore();
}
});

test("re-enters the same actor after sequential foreign awaits", async () => {
const context = newContext();

Expand Down Expand Up @@ -194,6 +219,54 @@ describe("__gate", () => {
});

describe("transformed await resume", () => {
test("preserves BrokenActorError when a failed section cannot re-enter", async () => {
const context = newContext();
const cause = new Error("section failed");

const exception = await context.run(async () => {
try {
__resumeAwait(
await __gateAwait(
context.blockConcurrencyWhile(() => {
throw cause;
}),
),
);
return undefined;
} catch (error) {
return error;
}
});

expect(exception).toBeInstanceOf(BrokenActorError);
expect(exception).toHaveProperty("cause", cause);
});

test("ignores a continuation marker after its input lock is gone", () => {
const context = newContext();
const key = Symbol.for("@mcp-b/do-runtime/current-continuation");
const value = Promise.resolve("outside");
Reflect.set(globalThis, key, { context, token: {} });

try {
expect(__gate(value)).toBe(value);
} finally {
Reflect.deleteProperty(globalThis, key);
}
});

test("publishes continuation identity for separately bundled runtime copies", async () => {
const context = newContext();
const key = Symbol.for("@mcp-b/do-runtime/current-continuation");

await context.run(async () => {
__resumeAwait(await __gateAwait(portHop()));
expect(Reflect.get(globalThis, key)).toMatchObject({ context });
});
await portHop();
expect(Reflect.has(globalThis, key)).toBe(false);
});

test("restores context at the first instruction after fulfillment", async () => {
const context = newContext();

Expand Down
Loading