Skip to content
Open
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
738 changes: 395 additions & 343 deletions package-lock.json

Large diffs are not rendered by default.

96 changes: 47 additions & 49 deletions test/tests/panic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@
// First, detect which panic mode we're running in by checking the error message
const detectResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
const detectText = await detectResp.text();

// panic=unwind mode returns "PanicError:" in the response
// panic=abort mode returns "Workers runtime canceled"
const isPanicUnwind = detectText.includes("PanicError:");

if (isPanicUnwind) {
// ===== PANIC=UNWIND MODE TESTS =====
// In this mode, panics are caught and converted to JS errors.
// The Worker continues without reinitialization.

// Test 1: Basic panic recovery - counter should NOT reset after panic
// We use a unique durable object ID to get fresh state
{
const uniqueId = `UNWIND_${Date.now()}_${Math.random()}`;

// Call the durable object twice to establish a counter
const resp1 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
const text1 = await resp1.text();
Expand Down Expand Up @@ -54,7 +54,7 @@
// Test 2: Multiple panics don't affect subsequent requests
{
const uniqueId = `UNWIND2_${Date.now()}_${Math.random()}`;

const resp1 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
const match1 = (await resp1.text()).match(/unstored_count: (\d+)/);
const count1 = match1 ? parseInt(match1[1]) : 0;
Expand Down Expand Up @@ -82,14 +82,31 @@
];

const responses = await Promise.all(requests);

// First should be 500 (panic), others should succeed
expect(responses[0].status).toBe(500);
expect(await responses[0].text()).toContain("PanicError:");
expect(responses[1].status).toBe(200);
expect(responses[2].status).toBe(200);
}

// Test 4: JS error recovery - counter should NOT reset after a JS error
{
const uniqueId = `JSERR_${Date.now()}_${Math.random()}`;

const resp1 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
const match1 = (await resp1.text()).match(/unstored_count: (\d+)/);
const count1 = match1 ? parseInt(match1[1]) : 0;

const jsErrorResp = await mf.dispatchFetch(`${mfUrl}test-js-error`);
expect(jsErrorResp.status).toBe(500);

const resp2 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
const match2 = (await resp2.text()).match(/unstored_count: (\d+)/);
const count2 = match2 ? parseInt(match2[1]) : 0;
expect(count2).toBe(count1 + 1);
}

} else {
// ===== PANIC=ABORT MODE TESTS (default) =====
// In this mode, panics cause "Workers runtime canceled" and WASM reinitializes.
Expand Down Expand Up @@ -163,57 +180,38 @@
expect(recoveryResp.status).toBe(200);
}
}
}

// explicit abort() recovery test
{
await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
expect(await resp.text()).toContain("unstored_count:");

const abortResp = await mf.dispatchFetch(`${mfUrl}test-abort`);
expect(abortResp.status).toBe(500);

const abortText = await abortResp.text();
expect(abortText).toContain("Workers runtime canceled");

const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
expect(await normalResp.text()).toContain("unstored_count: 1");
}

// JS error recovery test
// TODO: figure out how to achieve this one. Hard part is global error handler
// will need to detect JS errors, not just WebAssembly.RuntimeError, which
// may over-classify.
// {
// await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
// const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
// expect(await resp.text()).toContain("unstored_count:");
// // explicit abort() recovery test
// {
// await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
// const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
// expect(await resp.text()).toContain("unstored_count:");

// const jsErrorResp = await mf.dispatchFetch(`${mfUrl}test-js-error`);
// expect(jsErrorResp.status).toBe(500);
// const abortResp = await mf.dispatchFetch(`${mfUrl}test-abort`);
// expect(abortResp.status).toBe(500);

// const jsErrorText = await jsErrorResp.text();
// expect(jsErrorText).toContain("Workers runtime canceled");
// const abortText = await abortResp.text();
// expect(abortText).toContain("Workers runtime canceled");

// const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
// expect(await normalResp.text()).toContain("unstored_count: 1");
// }
// const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
// expect(await normalResp.text()).toContain("unstored_count: 1");
// }

// out of memory recovery test
{
await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
expect(await resp.text()).toContain("unstored_count:");
// out of memory recovery test
{
await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
expect(await resp.text()).toContain("unstored_count:");

const oomResp = await mf.dispatchFetch(`${mfUrl}test-oom`);
expect(oomResp.status).toBe(500);
const oomResp = await mf.dispatchFetch(`${mfUrl}test-oom`);
expect(oomResp.status).toBe(500);

const oomText = await oomResp.text();
expect(oomText).toContain("Workers runtime canceled");
const oomText = await oomResp.text();
expect(oomText).toContain("Workers runtime canceled");

const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
expect(await normalResp.text()).toContain("unstored_count: 1");
}
const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
expect(await normalResp.text()).toContain("unstored_count: 1");

Check failure on line 214 in test/tests/panic.spec.ts

View workflow job for this annotation

GitHub Actions / Test (panic-unwind)

tests/panic.spec.ts > Panic Hook with WASM Reinitialization > panic recovery tests

AssertionError: expected 'Error: Error: Invalid stale object fr…' to contain 'unstored_count: 1' Expected: "unstored_count: 1" Received: "Error: Error: Invalid stale object from previous Wasm instance - Cause: Error: Invalid stale object from previous Wasm instance" ❯ tests/panic.spec.ts:214:39
}
}, 20_000);
});
4 changes: 2 additions & 2 deletions test/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ binding = 'DELETE_BUCKET'
bucket_name = 'delete-bucket'
preview_bucket_name = 'delete-bucket'

[build]
command = "WASM_BINDGEN_BIN=../wasm-bindgen/target/debug/wasm-bindgen ../target/debug/worker-build --release"
# [build]
# command = "WASM_BINDGEN_BIN=../wasm-bindgen/target/debug/wasm-bindgen ../target/debug/worker-build --release"

[[migrations]]
tag = "v1"
Expand Down
86 changes: 86 additions & 0 deletions worker-build/src/js/shim-unwind.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Simplified shim for panic=unwind builds.
//
// With panic=unwind, wasm-bindgen automatically handles:
// - Termination detection via Wasm-level catch wrappers (generated when
// __instance_terminated is present in the Wasm binary)
// - Automatic instance reset via __wbg_termination_guard() at every export
// - Instance ID tracking for stale object detection
//
// The only JS-side concern remaining is re-constructing Durable Object class
// instances after a Wasm reset, since the Workers runtime reuses the same
// JS object across requests.
//
// The Entrypoint class is exported directly (not proxied) because its
// prototype methods delegate to `exports.*`, which are live ESM bindings
// that automatically resolve to the new instance after __wbg_reset_state().

import { WorkerEntrypoint } from "cloudflare:workers";
import * as exports from "./index.js";

Error.stackTraceLimit = 100;

if (!console.createTask) console.createTask = () => ({ run: (fn) => fn() });

// Shared state object from the worker crate's inline_js snippet. The object
// reference is grabbed once here (single Wasm hop at module load); after that
// every read of `reinitState.id` is a plain JS property access — no Wasm
// boundary crossing on the hot path.
const reinitState = exports.__worker_reinit_state();

class Entrypoint extends WorkerEntrypoint {}

$HANDLERS

export default Entrypoint;

// ---------------------------------------------------------------------------
// Durable Object class proxy
//
// After a Wasm reset, class instances from the previous instance become stale.
// The worker crate maintains a generation counter (`reinitState.id`) in a
// shared JS object that is bumped by a set_on_reinit hook after each reset.
// The proxy snapshots the counter at construction time and compares before
// each method call; a mismatch means the Wasm instance was reset and the DO
// must be re-constructed.
// ---------------------------------------------------------------------------

const instanceProxyHooks = {
set: (target, prop, value, receiver) => Reflect.set(target.instance, prop, value, receiver),
has: (target, prop) => Reflect.has(target.instance, prop),
deleteProperty: (target, prop) => Reflect.deleteProperty(target.instance, prop),
getPrototypeOf: (target) => Reflect.getPrototypeOf(target.instance),
setPrototypeOf: (target, proto) => Reflect.setPrototypeOf(target.instance, proto),
isExtensible: (target) => Reflect.isExtensible(target.instance),
preventExtensions: (target) => Reflect.preventExtensions(target.instance),
getOwnPropertyDescriptor: (target, prop) => Reflect.getOwnPropertyDescriptor(target.instance, prop),
defineProperty: (target, prop, descriptor) => Reflect.defineProperty(target.instance, prop, descriptor),
ownKeys: (target) => Reflect.ownKeys(target.instance),
};

const classProxyHooks = {
construct(ctor, args, newTarget) {
const target = {
instance: Reflect.construct(ctor, args, newTarget),
instanceId: reinitState.id,
ctor,
args,
newTarget,
};
return new Proxy(target, {
...instanceProxyHooks,
get(target, prop, receiver) {
if (target.instanceId !== reinitState.id) {
target.instance = Reflect.construct(target.ctor, target.args, target.newTarget);
target.instanceId = reinitState.id;
}
const original = Reflect.get(target.instance, prop, receiver);
if (typeof original !== 'function') return original;
return new Proxy(original, {
apply(fn, thisArg, argArray) {
return fn.apply(target.instance, argArray);
}
});
}
});
}
};
24 changes: 13 additions & 11 deletions worker-build/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use anyhow::{Context, Result};
use clap::Parser;

const SHIM_FILE: &str = include_str!("./js/shim.js");
const SHIM_UNWIND_FILE: &str = include_str!("./js/shim-unwind.js");

pub(crate) mod binary;
mod build;
Expand Down Expand Up @@ -109,16 +110,17 @@ pub fn main() -> Result<()> {
}

if module_target {
let shim = SHIM_FILE
.replace("$HANDLERS", &generate_handlers(&staging_dir)?)
.replace(
"$PANIC_CRITICAL_ERROR",
if builder.panic_unwind {
""
} else {
"criticalError = true;"
},
);
let handlers = generate_handlers(&staging_dir)?;
let shim = if builder.panic_unwind {
// Simplified shim: wasm-bindgen auto-detects __instance_terminated
// in the Wasm binary and generates catch wrappers that handle
// termination detection and instance reset at the Wasm boundary.
SHIM_UNWIND_FILE.replace("$HANDLERS", &handlers)
} else {
SHIM_FILE
.replace("$HANDLERS", &handlers)
.replace("$PANIC_CRITICAL_ERROR", "criticalError = true;")
};
let shim_path = output_path(&staging_dir, "shim.js");
fs::write(&shim_path, shim)
.with_context(|| format!("Failed to write {}", shim_path.display()))?;
Expand Down Expand Up @@ -204,7 +206,7 @@ fn generate_handlers(out_dir: &Path) -> Result<String> {
Ok(handlers)
}

static SYSTEM_FNS: &[&str] = &["__wbg_reset_state", "setPanicHook"];
static SYSTEM_FNS: &[&str] = &["__wbg_reset_state", "setPanicHook", "__worker_reinit_state"];

fn add_export_wrappers(out_dir: &Path) -> Result<()> {
let index_path = output_path(out_dir, "index.js");
Expand Down
1 change: 1 addition & 0 deletions worker-macros/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream {

let wrapper_fn = quote! {
pub fn #wrapper_fn_ident() {
::worker::reinit_support::init();
// call the original fn
#input_fn_ident()
}
Expand Down
14 changes: 7 additions & 7 deletions worker-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use proc_macro::TokenStream;
///
/// ## Example
///
/// ```rust
/// ```ignore
/// #[durable_object]
/// pub struct Chatroom {
/// users: Vec<User>,
Expand Down Expand Up @@ -44,7 +44,7 @@ use proc_macro::TokenStream;
/// * `alarm`: with [Alarms API](https://developers.cloudflare.com/durable-objects/examples/alarms-api/)
/// * `websocket`: [WebSocket server](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/)
///
/// ```rust
/// ```ignore
/// #[durable_object(fetch)]
/// pub struct Chatroom {
/// users: Vec<User>,
Expand Down Expand Up @@ -77,7 +77,7 @@ pub fn durable_object(attr: TokenStream, item: TokenStream) -> TokenStream {
///
/// At a high-level, the `fetch` handler is used to handle incoming HTTP requests. The function signature for a `fetch` handler is conceptually something like:
///
/// ```rust
/// ```ignore
/// async fn fetch(req: impl From<web_sys::Request>, env: Env, ctx: Context) -> Result<impl Into<web_sys::Response>, impl Into<Box<dyn Error>>>
/// ```
///
Expand All @@ -89,7 +89,7 @@ pub fn durable_object(attr: TokenStream, item: TokenStream) -> TokenStream {
///
/// ### worker::{Request, Response}
///
/// ```rust
/// ```ignore
/// #[event(fetch, respond_with_errors)]
/// async fn main(req: worker::Request, env: Env, ctx: Context) -> Result<worker::Response> {
/// worker::Response::ok("Hello World (worker type)")
Expand All @@ -98,7 +98,7 @@ pub fn durable_object(attr: TokenStream, item: TokenStream) -> TokenStream {
///
/// ### web_sys::{Request, Response}
///
/// ```rust
/// ```ignore
/// #[event(fetch, respond_with_errors)]
/// async fn main(req: web_sys::Request, env: Env, ctx: Context) -> Result<web_sys::Response> {
/// Ok(web_sys::Response::new_with_opt_str(Some("Hello World (native type)".into())).unwrap())
Expand All @@ -107,7 +107,7 @@ pub fn durable_object(attr: TokenStream, item: TokenStream) -> TokenStream {
///
/// ### axum (with `http` feature)
///
/// ```rust
/// ```ignore
/// #[event(fetch)]
/// async fn fetch(req: HttpRequest, env: Env, ctx: Context) -> Result<http::Response<axum::body::Body>> {
/// Ok(router().call(req).await?)
Expand All @@ -124,7 +124,7 @@ pub fn event(attr: TokenStream, item: TokenStream) -> TokenStream {
/// This is useful for implementing async handlers in frameworks which
/// expect the handler to be `Send`, such as `axum`.
///
/// ```rust
/// ```ignore
/// #[worker::send]
/// async fn foo() {
/// // JsFuture is !Send
Expand Down
2 changes: 2 additions & 0 deletions worker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ mod context;
mod cors;
pub mod crypto;
pub mod panic_abort;
#[doc(hidden)]
pub mod reinit_support;
// Require pub module for macro export
#[cfg(feature = "d1")]
/// **Requires** `d1` feature.
Expand Down
Loading
Loading