diff --git a/crates/js-component-bindgen/src/function_bindgen.rs b/crates/js-component-bindgen/src/function_bindgen.rs index 7efce5ab5..f8af8aad3 100644 --- a/crates/js-component-bindgen/src/function_bindgen.rs +++ b/crates/js-component-bindgen/src/function_bindgen.rs @@ -1576,6 +1576,14 @@ impl Bindgen for FunctionBindgen<'_> { Instruction::CallWasm { name, sig } => { let debug_log_fn = self.intrinsic(Intrinsic::DebugLog); + let get_component_state = self.intrinsic(Intrinsic::Component( + ComponentIntrinsic::GetOrCreateAsyncState, + )); + let component_idx_expr = self + .component_state + .as_ref() + .map(|state| state.get_js_exprs().component_idx) + .unwrap_or_else(|| "-1".into()); let has_post_return = self.post_return.is_some(); let is_async = self.is_async; uwriteln!( @@ -1594,6 +1602,13 @@ impl Bindgen for FunctionBindgen<'_> { // (if we're calling into wasm then we know it was not) uwriteln!(self.src, "const hostProvided = false;"); + // Argument validation and lowering happen before this instruction. Only + // disable the instance once execution is about to enter the component. + uwriteln!( + self.src, + "{get_component_state}({component_idx_expr}).throwIfTrapped();" + ); + // Inject machinery for starting a 'current' task // (this will define the 'task' variable) self.start_current_task(inst); @@ -1689,6 +1704,7 @@ impl Bindgen for FunctionBindgen<'_> { taskID: task.id(), err, }}); + {get_component_state}({component_idx_expr}).markTrapped(err); task.setErrored(err); task.reject(err); task.exit(); @@ -1706,6 +1722,7 @@ impl Bindgen for FunctionBindgen<'_> { taskID: task.id(), err, }}); + {get_component_state}({component_idx_expr}).markTrapped(err); task.setErrored(err); task.reject(err); task.exit(); @@ -1791,6 +1808,9 @@ impl Bindgen for FunctionBindgen<'_> { // Call to an imported interface (normally provided by the host) Instruction::CallInterface { func, async_ } => { let debug_log_fn = self.intrinsic(Intrinsic::DebugLog); + let get_component_state = self.intrinsic(Intrinsic::Component( + ComponentIntrinsic::GetOrCreateAsyncState, + )); let start_current_task_fn = self.intrinsic(Intrinsic::AsyncTask( AsyncTaskIntrinsic::CreateNewCurrentTask, )); @@ -1935,6 +1955,7 @@ impl Bindgen for FunctionBindgen<'_> { subtaskID: task.getParentSubtask()?.id(), err, }}); + {get_component_state}({component_idx_expr}).markTrapped(err); task.setErrored(err); task.reject(err); task.exit(); @@ -1953,6 +1974,7 @@ impl Bindgen for FunctionBindgen<'_> { subtaskID: task.getParentSubtask()?.id(), err, }}); + {get_component_state}({component_idx_expr}).markTrapped(err); task.setErrored(err); task.reject(err); task.exit(); @@ -2016,6 +2038,7 @@ impl Bindgen for FunctionBindgen<'_> { try {{ ret = {{ tag: 'ok', val: {call} }}; }} catch (e) {{ + if ({get_component_state}({component_idx_expr}).markTrapped(e)) {{ throw e; }} ret = {{ tag: 'err', val: {err_payload}(e) }}; }} "#, diff --git a/crates/js-component-bindgen/src/intrinsics/component.rs b/crates/js-component-bindgen/src/intrinsics/component.rs index a3aecbf6a..d4e7fcf93 100644 --- a/crates/js-component-bindgen/src/intrinsics/component.rs +++ b/crates/js-component-bindgen/src/intrinsics/component.rs @@ -136,6 +136,7 @@ impl ComponentIntrinsic { let promise_with_resolvers_fn = Intrinsic::PromiseWithResolversPonyfill.name(); let stream_readable_end_class = Intrinsic::AsyncStream(AsyncStreamIntrinsic::StreamReadableEndClass).name(); + let trap_error_class = Intrinsic::TrapError.name(); output.push_str(&format!( r#" @@ -161,6 +162,7 @@ impl ComponentIntrinsic { #suspendedTasksByTaskID = new Map(); #suspendedTaskIDs = []; #errored = null; + #trapped = null; #backpressure = 0; #backpressureWaiters = 0n; @@ -197,6 +199,19 @@ impl ComponentIntrinsic { this.#errored = err; }} + markTrapped(err) {{ + if (!(err instanceof {trap_error_class} || err instanceof WebAssembly.RuntimeError)) {{ + return false; + }} + {debug_log_fn}('[{component_async_state_class}#markTrapped()] component trapped', {{ err, componentIdx: this.#componentIdx }}); + if (this.#trapped === null) {{ this.#trapped = err; }} + return true; + }} + + throwIfTrapped() {{ + if (this.#trapped !== null) {{ throw this.#trapped; }} + }} + callingSyncImport(val) {{ if (val === undefined) {{ return this.#callingAsyncImport; }} if (typeof val !== 'boolean') {{ throw new TypeError('invalid setting for async import'); }} diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 784e70c05..d78cce058 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1409,9 +1409,10 @@ pub fn render_intrinsics(args: RenderIntrinsicsArgs) -> Source { if args.intrinsics.contains(&Intrinsic::Component( ComponentIntrinsic::ComponentAsyncStateClass, )) { - args.intrinsics.extend([&Intrinsic::AsyncStream( - AsyncStreamIntrinsic::GlobalStreamMap, - )]); + args.intrinsics.extend([ + &Intrinsic::TrapError, + &Intrinsic::AsyncStream(AsyncStreamIntrinsic::GlobalStreamMap), + ]); } if args diff --git a/docs/src/advanced/detecting-traps.md b/docs/src/advanced/detecting-traps.md index 69b61d583..a7b3f5680 100644 --- a/docs/src/advanced/detecting-traps.md +++ b/docs/src/advanced/detecting-traps.md @@ -4,8 +4,8 @@ While some errors are represented at the WIT type level and expected, some inter may cause a component instance to [trap][wiki-trap]. Jco-generated bindings report the component-model traps they detect with the exported `_util.TrapError` class. -**After an instance has trapped, the component instance should no longer be used, -and any subsequent use (successful or otherwise) is undefined behavior**. +After an instance has trapped, Jco marks the component instance as unusable. Any subsequent +call throws the original trap without re-entering the component. To detect these traps, use the `_util.TrapError` class: @@ -21,7 +21,7 @@ try { } catch (err) { if (err instanceof _util.TrapError) { console.error(`TRAP: ${err}`); - // avoid continuing to use the instance + // The instance is now disabled and cannot be called again. } else { // Other exceptions are unexpected and should be handled separately. throw err; diff --git a/packages/jco-transpile/test/transpile.ts b/packages/jco-transpile/test/transpile.ts index 82156f43d..b51b31140 100644 --- a/packages/jco-transpile/test/transpile.ts +++ b/packages/jco-transpile/test/transpile.ts @@ -11,7 +11,7 @@ import { readComponentBytes } from './helpers.js'; // - (2025/02/04) increased due to stabilization changes for async tasks // - (2025/12/16) increased due to additional async impl // - (2026/07/02) increased due to async launch (and actually enabling the tests) -const FLAVORFUL_WASM_TRANSPILED_CODE_CHAR_LIMIT = 185_000; +const FLAVORFUL_WASM_TRANSPILED_CODE_CHAR_LIMIT = 190_000; suite('Transpile', async () => { const flavorfulWasmBytes = await readComponentBytes(