diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index af62a386a5..828492e4cb 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -26,7 +26,6 @@ import { ErrorCaptureLive } from "./observability"; import { cloudflareAccountMiddleware } from "./account/account-provider"; import { makeCloudflareApprovalHandler } from "./mcp"; import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; -import { preloadQuickJs } from "./quickjs"; // =========================================================================== // The Cloudflare host, as ONE `ExecutorApp.make` call: the 4th app alongside @@ -67,7 +66,6 @@ export const reconcileInternalIntegrationsOnce = (env: CloudflareEnv): Promise preloadQuickJs()); const handle = yield* Effect.promise(() => openDb(env)); const executor = yield* makeScopedExecutor( accountId, @@ -94,10 +92,6 @@ export const makeCloudflareApp = async ( ) => { const plugins = makeCloudflarePlugins(config.secretKey); - // Load the Workers-compatible (WASM-inlined) QuickJS variant before any - // executor is built, the default variant cannot fetch its .wasm on Workers. - await preloadQuickJs(); - // Open and idempotently bring up the D1 schema once. This is the long-lived // handle the per-request scoped executor reads through the DbProvider seam. const handle = await openDb(env); diff --git a/apps/host-cloudflare/src/db/d1.test.ts b/apps/host-cloudflare/src/db/d1.test.ts index 83f9788090..b9a95b0b8e 100644 --- a/apps/host-cloudflare/src/db/d1.test.ts +++ b/apps/host-cloudflare/src/db/d1.test.ts @@ -5,6 +5,14 @@ import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk" import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; import { createD1ExecutorDb } from "./d1"; +import { cloudflareDataMigrationIds } from "./data-migrations"; + +const readMarker = async (client: SqliteDataMigrationClient): Promise => { + const result = await client.execute( + `SELECT fingerprint FROM private_executor_cloudflare_schema_fingerprint WHERE id = 'runtime-schema'`, + ); + return String(result.rows[0]?.fingerprint); +}; const makeRecordingD1 = ( client: SqliteDataMigrationClient, @@ -63,46 +71,99 @@ const isRuntimeSchemaStatement = (sql: string): boolean => { return !normalized.includes("DATA_MIGRATION"); }; +const ranDataMigrations = (statements: readonly string[]): boolean => + statements.some((sql) => sql.includes("SELECT name FROM data_migration")); + +const setMarker = (client: SqliteDataMigrationClient, fingerprint: string) => + client.execute({ + sql: `UPDATE private_executor_cloudflare_schema_fingerprint SET fingerprint = ? WHERE id = 'runtime-schema'`, + args: [fingerprint], + }); + +// Opens the database once so it is current, then opens it again and returns +// only what the second open sent to D1. +const openTwice = async ( + db: D1Database, + statements: string[], + between: () => Promise = async () => {}, +): Promise => { + const first = await createD1ExecutorDb(db, undefined); + await first.close(); + await between(); + const start = statements.length; + const second = await createD1ExecutorDb(db, undefined); + await second.close(); + return statements.slice(start); +}; + describe("createD1ExecutorDb", () => { it("does not repeat runtime schema DDL after the current schema was prepared", async () => { const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); const { db, statements } = makeRecordingD1(sqlite.client); - const first = await createD1ExecutorDb(db, undefined); - await first.close(); - const firstSchemaStatements = statements.filter(isRuntimeSchemaStatement); - expect(firstSchemaStatements.length).toBeGreaterThan(0); - - const secondStart = statements.length; - const second = await createD1ExecutorDb(db, undefined); - await second.close(); - const secondStatements = statements.slice(secondStart); + const secondStatements = await openTwice(db, statements); + expect(statements.filter(isRuntimeSchemaStatement).length).toBeGreaterThan(0); expect(secondStatements.filter(isRuntimeSchemaStatement)).toEqual([]); - expect(secondStatements.some((sql) => sql.includes("SELECT name FROM data_migration"))).toBe( - true, + + await sqlite.close(); + }); + + it("opens a current database with exactly one D1 query", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, statements } = makeRecordingD1(sqlite.client); + + const secondStatements = await openTwice(db, statements); + + expect(secondStatements).toHaveLength(1); + expect(secondStatements[0]).toContain("SELECT fingerprint"); + + await sqlite.close(); + }); + + it("records every known data migration in the ledger", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db } = makeRecordingD1(sqlite.client); + + const handle = await createD1ExecutorDb(db, undefined); + await handle.close(); + + const ledger = await sqlite.client.execute(`SELECT name FROM data_migration`); + expect(ledger.rows.map((row) => row.name)).toEqual( + expect.arrayContaining([...cloudflareDataMigrationIds]), ); await sqlite.close(); }); + it("runs the data migrations again when the stored fingerprint predates the migration list", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, statements } = makeRecordingD1(sqlite.client); + const missing = cloudflareDataMigrationIds[cloudflareDataMigrationIds.length - 1]; + + const secondStatements = await openTwice(db, statements, async () => { + await sqlite.client.execute({ + sql: "DELETE FROM data_migration WHERE name = ?", + args: [missing], + }); + await setMarker(sqlite.client, "older"); + }); + + expect(ranDataMigrations(secondStatements)).toBe(true); + const ledger = await sqlite.client.execute(`SELECT name FROM data_migration`); + expect(ledger.rows.map((row) => row.name)).toContain(missing); + + await sqlite.close(); + }); + it("runs the schema ensure again when the generated fingerprint is stale", async () => { const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); const { db, statements } = makeRecordingD1(sqlite.client); - const first = await createD1ExecutorDb(db, undefined); - await first.close(); - await sqlite.client.execute( - `UPDATE private_executor_cloudflare_schema_fingerprint - SET fingerprint = 'stale' - WHERE id = 'runtime-schema'`, + const secondStatements = await openTwice(db, statements, () => + setMarker(sqlite.client, "stale"), ); - const secondStart = statements.length; - const second = await createD1ExecutorDb(db, undefined); - await second.close(); - const secondStatements = statements.slice(secondStart); - expect( secondStatements.some((sql) => sql.startsWith('CREATE TABLE IF NOT EXISTS "integration"')), ).toBe(true); @@ -112,20 +173,14 @@ describe("createD1ExecutorDb", () => { it("adds the compiled artifact columns to a database that predates them", async () => { const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); - const { db } = makeRecordingD1(sqlite.client); - const first = await createD1ExecutorDb(db, undefined); - await first.close(); - for (const column of ["compiled_fingerprint", "compiled_js", "compiled_css"]) { - await sqlite.client.execute(`ALTER TABLE "artifact" DROP COLUMN "${column}"`); - } - await sqlite.client.execute( - `UPDATE private_executor_cloudflare_schema_fingerprint - SET fingerprint = 'stale' - WHERE id = 'runtime-schema'`, - ); + const { db, statements } = makeRecordingD1(sqlite.client); - const second = await createD1ExecutorDb(db, undefined); - await second.close(); + await openTwice(db, statements, async () => { + for (const column of ["compiled_fingerprint", "compiled_js", "compiled_css"]) { + await sqlite.client.execute(`ALTER TABLE "artifact" DROP COLUMN "${column}"`); + } + await setMarker(sqlite.client, "stale"); + }); const columns = await sqlite.client.execute(`PRAGMA table_info("artifact")`); const names = columns.rows.map((row) => String(row.name)); @@ -141,11 +196,7 @@ describe("createD1ExecutorDb", () => { const { db } = makeRecordingD1(sqlite.client); const prepared = await createD1ExecutorDb(db, undefined); await prepared.close(); - await sqlite.client.execute( - `UPDATE private_executor_cloudflare_schema_fingerprint - SET fingerprint = 'stale' - WHERE id = 'runtime-schema'`, - ); + await setMarker(sqlite.client, "stale"); const handles = await Promise.all([ createD1ExecutorDb(db, undefined), @@ -256,4 +307,38 @@ describe("createD1ExecutorDb", () => { await sqlite.close(); }); + + it("rewrites the marker after preparing a database whose marker is out of date", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, statements } = makeRecordingD1(sqlite.client); + let currentMarker = ""; + + const secondStatements = await openTwice(db, statements, async () => { + currentMarker = await readMarker(sqlite.client); + await setMarker(sqlite.client, "older"); + }); + + expect(ranDataMigrations(secondStatements)).toBe(true); + expect(await readMarker(sqlite.client)).toBe(currentMarker); + + await sqlite.close(); + }); + + it("keeps the old marker when data preparation fails so the next open retries", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, statements, failWhen } = makeRecordingD1(sqlite.client); + let currentMarker = ""; + await openTwice(db, statements, async () => { + currentMarker = await readMarker(sqlite.client); + await setMarker(sqlite.client, "older"); + failWhen((sql) => sql.includes("SELECT name FROM data_migration")); + await expect(createD1ExecutorDb(db, undefined)).rejects.toBeDefined(); + expect(await readMarker(sqlite.client)).toBe("older"); + failWhen(null); + }); + + expect(await readMarker(sqlite.client)).toBe(currentMarker); + + await sqlite.close(); + }); }); diff --git a/apps/host-cloudflare/src/db/d1.ts b/apps/host-cloudflare/src/db/d1.ts index 5763670987..3b7d0a41b9 100644 --- a/apps/host-cloudflare/src/db/d1.ts +++ b/apps/host-cloudflare/src/db/d1.ts @@ -14,7 +14,11 @@ import { import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store"; import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config"; -import { prepareCloudflareD1Data } from "./data-migrations"; +import { + cloudflareDataMigrationIds, + ensureCloudflareD1SchemaCompatibility, + prepareCloudflareD1Data, +} from "./data-migrations"; import { withD1TransientRetry } from "./d1-retry"; const SCHEMA_FINGERPRINT_TABLE = `private_${CLOUDFLARE_NAMESPACE}_schema_fingerprint`; @@ -26,6 +30,22 @@ const schemaFingerprint = async (statements: readonly string[]): Promise return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); }; +// Every open in an isolate uses the same fixed options, so the hash is +// computed once. Hashing the compatibility function's source makes a change to +// the repair code change the fingerprint without a hand-bumped version. +let expectedFingerprintCache: Promise | null = null; + +const expectedSchemaFingerprint = ( + options: Parameters[0], +): Promise => { + expectedFingerprintCache ??= schemaFingerprint([ + ...createDrizzleRuntimeSchemaSqlFromTables(options), + `compatibility:${ensureCloudflareD1SchemaCompatibility.toString()}`, + `data-migrations:${cloudflareDataMigrationIds.join(",")}`, + ]); + return expectedFingerprintCache; +}; + const isMissingFingerprintTable = (error: unknown): boolean => { for (let current = error, depth = 0; current != null && depth < 8; depth += 1) { const record = @@ -105,19 +125,19 @@ export const createD1ExecutorDb = async ( // exposes one. A generated fingerprint lets every Worker/DO isolate prove the // expected generated schema was already prepared with one primary read instead of // replaying dozens of idempotent CREATE/ALTER statements on every database - // open. The marker is stored only after the ensure, data migrations, and any - // required post-compatibility ensure succeed. Data migrations retain their - // own ledger and still run below on every open. - const expectedFingerprint = await schemaFingerprint( - createDrizzleRuntimeSchemaSqlFromTables(options), - ); - const preparedFingerprint = await readPreparedSchemaFingerprint(db); + // open. The fingerprint also covers the compatibility-check source and the + // ordered data-migration ids, so a current database opens with that one read + // and skips both. The marker is stored only after the ensure, compatibility + // check, data migrations, and any required post-compatibility ensure succeed. + // Data migrations keep their own ledger for databases that need them. + const [expectedFingerprint, preparedFingerprint] = await Promise.all([ + expectedSchemaFingerprint(options), + readPreparedSchemaFingerprint(db), + ]); const requiresSchemaPreparation = preparedFingerprint !== expectedFingerprint; if (requiresSchemaPreparation) { await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options); - } - const migrationResult = await prepareCloudflareD1Data(db, blobs); - if (requiresSchemaPreparation) { + const migrationResult = await prepareCloudflareD1Data(db, blobs); // Compatibility migrations can rebuild a legacy table. Re-run the // idempotent ensure before stamping so the database matches the generated // schema after migration, including newer nullable columns the legacy diff --git a/apps/host-cloudflare/src/db/data-migrations.ts b/apps/host-cloudflare/src/db/data-migrations.ts index 1efb4340dd..6476c4c82d 100644 --- a/apps/host-cloudflare/src/db/data-migrations.ts +++ b/apps/host-cloudflare/src/db/data-migrations.ts @@ -256,6 +256,16 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite encryptedSecretsRepartitionDataMigration, ]; +// Ordered names of every data migration this build knows. The ledger already +// makes a migration run once, and the bucket only decides how it copies blobs, +// so the names alone describe what a fully prepared database looks like. +export const cloudflareDataMigrationIds: readonly string[] = [ + googleOpenApiOwnershipDataMigration.name, + providerServiceSplitDataMigration.name, + openApiNdjsonOutputDataMigration.name, + encryptedSecretsRepartitionDataMigration.name, +]; + export const prepareCloudflareD1Data = ( db: D1Database, bucket: R2Bucket | undefined, diff --git a/apps/host-cloudflare/src/execution.test.ts b/apps/host-cloudflare/src/execution.test.ts new file mode 100644 index 0000000000..ff8b54f18e --- /dev/null +++ b/apps/host-cloudflare/src/execution.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +// oxlint-disable-next-line executor/no-vitest-import -- vi.mock hoisting needs the vitest import +import { vi } from "vitest"; +import { Effect } from "effect"; + +import { CodeExecutorProvider } from "@executor-js/api/server"; + +const { loadQuickJs } = vi.hoisted(() => ({ loadQuickJs: vi.fn() })); + +vi.mock("./quickjs", () => ({ loadQuickJs })); +vi.mock("./mcp", () => ({ McpExecutionOwnerDirectoryDO: class {}, McpSessionDO: class {} })); +vi.mock("./app", () => ({ + makeCloudflareApp: vi.fn(), + reconcileInternalIntegrationsOnce: vi.fn(), +})); + +await import("./worker"); +const { CloudflareCodeExecutorProvider } = await import("./execution"); + +describe("CloudflareCodeExecutorProvider", () => { + it.effect("does not load QuickJS when the worker starts or the provider is built", () => + Effect.gen(function* () { + yield* CodeExecutorProvider.asEffect().pipe(Effect.provide(CloudflareCodeExecutorProvider)); + + expect(loadQuickJs).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index ceaeb3f552..dd112494d0 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -30,7 +30,6 @@ import type { ResumeResponse } from "@executor-js/execution"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; import { createD1ExecutorDb } from "../db/d1"; import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution"; -import { preloadQuickJs } from "../quickjs"; // --------------------------------------------------------------------------- // Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare @@ -146,9 +145,6 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, diff --git a/apps/host-cloudflare/src/quickjs.ts b/apps/host-cloudflare/src/quickjs.ts index 9d84484ed6..fbf8995e1e 100644 --- a/apps/host-cloudflare/src/quickjs.ts +++ b/apps/host-cloudflare/src/quickjs.ts @@ -9,7 +9,7 @@ import baseVariant from "@jitl/quickjs-wasmfile-release-sync"; // monorepo-root node_modules path — see scripts/vendor-quickjs-wasm.ts. import wasmModule from "./quickjs-engine.wasm"; -import { setQuickJSModule } from "@executor-js/runtime-quickjs"; +import type { QuickJSWASMModule } from "quickjs-emscripten"; // --------------------------------------------------------------------------- // QuickJS-on-Workers WASM loading. @@ -18,18 +18,10 @@ import { setQuickJSModule } from "@executor-js/runtime-quickjs"; // build (its `./emscripten-module` export has a `workerd` condition wrangler // selects) — that build expects the WASM module to be supplied rather than // fetched/compiled at runtime. `newVariant(base, { wasmModule })` hands it the -// statically-imported, pre-compiled module, and `setQuickJSModule` makes every -// `makeQuickJsExecutor()` reuse it. Preloaded once per isolate. +// statically-imported, pre-compiled module. The worker entry registers this +// through `setQuickJSModuleLoader`, so the runtime builds it on the first +// execution and reuses it for the rest of the isolate. // --------------------------------------------------------------------------- -let preloaded: Promise | null = null; - -export const preloadQuickJs = (): Promise => { - if (!preloaded) { - const variant = newVariant(baseVariant, { wasmModule }); - preloaded = newQuickJSWASMModuleFromVariant(variant).then((mod) => { - setQuickJSModule(mod); - }); - } - return preloaded; -}; +export const loadQuickJs = (): Promise => + newQuickJSWASMModuleFromVariant(newVariant(baseVariant, { wasmModule })); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index 6ed8951936..9a59c8b5a2 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,5 +1,7 @@ import { WorkerEntrypoint } from "cloudflare:workers"; +import { setQuickJSModuleLoader } from "@executor-js/runtime-quickjs"; + import { makeCloudflareApp, reconcileInternalIntegrationsOnce } from "./app"; import { cloudflareAccessConfigErrorMessage, @@ -9,6 +11,12 @@ import { } from "./config"; import { mcpResourceFromPath } from "./mcp/resource"; +// Workers cannot fetch the default QuickJS .wasm, so every QuickJS user in this +// script (the fetch handlers and the MCP Durable Objects) resolves the +// WASM-inlined variant through this loader. The import stays dynamic so the +// engine is not instantiated or evaluated at startup. +setQuickJSModuleLoader(() => import("./quickjs").then((m) => m.loadQuickJs())); + // The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported // at the Worker entry module scope for the runtime to find them. export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; diff --git a/e2e/cloudflare/artifact-smoke-render.test.ts b/e2e/cloudflare/artifact-smoke-render.test.ts new file mode 100644 index 0000000000..1b0fb320a4 --- /dev/null +++ b/e2e/cloudflare/artifact-smoke-render.test.ts @@ -0,0 +1,32 @@ +// Cloudflare self-host: create-artifact runs its smoke render in QuickJS inside +// workerd. The render fails open when the sandbox is unavailable, so a created +// artifact proves nothing; a component that throws on first render being +// REFUSED proves the WASM-inlined QuickJS actually ran. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target } from "../src/services"; + +scenario( + "Cloudflare · create-artifact refuses a component that throws on first render", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const session = mcp.session(identity); + const suffix = randomBytes(4).toString("hex"); + + const refused = yield* session.call("create-artifact", { + code: `function App() { throw new Error("boom-${suffix}"); }`, + title: `Throws ${suffix}`, + description: "Should never be saved", + }); + + expect(refused.ok, "create-artifact refuses a first-render throw").toBe(false); + expect(refused.text, "the refusal carries the sandbox's own error").toContain(`boom-${suffix}`); + }), +); diff --git a/packages/kernel/runtime-quickjs/src/index.ts b/packages/kernel/runtime-quickjs/src/index.ts index d8a8e48dd3..5d517b964b 100644 --- a/packages/kernel/runtime-quickjs/src/index.ts +++ b/packages/kernel/runtime-quickjs/src/index.ts @@ -35,8 +35,32 @@ export const setQuickJSModule = (mod: QuickJSWASMModule) => { preloadedModule = mod; }; -const resolveQuickJS = (): Promise => - preloadedModule ? Promise.resolve(preloadedModule) : getQuickJS(); +export type QuickJSModuleLoader = () => Promise; + +let moduleLoader: QuickJSModuleLoader | null = null; +let loadingModule: Promise | null = null; + +// Register a lazy source for the module (Workers must supply a WASM-inlined +// variant). The loader first runs on the first resolve, so hosts pay the +// instantiation cost only when code actually executes. +export const setQuickJSModuleLoader = (loader: QuickJSModuleLoader) => { + moduleLoader = loader; + loadingModule = null; +}; + +const resolveQuickJS = (): Promise => { + if (preloadedModule) return Promise.resolve(preloadedModule); + if (!moduleLoader) return getQuickJS(); + if (!loadingModule) { + // oxlint-disable-next-line executor/no-promise-catch -- boundary: this module has no Effect context; reset the memo on failure so the next resolve retries instead of caching a permanent rejection + loadingModule = moduleLoader().catch((err: unknown) => { + loadingModule = null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: rethrow so the caller still observes the original failure + throw err; + }); + } + return loadingModule; +}; /** * Run a sealed, self-contained bundle in a QuickJS sandbox. @@ -44,8 +68,8 @@ const resolveQuickJS = (): Promise => * A sibling of `makeQuickJsExecutor` for validation work rather than user * execution — no tool bridge, no code recovery, no metering above it. See * `sealed-bundle.ts` for why that separation matters. The WASM module is - * resolved the same way, so a host that called `setQuickJSModule` (Workers must) - * is honoured here too. + * resolved the same way, so a host that called `setQuickJSModule` or + * `setQuickJSModuleLoader` (Workers must) is honoured here too. */ export const executeSealedBundle = ( options: SealedBundleOptions, diff --git a/packages/kernel/runtime-quickjs/src/module-loader.test.ts b/packages/kernel/runtime-quickjs/src/module-loader.test.ts new file mode 100644 index 0000000000..225191e507 --- /dev/null +++ b/packages/kernel/runtime-quickjs/src/module-loader.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest"; +// oxlint-disable-next-line executor/no-vitest-import -- vi.resetModules gives each test fresh module-level loader state +import { beforeEach, vi } from "vitest"; +import * as Effect from "effect/Effect"; +import { getQuickJS } from "quickjs-emscripten"; + +// The loader seam is module-level state, so every test imports a fresh copy. +const freshRuntime = () => import("./index"); + +const run = (runtime: Awaited>) => + Effect.runPromise(runtime.executeSealedBundle({ bundle: "globalThis.value = 1;", call: "'ok'" })); + +describe("setQuickJSModuleLoader", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("does not call the loader until the first resolve", async () => { + const runtime = await freshRuntime(); + let calls = 0; + runtime.setQuickJSModuleLoader(() => { + calls += 1; + return getQuickJS(); + }); + + expect(calls).toBe(0); + await run(runtime); + expect(calls).toBe(1); + }); + + it("calls the loader once across many resolves", async () => { + const runtime = await freshRuntime(); + let calls = 0; + runtime.setQuickJSModuleLoader(() => { + calls += 1; + return getQuickJS(); + }); + + await Promise.all([run(runtime), run(runtime), run(runtime)]); + await run(runtime); + + expect(calls).toBe(1); + }); + + it("prefers a preloaded module over the loader", async () => { + const runtime = await freshRuntime(); + let calls = 0; + runtime.setQuickJSModuleLoader(() => { + calls += 1; + return getQuickJS(); + }); + runtime.setQuickJSModule(await getQuickJS()); + + await run(runtime); + + expect(calls).toBe(0); + }); + + it("retries the loader on the next resolve after a rejected load", async () => { + const runtime = await freshRuntime(); + let calls = 0; + runtime.setQuickJSModuleLoader(() => { + calls += 1; + return calls === 1 ? Promise.reject(new Error("wasm load failed")) : getQuickJS(); + }); + + await expect(run(runtime)).rejects.toThrow("wasm load failed"); + await expect(run(runtime)).resolves.toBe("ok"); + + expect(calls).toBe(2); + }); + + it("serves the sealed bundle runner and the executor from the same loader", async () => { + const runtime = await freshRuntime(); + let calls = 0; + runtime.setQuickJSModuleLoader(() => { + calls += 1; + return getQuickJS(); + }); + + await run(runtime); + const executed = await Effect.runPromise( + runtime.makeQuickJsExecutor().execute("return 1 + 2", { invoke: () => Effect.void }), + ); + + expect(executed.result).toBe(3); + expect(calls).toBe(1); + }); +}); diff --git a/packages/kernel/runtime-quickjs/src/sealed-bundle.ts b/packages/kernel/runtime-quickjs/src/sealed-bundle.ts index 56619df68b..0d30ff738c 100644 --- a/packages/kernel/runtime-quickjs/src/sealed-bundle.ts +++ b/packages/kernel/runtime-quickjs/src/sealed-bundle.ts @@ -75,8 +75,9 @@ const DEFAULT_MAX_STACK_SIZE_BYTES = 2 * 1024 * 1024; const BUNDLE_FILENAME = "executor-sealed-bundle.js"; const CALL_FILENAME = "executor-sealed-call.js"; -/** Set by `setQuickJSModule` in this package's main entry. Read lazily so a - * host that preloads a custom WASM module (Workers must) is honoured. */ +/** Supplied by this package's main entry (`setQuickJSModule` or + * `setQuickJSModuleLoader`). Read lazily so a host that provides a custom WASM + * module (Workers must) is honoured. */ type ModuleResolver = () => Promise; const errorMessage = (cause: unknown): string =>