diff --git a/crates/js-component-bindgen/src/intrinsics/component.rs b/crates/js-component-bindgen/src/intrinsics/component.rs index 080b04c97..aef0264b8 100644 --- a/crates/js-component-bindgen/src/intrinsics/component.rs +++ b/crates/js-component-bindgen/src/intrinsics/component.rs @@ -161,6 +161,21 @@ impl ComponentIntrinsic { if (unresolvedRoots.size === 0) {{ return; }} const err = new {runtime_error_class}('wasm trap: deadlock detected: event loop cannot make further progress'); + // Which tasks were waiting, and on whose behalf. The message stays + // exactly what the Canonical ABI calls for, so this rides alongside + // it: a deadlock reported from a real program is otherwise a bare + // sentence, and the state that produced it is gone by the time + // anyone reads the failure. + err.deadlockDetail = {{ + pendingHostOperations: {store_async_state}.pendingHostOperations, + suspendedTasks: [...suspendedTasks].map((task) => ({{ + taskID: task.id(), + componentIdx: task.componentIdx(), + state: task.state(), + rootTaskID: task.getRootTask().id(), + }})), + unresolvedRootTaskIDs: [...unresolvedRoots].map((root) => root.id()), + }}; {store_trap}.error = err; for (const root of unresolvedRoots) {{ root.setErrored(err); diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 6b4b85047..fad8d4f85 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1659,6 +1659,21 @@ mod tests { assert!(check.contains("task.reject(err);")); } + #[test] + fn detected_deadlocks_carry_the_state_that_produced_them() { + let check = + render_intrinsic_body(Intrinsic::Component(ComponentIntrinsic::CheckForDeadlock)); + + // The message is what the Canonical ABI asks for and is asserted on elsewhere, so + // the state that produced the deadlock rides beside it rather than inside it. + assert!(check.contains("err.deadlockDetail = {")); + assert!(check.contains("pendingHostOperations: STORE_ASYNC_STATE.pendingHostOperations,")); + assert!(check.contains("taskID: task.id(),")); + assert!(check.contains("componentIdx: task.componentIdx(),")); + assert!(check.contains("rootTaskID: task.getRootTask().id(),")); + assert!(check.contains("unresolvedRootTaskIDs:")); + } + #[test] fn host_async_operations_suppress_deadlock_detection() { let tracker = diff --git a/packages/jco-transpile/test/p3/cli-runner.js b/packages/jco-transpile/test/p3/cli-runner.js index 38f48497b..8655ec6ed 100644 --- a/packages/jco-transpile/test/p3/cli-runner.js +++ b/packages/jco-transpile/test/p3/cli-runner.js @@ -17,6 +17,12 @@ const WASI_CLI_RUN_EXPORT = 'wasi:cli/run@0.3.0'; function reportUnhandled(error) { console.error(error?.stack ?? error); + // A detected deadlock says only that the event loop stopped. What was waiting is + // attached to the error, and this is the last chance to print it: the run ends here, + // and on CI the captured output is all anyone gets. + if (error?.deadlockDetail) { + console.error(`deadlock detail: ${JSON.stringify(error.deadlockDetail, null, 2)}`); + } process.exit(1); }