From 5c9dca9011230c1d328ed943d4677474e3a6ff15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:26:15 +0000 Subject: [PATCH 1/2] sql.ingest(), workerd's SQL error wording, and new events in the runtime's async context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sql.ingest(sql) (workerd, experimental): runs the complete statements at the start of an SQL chunk (quotes, comments and trigger bodies respected) and returns the rest as `remainder`, with rowsRead, rowsWritten and statementCount; an SQL dump can be fed through it in chunks of any size. sql.exec(), as workerd: SQL with no statement (`""`, `";"`) or a last statement of comments alone throws "SQL code did not contain a statement."; a syntax error is worded `near "X": syntax error at offset N: SQLITE_ERROR` (the drivers prepare statements without their `;`, where SQLite says "incomplete input": reworded as the syntax error at that `;`). No celld recording depends on the old wording. wd-sql-default now gets past ingest and stops at workerd's wording of other SQLite errors (SQLITE_MISMATCH, SQLITE_AUTH), where cellb follows celld: still a known gap, judged by workerd. A call to a Durable Object is a new event, as on Cloudflare: events and constructors start in the async context the runtime was made in, not the caller's (AsyncLocalStorage.snapshot + a fresh AsyncResource per event on Node, so a store one event sets does not reach the next; the polyfill's frame in a browser). A host can still scope a whole runtime (p09 does). Tests: p15 (workerd's ingest assertions, a dump in chunks of 1–81 bytes with strings, comments and a trigger), p16 (a direct and an object-to- object call do not see the caller's store; the runtime's store reaches events and constructors), the async-context worker's expectation now Cloudflare's (Node and Chromium). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXJQA3LbggJHi5qZBpUKe8 --- packages/cellb/conformance/scenarios.ts | 6 +- packages/cellb/docs/gaps.md | 18 ++-- packages/cellb/src/core/async-context.ts | 45 ++++++++++ packages/cellb/src/core/object-host.ts | 10 ++- packages/cellb/src/core/runtime.ts | 4 + packages/cellb/src/core/sql-split.ts | 49 ++++++++++ packages/cellb/src/core/sql-storage.ts | 42 ++++++++- packages/cellb/test/async-context-worker.ts | 7 +- packages/cellb/test/p15-sql-ingest.test.ts | 90 +++++++++++++++++++ packages/cellb/test/p16-async-context.test.ts | 53 +++++++++++ 10 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 packages/cellb/src/core/async-context.ts create mode 100644 packages/cellb/test/p15-sql-ingest.test.ts create mode 100644 packages/cellb/test/p16-async-context.test.ts diff --git a/packages/cellb/conformance/scenarios.ts b/packages/cellb/conformance/scenarios.ts index e2e458e..83bcee6 100644 --- a/packages/cellb/conformance/scenarios.ts +++ b/packages/cellb/conformance/scenarios.ts @@ -485,8 +485,12 @@ scenarios["wd-sql-default"] = { path: "/?skip=testCursorUaf,testMultiStatement,testRollbackKvInit,testSessionsAPIBookmark", }, ], + // celld stops at ingest's syntax-error message (its wording misses workerd's "at offset"). + // cellb words syntax errors as workerd does and gets past ingest, to workerd's wording of + // other errors (": SQLITE_MISMATCH", ": SQLITE_AUTH"), where it follows celld's. + judge: "workerd", knownGap: - "sql.ingest() (experimental) is missing; celld itself stops at an ingest syntax-error message", + "workerd's wording of SQLite errors (': SQLITE_', its authorizer messages); cellb words them as celld does", }; // celld's deviations from workerd (conformance/record-workerd.ts); cellb follows celld. diff --git a/packages/cellb/docs/gaps.md b/packages/cellb/docs/gaps.md index 8fe2333..bf812d6 100644 --- a/packages/cellb/docs/gaps.md +++ b/packages/cellb/docs/gaps.md @@ -46,7 +46,9 @@ from the celld golden, and turns red when it stops differing ([conformance.md](c Each item is proven by a conformance case changing state (a `knownGap` removed, or a new step recorded on celld or workerd), not by a test of the mechanism alone. -- **Small known gaps**, one at a time: `sql.ingest()` only if wanted. +- **Small known gaps**, one at a time. `sql.ingest()` is in (workerd's semantics and wording of + syntax errors); the rest of workerd's `sql-test.js` default test stops at its wording of other + SQLite errors (`: SQLITE_MISMATCH`, `: SQLITE_AUTH`), where cellb follows celld's. ## Firefox and WebKit @@ -100,12 +102,12 @@ Dedicated Workers all work in Firefox 157, and the whole suite passes there. Wha stores: a `run()`'s store stays set until its promise settles, which is right for one run at a time and shared between concurrent ones (two cells answering at once can read each other's). Event listeners (`addEventListener`, MessagePort `onmessage`) do not carry stores, on Node either. -- **A called object starts in its caller's async context.** Its `AsyncLocalStorage` stores are the - caller's (on Node and in a browser), where on Cloudflare a call to another object is a new event - with none. The Agents SDK and Flue set their own stores at each entry, so they are not affected; - code that reads a store it did not set in the callee sees the caller's. Changing it means - starting each event in the root context, which the celld conformance harness (test/p09) relies - on not doing: it attributes Durable Object logs to a scenario through such a store. +- **An object's events start in the async context the runtime was made in.** A call to an object + is a new event, as on Cloudflare: the callee (and its constructor) does not see the caller's + `AsyncLocalStorage` stores. Where Cloudflare has none, cellb has the stores that were current + when `createCellRuntime` ran, so a host can scope a whole runtime (test/p09 attributes each + scenario's logs this way). On Node this needs `process.getBuiltinModule` (Node 22.3 and later); + in a browser it goes through cellb's `node:async_hooks` polyfill. - **A browser runtime is one thread, and OPFS writes are its cost.** Its cells interleave, and each commit's SQLite I/O is synchronous on that thread. A Flue conversation turn (a prompt, a tool call, two model turns) makes 21 commits (155 when commits ended with each microtask); in this @@ -141,6 +143,8 @@ Dedicated Workers all work in Firefox 157, and the whole suite passes there. Wha written; two tabs using it at once can both win. - **`Set-Cookie` cannot reach a page** through a ServiceWorker, and a browser `Response` drops it. - **`bufferedAmount` is always 0** on cellb's WebSockets: there is no backpressure on socket ports. + Making it real takes acknowledgement frames in both ends' protocol (proposed with the port + protocol's move to webrun-http-browser, statewalker/webrun-wire#35). - **The input gate does not hold non-storage I/O.** A timer or `fetch` completion can interleave with another event while the gate is closed. celld behaves the same; workerd does not (`wd-actor-block-concurrency-timer`). diff --git a/packages/cellb/src/core/async-context.ts b/packages/cellb/src/core/async-context.ts new file mode 100644 index 0000000..e710964 --- /dev/null +++ b/packages/cellb/src/core/async-context.ts @@ -0,0 +1,45 @@ +/** Runs `fn` in an async context captured earlier. */ +export type AsyncRoot = (fn: () => R) => R; + +const POLYFILL_STATE = Symbol.for("cellb.asyncContext.state"); + +/** + * The async context current now, to start events in: on Cloudflare a call to a Durable Object is + * a new event, which sees no `AsyncLocalStorage` store its caller set. On Node it is every store + * (`AsyncLocalStorage.snapshot()`); in a browser, the `node:async_hooks` polyfill's frame (a + * realm's polyfill copies share it), or its root frame if the polyfill loads later. Elsewhere + * events run in their caller's context, as before. + */ +export function asyncRoot(): AsyncRoot { + const hooks = ( + globalThis as { + process?: { getBuiltinModule?(id: string): unknown }; + } + ).process?.getBuiltinModule?.("node:async_hooks") as + | { + AsyncLocalStorage?: { snapshot?(): (fn: () => R) => R }; + AsyncResource?: new (type: string) => { runInAsyncScope(fn: () => R): R }; + } + | undefined; + const snapshot = hooks?.AsyncLocalStorage?.snapshot?.(); + const Resource = hooks?.AsyncResource; + // Each event in a resource of its own, made in the snapshot: it inherits the snapshot's stores, + // and a store an event sets (`run` writes it on the current resource, before Node's + // AsyncContextFrame) stays its own instead of reaching the next event started in the snapshot. + if (snapshot && Resource) + return (fn) => snapshot(() => new Resource("cellb.event").runInAsyncScope(fn)); + type State = { root: unknown; current: unknown }; + const realm = globalThis as Record; + const captured = realm[POLYFILL_STATE]?.current; + return (fn) => { + const state = realm[POLYFILL_STATE]; + if (!state) return fn(); + const prev = state.current; + state.current = captured ?? state.root; + try { + return fn(); + } finally { + state.current = prev; + } + }; +} diff --git a/packages/cellb/src/core/object-host.ts b/packages/cellb/src/core/object-host.ts index 3cd1d06..dc0ba50 100644 --- a/packages/cellb/src/core/object-host.ts +++ b/packages/cellb/src/core/object-host.ts @@ -9,6 +9,7 @@ import type { SyncSqlDriver, ValueCodec, } from "../ports.js"; +import type { AsyncRoot } from "./async-context.js"; import { CELLD_SCHEMA, LOG_SEQ_READ, LOG_SEQ_WRITE, LOG_TABLE, scopeOf } from "./celld-schema.js"; import { InputGate, OutputGate } from "./gates.js"; import { @@ -93,6 +94,8 @@ export class ObjectHost { /** A facet: on open its rows move to this run's scope, as celld does. */ facet?: boolean; acquire?: Lease["tryAcquire"]; + /** The async context events start in (`asyncRoot()`): not the caller's, as on Cloudflare. */ + asyncRoot?: AsyncRoot; }) { this.#backup = opts.backup; this.cellKey = opts.cellKey; @@ -174,7 +177,9 @@ export class ObjectHost { return facet; }, ); - const construct = () => (this.#instance = new opts.cls(state, opts.env)); + const root = opts.asyncRoot ?? ((fn) => fn()); + this.#root = root; + const construct = () => root(() => (this.#instance = new opts.cls(state, opts.env))); this.#construct = construct; const load = opts.backup?.load; const replay = opts.backup?.replay; @@ -265,7 +270,7 @@ export class ObjectHost { let started: Promise; this.#active++; try { - started = Promise.resolve(fn(this.#instance, this.state)); + started = Promise.resolve(this.#root(() => fn(this.#instance, this.state))); } catch (e) { started = Promise.reject(e); } @@ -410,6 +415,7 @@ export class ObjectHost { } #construct?: () => unknown; + #root: AsyncRoot = (fn) => fn(); readonly #open: () => void; #epoch: number | undefined; diff --git a/packages/cellb/src/core/runtime.ts b/packages/cellb/src/core/runtime.ts index c1dc2ec..b1f1eab 100644 --- a/packages/cellb/src/core/runtime.ts +++ b/packages/cellb/src/core/runtime.ts @@ -11,6 +11,7 @@ import { v8Codec, v8CodecWith } from "../v8/codec.js"; import { type CfWebSocket, takeClientPort } from "../ws/server.js"; import { withUpgrade } from "../ws/upgrade.js"; import { type AlarmEntry, AlarmScheduler } from "./alarms.js"; +import { asyncRoot } from "./async-context.js"; import type { ExecutionContext } from "./durable-object.js"; import { ctxExports, type DurableObjectClass, storedExports } from "./handles.js"; import { DurableObjectId, namespaceKey } from "./id.js"; @@ -127,6 +128,8 @@ export function createCellRuntime(opts: CellRuntimeOptions): CellRuntime { const codec = opts.codec ?? v8Codec; const hosts = new Map(); const background = new Set>(); + // Events start in the context the runtime was made in, not their caller's (Cloudflare). + const eventsRoot = asyncRoot(); const waitUntil = (p: Promise) => { const t = p.catch(() => {}).finally(() => background.delete(t)); background.add(t); @@ -232,6 +235,7 @@ export function createCellRuntime(opts: CellRuntimeOptions): CellRuntime { onAlarmChange: (at) => alarms.set({ cellKey, className, idHex: id.toString() }, at), exports: exportsObj, facet: opts.facets, + asyncRoot: eventsRoot, acquire: ownership ? (key) => ownership.tryAcquire(key) : undefined, sqlExtensions: opts.sqlExtensions, deleteAllPreservesAlarm: opts.deleteAllPreservesAlarm, diff --git a/packages/cellb/src/core/sql-split.ts b/packages/cellb/src/core/sql-split.ts index 13f06dd..3273c6d 100644 --- a/packages/cellb/src/core/sql-split.ts +++ b/packages/cellb/src/core/sql-split.ts @@ -30,3 +30,52 @@ export function splitSqlStatements(sql: string): string[] { if (cur.trim()) out.push(cur.trim()); return out.length ? out : [sql]; } + +/** + * The complete statements at the start of `sql` (each ended by a top-level `;`, a trigger body + * as one), and what follows the last of them: the `remainder` of workerd's `sql.ingest()`, kept + * as it is (whitespace, a comment, an unfinished statement or string). + */ +export function takeCompleteStatements(sql: string): { statements: string[]; remainder: string } { + const statements: string[] = []; + let start = 0; // where the current statement begins + let code = ""; // the current statement without comments, to recognize a trigger + for (let i = 0; i < sql.length; i++) { + const c = sql[i]; + if (c === "'" || c === '"' || c === "`" || c === "[") { + const end = sql.indexOf(c === "[" ? "]" : c, i + 1); + if (end < 0) break; // an unfinished string: incomplete + code += sql.slice(i, end + 1); + i = end; + } else if (c === "-" && sql[i + 1] === "-") { + const end = sql.indexOf("\n", i); + if (end < 0) break; + i = end; + } else if (c === "/" && sql[i + 1] === "*") { + const end = sql.indexOf("*/", i + 2); + if (end < 0) break; + i = end + 1; + } else if (c === ";") { + if (TRIGGER.test(code) && !/\bEND\s*$/i.test(code)) { + code += c; + continue; + } + if (code.trim()) statements.push(sql.slice(start, i + 1).trim()); + start = i + 1; + code = ""; + } else code += c; + } + return { statements, remainder: sql.slice(start) }; +} + +const COMMENTS = /--[^\n]*(\n|$)|\/\*[\s\S]*?(\*\/|$)/g; + +/** Whether `sql` is comments and nothing else (an empty `sql` is not). */ +export function onlyComments(sql: string): boolean { + return sql.replace(COMMENTS, " ").trim() === "" && sql.trim() !== ""; +} + +/** Whether `sql` holds a statement: anything but whitespace, comments and `;`. */ +export function hasStatement(sql: string): boolean { + return sql.replace(COMMENTS, " ").replace(/;/g, " ").trim() !== ""; +} diff --git a/packages/cellb/src/core/sql-storage.ts b/packages/cellb/src/core/sql-storage.ts index 9ab9eb0..97d456e 100644 --- a/packages/cellb/src/core/sql-storage.ts +++ b/packages/cellb/src/core/sql-storage.ts @@ -1,5 +1,6 @@ import type { CommitChanges, SqlResult, SyncSqlDriver } from "../ports.js"; import { LOG_SEQ_READ, LOG_SEQ_WRITE, LOG_TABLE } from "./celld-schema.js"; +import { hasStatement, onlyComments, takeCompleteStatements } from "./sql-split.js"; const FORBIDDEN_TXN = /^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i; const FORBIDDEN_INTERNAL = /\b_cf_\w*/i; @@ -159,7 +160,46 @@ export class SqlStorage { } if (bindings.length && countParameters(query) !== bindings.length) throw new Error("SQL error: Wrong number of parameter bindings for SQL query."); - return new SqlStorageCursor(this.internalExec(query, bindings)); + // workerd prepares statement after statement: one made of comments alone is an error. + // workerd prepares statement after statement: none at all, or a last one made of comments + // alone, is an error. + if (!hasStatement(query) || onlyComments(takeCompleteStatements(query).remainder)) + throw new Error("SQL code did not contain a statement."); + try { + return new SqlStorageCursor(this.internalExec(query, bindings)); + } catch (err) { + // workerd's wording: SQLite's message and the offset of the token it stopped at. The + // drivers prepare each statement without its `;`, where SQLite says "incomplete input" + // for what it calls a syntax error at that `;` when it sees it. + const message = String((err as Error)?.message ?? err); + const m = /near "([^"]*)": syntax error/.exec(message); + if (m) throw new Error(`${m[0]} at offset ${Math.max(0, query.indexOf(m[1]))}: SQLITE_ERROR`); + if (/incomplete input/.test(message) && query.trimEnd().endsWith(";")) + throw new Error(`near ";": syntax error at offset ${query.lastIndexOf(";")}: SQLITE_ERROR`); + throw err; + } + } + + /** + * workerd's `sql.ingest(sql)` (experimental): runs the complete statements at the start of + * `sql` (an SQL dump read in chunks) and returns what follows them, to be prefixed to the next + * chunk. A complete statement that does not parse throws, as `exec` does. + */ + ingest(sql: string): { + remainder: string; + rowsRead: number; + rowsWritten: number; + statementCount: number; + } { + const { statements, remainder } = takeCompleteStatements(sql); + let rowsRead = 0; + let rowsWritten = 0; + for (const statement of statements) { + const cursor = this.exec(statement); + rowsRead += cursor.rowsRead; + rowsWritten += cursor.rowsWritten; + } + return { remainder, rowsRead, rowsWritten, statementCount: statements.length }; } /** diff --git a/packages/cellb/test/async-context-worker.ts b/packages/cellb/test/async-context-worker.ts index b051766..e4d437d 100644 --- a/packages/cellb/test/async-context-worker.ts +++ b/packages/cellb/test/async-context-worker.ts @@ -2,9 +2,8 @@ * A Worker that reads one AsyncLocalStorage store across every kind of continuation, in many * Durable Objects at once. Run on Node (the real node:async_hooks) and in a browser (cellb's * polyfill), it must answer the same: each object sees its own store everywhere, and the Worker's - * fetch, outside any run(), sees none. The object it calls sees the CALLER's store: cellb starts a - * called object's event in the caller's async context (both run in one realm). On Cloudflare that - * event starts with no store; docs/gaps.md has the difference. + * fetch, outside any run(), sees none. The object it calls sees no store either: a call to an object + * is a new event, which starts in the runtime's own async context, as on Cloudflare. */ export const ASYNC_CONTEXT_WORKER = ` import { DurableObject, env } from "cloudflare:workers"; @@ -76,7 +75,7 @@ export function expectedSeen(name: string) { inTimeout: name, inMicrotask: name, inThen: name, - calleeSees: name, // Cloudflare: null (docs/gaps.md) + calleeSees: null, // a new event: not the caller's store afterRpc: name, inForAwait: name, inAll: name, diff --git a/packages/cellb/test/p15-sql-ingest.test.ts b/packages/cellb/test/p15-sql-ingest.test.ts new file mode 100644 index 0000000..f969fdc --- /dev/null +++ b/packages/cellb/test/p15-sql-ingest.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + createCellRuntime, + DurableObject, + type DurableObjectNamespace, + runInDurableObject, +} from "../src/index.js"; +import { nodeSqliteOpener } from "../src/node.js"; + +// workerd's `sql.ingest()` (experimental): an SQL dump read in chunks. The assertions are +// workerd's own (conformance/workerd-tests/wd-sql/sql-test.js), run on cellb directly. +class Box extends DurableObject {} + +async function withSql(fn: (sql: any) => void | Promise) { + const runtime = createCellRuntime({ + worker: { Box, default: {} }, + durableObjects: { BOX: { className: "Box" } }, + openSql: nodeSqliteOpener(), + }); + try { + const stub = (runtime.env.BOX as DurableObjectNamespace).getByName("a"); + await runInDurableObject(stub as never, async (_i, ctx) => fn(ctx.storage.sql)); + } finally { + await runtime.close(); + } +} + +describe("P15 sql.ingest", () => { + it("runs the complete statements and returns the rest", () => + withSql((sql) => { + expect(sql.ingest("SELECT 123; SELECT 456; ").remainder).toBe(" "); + expect(sql.ingest("SELECT 123; SELECT 456;").remainder).toBe(""); + for (const tail of [" SELECT 456", " SELECT 45", " SELECT ", " SEL", " "]) + expect(sql.ingest(`SELECT 123;${tail}`).remainder).toBe(tail); + expect(sql.ingest("SELECT 123").remainder).toBe("SELECT 123"); + // A trailing comment is a remainder for ingest, and no statement for exec. + expect(sql.ingest("SELECT 123; SELECT 456; -- trailing comment").remainder).toBe( + " -- trailing comment", + ); + expect(() => sql.exec("SELECT 123; SELECT 456; -- trailing comment")).toThrow( + /SQL code did not contain a statement/, + ); + expect(() => sql.exec("")).toThrow(/SQL code did not contain a statement/); + expect(() => sql.exec(";")).toThrow(/SQL code did not contain a statement/); + })); + + // workerd's tests match `String(error)`, "Error: near …"; these match the message. + it("throws on a complete statement that does not parse, as workerd words it", () => + withSql((sql) => { + expect(() => sql.ingest("SELECT * bunk;")).toThrow(/^near "bunk": syntax error at offset/); + expect(() => sql.ingest("INSER INTO xyz VALUES ('a'),('b');")).toThrow( + /^near "INSER": syntax error/, + ); + expect(() => sql.ingest("INSERT INTO xyz VALUES ('a')('b');")).toThrow( + /^near "\(": syntax error/, + ); + expect(() => sql.exec("SELECT ;")).toThrow(/syntax error at offset 7/); + expect(() => sql.exec("SELECT -;")).toThrow(/syntax error at offset 8/); + })); + + it("ingests a dump split into chunks of any size, strings and triggers included", () => + withSql((sql) => { + sql.exec("CREATE TABLE streaming(val TEXT)"); + sql.exec("CREATE TABLE log(n INTEGER)"); + const dump = [ + "CREATE TRIGGER IF NOT EXISTS t AFTER INSERT ON streaming BEGIN INSERT INTO log VALUES (1); END;", + ...Array.from( + { length: 6 }, + (_, i) => `INSERT INTO streaming VALUES ('a;${i}'), ("b -- ${i}"), ('ü${i}');`, + ), + "/* done */", + ].join("\n"); + for (const size of [1, 3, 9, 27, 81]) { + sql.exec("DELETE FROM streaming"); + let buffer = ""; + let rows = 0; + let statements = 0; + for (let i = 0; i < dump.length; i += size) { + const r = sql.ingest(buffer + dump.slice(i, i + size)); + buffer = r.remainder; + rows += r.rowsWritten; + statements += r.statementCount; + } + expect(buffer.trim()).toBe("/* done */"); + expect(sql.exec("SELECT count(*) AS n FROM streaming").one().n).toBe(18); + expect(statements).toBe(7); // the trigger and the six inserts + expect(rows).toBeGreaterThanOrEqual(18); + } + })); +}); diff --git a/packages/cellb/test/p16-async-context.test.ts b/packages/cellb/test/p16-async-context.test.ts new file mode 100644 index 0000000..17f7e2e --- /dev/null +++ b/packages/cellb/test/p16-async-context.test.ts @@ -0,0 +1,53 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { describe, expect, it } from "vitest"; +import { createCellRuntime, DurableObject, type DurableObjectNamespace } from "../src/index.js"; +import { nodeSqliteOpener } from "../src/node.js"; + +// On Cloudflare a call to a Durable Object is a new event: the callee sees no AsyncLocalStorage +// store its caller set. cellb starts every event (and the constructor) in the context the +// runtime was made in. +const request = new AsyncLocalStorage(); +const deployment = new AsyncLocalStorage(); + +class Probe extends DurableObject { + constructedWith = { request: request.getStore(), deployment: deployment.getStore() }; + seen() { + return { + request: request.getStore() ?? null, + deployment: deployment.getStore() ?? null, + constructedWith: this.constructedWith, + }; + } + async callOther(name: string) { + // A store set in this object does not reach the object it calls either. + return request.run("set-in-caller-object", () => + (this.env as { PROBE: DurableObjectNamespace }).PROBE.getByName(name).seen(), + ); + } +} + +describe("P16 every event starts in the runtime's own async context", () => { + it("a called object sees the runtime's stores, not its caller's", async () => { + const runtime = deployment.run("deployment-1", () => + createCellRuntime({ + worker: { Probe, default: {} }, + durableObjects: { PROBE: { className: "Probe" } }, + openSql: nodeSqliteOpener(), + }), + ); + try { + const ns = runtime.env.PROBE as DurableObjectNamespace; + const direct = await request.run("set-by-caller", () => (ns.getByName("a") as any).seen()); + expect(direct).toEqual({ + request: null, + deployment: "deployment-1", + constructedWith: { request: undefined, deployment: "deployment-1" }, + }); + const viaObject = await (ns.getByName("b") as any).callOther("c"); + expect(viaObject.request).toBeNull(); + expect(viaObject.deployment).toBe("deployment-1"); + } finally { + await runtime.close(); + } + }); +}); From 0bf0cd68cb0c72685c1ddd2e29efe757de20d701 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:33:06 +0000 Subject: [PATCH 2/2] p11f: give celld up to a minute to wake a queue it took over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Queues: a delayed message sent on cellb is delivered by celld" failed now and then in CI (#5, #7). celld wakes cells from its wake index by minute (wake/entries//…): a message due in a later minute than celld started in is woken at its next scan, up to a minute on, and the test polled for 20 s. Reproduced every time by sending at :54 of a minute (delivered 59 s after celld started); cellb writes the same entry celld writes for its own delayed messages. The test now polls for up to 80 s (timeout 150 s). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXJQA3LbggJHi5qZBpUKe8 --- packages/cellb/e2e/p11f-celld-one-bucket.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cellb/e2e/p11f-celld-one-bucket.test.ts b/packages/cellb/e2e/p11f-celld-one-bucket.test.ts index ca8abe9..8886cae 100644 --- a/packages/cellb/e2e/p11f-celld-one-bucket.test.ts +++ b/packages/cellb/e2e/p11f-celld-one-bucket.test.ts @@ -529,8 +529,11 @@ describe.skipIf(!CELLD)( await w.close(); celld = await celldDev(dir); try { + // celld wakes cells from its wake index by minute (wake/entries//…): a + // message due in a later minute than celld started in is woken at its next scan, up + // to a minute on. So up to 80 s here; within one minute it is a few seconds. let got: unknown[] = []; - for (let i = 0; i < 40 && got.length < 2; i++) { + for (let i = 0; i < 160 && got.length < 2; i++) { await new Promise((r) => setTimeout(r, 500)); got = await (await fetch(`${celld.base}/`)).json(); } @@ -559,7 +562,7 @@ describe.skipIf(!CELLD)( it( "Queues: a delayed message sent on cellb is delivered by celld", () => handoff("cellb→celld"), - 120_000, + 150_000, // celld may take up to a minute to wake the queue (above) ); // A workflow instance is a cell (celld's own workflow code on both sides): a sleeping or