Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions .changeset/fix-dynamic-import-cross-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@cloudflare/vitest-pool-workers": patch
---

fix: Support dynamic `import()` inside entrypoint and Durable Object handlers

Previously, calling `exports.default.fetch()` or `SELF.fetch()` on a worker whose handler used a dynamic `import()` would hang and fail with "Cannot perform I/O on behalf of a different Durable Object". This happened because the module runner's transport — which communicates over a WebSocket owned by the runner Durable Object — was invoked from a different DO context.

The fix patches the module runner's transport via the `onModuleRunner` hook so that all `invoke()` calls are routed through the runner DO's I/O context, regardless of where the `import()` originates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function greet(name: string): string {
return `Hello, ${name}!`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Durable Object that uses dynamic import() in fetch handler.
// Regression test for https://github.com/cloudflare/workers-sdk/issues/5387
export class GreeterDO implements DurableObject {
constructor(readonly state: DurableObjectState) {}
async fetch(request: Request): Promise<Response> {
const { greet } = await import("./greeting");
return new Response(greet("DO"));
}
}

export default {
async fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise<Response> {
// Dynamic import inside a fetch handler — this is the pattern that
// triggers the cross-DO I/O violation in vitest-pool-workers 0.13.x
// when called via `exports.default.fetch()` in tests.
// See: https://github.com/cloudflare/workers-sdk/issues/12924
const { greet } = await import("./greeting");
return new Response(greet("World"));
},
} satisfies ExportedHandler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.workerd.json",
"include": ["./**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./index");
}
}
interface Env extends Cloudflare.Env {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { env, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test";
Comment thread
penalosa marked this conversation as resolved.
Outdated
import { exports } from "cloudflare:workers";
import { it } from "vitest";

// Regression test for https://github.com/cloudflare/workers-sdk/issues/12924
//
// Calling exports.default.fetch() on a worker whose fetch handler uses a
// dynamic import() would hang with "Cannot perform I/O on behalf of a
// different Durable Object". Pre-loading the module (e.g. via a static
// import of the worker) masks the bug by caching the module.
it("exports.default.fetch() with dynamic import()", async ({ expect }) => {
const response = await exports.default.fetch(
new Request("https://example.com/")
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("Hello, World!");
});

// Regression test for https://github.com/cloudflare/workers-sdk/issues/5387
//
// Dynamic import() inside a Durable Object fetch handler has the same
// cross-context I/O violation when the module isn't already cached.
it("Durable Object fetch with dynamic import()", async ({ expect }) => {
const id = env.GREETER.idFromName("test");
const stub = env.GREETER.get(id);
const response = await stub.fetch(new Request("https://example.com/"));
expect(response.status).toBe(200);
expect(await response.text()).toBe("Hello, DO!");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.workerd-test.json",
"include": ["./**/*.ts", "../src/**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.node.json",
"include": ["./*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [
cloudflareTest({
wrangler: {
configPath: "./wrangler.jsonc",
},
}),
],

test: {},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "dynamic-import",
"main": "src/index.ts",
// don't provide compatibility_date so that vitest will infer the latest one
"durable_objects": {
"bindings": [{ "name": "GREETER", "class_name": "GreeterDO" }],
},
"migrations": [{ "tag": "v1", "new_classes": ["GreeterDO"] }],
}
32 changes: 24 additions & 8 deletions packages/vitest-pool-workers/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,14 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject
try {
poolSocket.send(structuredSerializableStringify(response));
} catch (error) {
// If the user tried to perform a dynamic `import()` or `console.log()`
Comment thread
penalosa marked this conversation as resolved.
// from inside a `export default { fetch() { ... } }` handler using `SELF`
// or from inside their own Durable Object, Vitest will try to send an
// RPC message from a non-`RunnerObject` I/O context. There's nothing we
// can really do to prevent this: we want to run these things in different
// I/O contexts with the behaviour this causes. We'd still like to send
// the RPC message though, so if we detect this, we try resend the message
// from the runner object.
// If the user called `console.log()` or similar from inside an
// `export default { fetch() {} }` handler (via `SELF`/`exports`) or
// from inside their own Durable Object, Vitest will try to send an
// RPC message from a non-`RunnerObject` I/O context. We'd still like
// to send the message, so if we detect a cross-DO I/O error we
// resend from the runner object.
// (Dynamic `import()` cross-DO errors are handled separately by the
// transport patch in entrypoints.ts — see #12924.)
Comment thread
penalosa marked this conversation as resolved.
Outdated
Comment thread
penalosa marked this conversation as resolved.
Outdated
Comment thread
penalosa marked this conversation as resolved.
Outdated
if (isDifferentIOContextError(error)) {
const promise = runInRunnerObject(() => {
poolSocket.send(structuredSerializableStringify(response));
Expand All @@ -231,6 +231,22 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject
runTests: (state, traces) => runBaseTests("run", state, traces),
collectTests: (state, traces) => runBaseTests("collect", state, traces),
setup: setupEnvironment,
// Patch the module runner's transport so that `invoke()` calls always
// execute inside the Runner DO's I/O context. Without this, a dynamic
// `import()` inside an entrypoint handler (which runs in a *different*
// DO context) fails with "Cannot perform I/O on behalf of a different
// Durable Object". See: https://github.com/cloudflare/workers-sdk/issues/12924
onModuleRunner(moduleRunner: unknown) {
const runner = moduleRunner as {
transport?: { invoke?: (...args: unknown[]) => unknown };
};
if (runner.transport?.invoke) {
const originalInvoke = runner.transport.invoke.bind(runner.transport);
runner.transport.invoke = (...args: unknown[]) => {
return runInRunnerObject(() => originalInvoke(...args));
};
}
Comment thread
penalosa marked this conversation as resolved.
},
Comment thread
penalosa marked this conversation as resolved.
});

return new Response(null, { status: 101, webSocket: poolResponseSocket });
Expand Down
Loading