From b12165cf7a93c029dc1e63e93fa03271df6927d7 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 14 Sep 2026 16:17:28 +0000 Subject: [PATCH 01/11] fix(bindgen): deliver idle stream and future drop events --- .../src/intrinsics/mod.rs | 46 +- .../src/intrinsics/p3/async_future.rs | 50 +- .../src/intrinsics/p3/async_stream.rs | 103 +++- .../src/intrinsics/p3/waitable.rs | 3 - .../src/transpile_bindgen.rs | 9 +- .../wast/component-model/async/idle-drop.wast | 572 ++++++++++++++++++ .../test/fixtures/wast/upstream-manifest.json | 4 + .../test/p3/ported/component-model/wast.ts | 1 + 8 files changed, 753 insertions(+), 35 deletions(-) create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/idle-drop.wast diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 460549fdd..536776786 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -180,6 +180,11 @@ pub enum Intrinsic { /// path before falling back to a JSPI-suspending slow path. ConditionalSuspending2I32ToI32Fn, + /// Build an `(i32, i32, i32) -> i32` Wasm trampoline that calls a plain + /// fast path before falling back to a JSPI-suspending slow path when it + /// returns -2. + ConditionalSuspending3I32ToI32Fn, + /// Build an `(i32, i32, i32) -> ()` Wasm trampoline that calls a plain fast /// path before falling back to a JSPI-suspending slow path. ConditionalSuspending3I32ToVoidFn, @@ -1324,6 +1329,35 @@ impl Intrinsic { )); } + Self::ConditionalSuspending3I32ToI32Fn => { + let conditional_suspending_fn = + args.require_intrinsic(Self::ConditionalSuspending3I32ToI32Fn); + + output.push_str(&format!( + r#" + const {conditional_suspending_fn}Module = new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x08, 0x01, 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x01, + 0x7f, 0x02, 0x11, 0x02, 0x00, 0x04, 0x66, 0x61, 0x73, + 0x74, 0x00, 0x00, 0x00, 0x04, 0x73, 0x6c, 0x6f, 0x77, + 0x00, 0x00, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01, + 0x03, 0x72, 0x75, 0x6e, 0x00, 0x02, 0x0a, 0x21, 0x01, + 0x1f, 0x01, 0x01, 0x7f, 0x20, 0x00, 0x20, 0x01, 0x20, + 0x02, 0x10, 0x00, 0x22, 0x03, 0x41, 0x7e, 0x47, 0x04, + 0x7f, 0x20, 0x03, 0x05, 0x20, 0x00, 0x20, 0x01, 0x20, + 0x02, 0x10, 0x01, 0x0b, 0x0b, + ])); + + function {conditional_suspending_fn}(fast, slow) {{ + return new WebAssembly.Instance( + {conditional_suspending_fn}Module, + {{ '': {{ fast, slow: new WebAssembly.Suspending(slow) }} }}, + ).exports.run; + }} + "#, + )); + } + Self::ConditionalSuspending3I32ToVoidFn => { let conditional_suspending_fn = args.require_intrinsic(Self::ConditionalSuspending3I32ToVoidFn); @@ -1775,6 +1809,10 @@ mod tests { assert!(conditional.contains("new WebAssembly.Suspending(slow)")); assert!(conditional.contains("0x66, 0x61, 0x73, 0x74")); + let conditional = render_intrinsic_body(Intrinsic::ConditionalSuspending3I32ToI32Fn); + assert!(conditional.contains("new WebAssembly.Suspending(slow)")); + assert!(conditional.contains("0x41, 0x7e")); + let poll = render_intrinsic_body(Intrinsic::Waitable(WaitableIntrinsic::WaitableSetPoll)); assert!(poll.contains("deliverPendingCancel({ cancellable: isCancellable })")); @@ -2125,6 +2163,7 @@ mod tests { assert!(source.contains(&format!("function {}(", op.name()))); assert!(!source.contains(&format!("async function {}(", op.name()))); + assert!(source.contains("syncFastOnly")); } for end in [ @@ -2136,6 +2175,7 @@ mod tests { assert!(!source.contains("async copy(args) {")); assert!(source.contains("if (isAsync) {")); assert!(source.contains("return task.suspendUntil({")); + assert!(source.contains("if (deferSyncFinish) { await Promise.resolve(); }")); } } @@ -2153,14 +2193,15 @@ mod tests { ] { let source = render_intrinsic_body(Intrinsic::AsyncStream(intrinsic)); let busy_check = source - .find("if (streamEnd.isCopying() || streamEnd.hasPendingEvent()) {") - .expect("stream drop should reject an active or undelivered copy"); + .find("if (streamEnd.isCopying()) {") + .expect("stream drop should reject an active copy"); let removal = source .find("const removedStreamEnd = deleteStreamEnd(") .expect("stream drop should remove a validated idle end"); assert!(source.contains(&format!("throw new WebAssemblyRuntimeError('{error}');"))); assert!(busy_check < removal, "busy validation must precede removal"); + assert!(!source.contains("streamEnd.isCopying() || streamEnd.hasPendingEvent()")); assert!(source.contains("if (removedStreamEnd !== streamEnd) {")); } } @@ -2645,6 +2686,7 @@ impl Intrinsic { Self::SuspendingImportWrapperFn => "_suspendingImport", Self::ConditionalSuspending1I32ToI32Fn => "_conditionalSuspending1I32ToI32", Self::ConditionalSuspending2I32ToI32Fn => "_conditionalSuspending2I32ToI32", + Self::ConditionalSuspending3I32ToI32Fn => "_conditionalSuspending3I32ToI32", Self::ConditionalSuspending3I32ToVoidFn => "_conditionalSuspending3I32ToVoid", // Iteratively saved metadata diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs index c087260b9..6006fb33d 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs @@ -680,6 +680,8 @@ impl AsyncFutureIntrinsic { Self::FutureEndClass => { let debug_log_fn = render_args.require_intrinsic(Intrinsic::DebugLog); let future_end_class = render_args.require_intrinsic(Self::FutureEndClass); + let async_event_code_enum = + render_args.require_intrinsic(Intrinsic::AsyncEventCodeEnum); uwriteln!( output, @@ -705,6 +707,7 @@ impl AsyncFutureIntrinsic { #dropped = false; #isPeerDroppedFn; + #notifyPeerDroppedFn; constructor(args) {{ {debug_log_fn}('[{future_end_class}#constructor()] args', args); @@ -719,6 +722,10 @@ impl AsyncFutureIntrinsic { throw new TypeError('isPeerDroppedFn must be a function'); }} this.#isPeerDroppedFn = args.isPeerDroppedFn ?? (() => false); + if (args.notifyPeerDroppedFn !== undefined && typeof args.notifyPeerDroppedFn !== 'function') {{ + throw new TypeError('notifyPeerDroppedFn must be a function'); + }} + this.#notifyPeerDroppedFn = args.notifyPeerDroppedFn ?? (() => {{}}); }} getWaitable() {{ return this.#waitable; }} @@ -801,6 +808,21 @@ impl AsyncFutureIntrinsic { isDropped() {{ return this.#dropped; }} isPeerDropped() {{ return this.#isPeerDroppedFn(); }} + notifyDropped() {{ + if (this.isDropped() || this.isDoneState() || this.hasPendingEvent()) {{ return; }} + const eventCode = this.isReadable() + ? {async_event_code_enum}.FUTURE_READ + : {async_event_code_enum}.FUTURE_WRITE; + this.setPendingEvent(() => {{ + this.setCopyState({future_end_class}.CopyState.DONE); + return {{ + code: eventCode, + payload0: this.waitableIdx(), + payload1: {future_end_class}.CopyResult.DROPPED, + }}; + }}); + }} + drop() {{ if (this.isDropped()) {{ throw new Error('future already dropped'); }} @@ -812,6 +834,8 @@ impl AsyncFutureIntrinsic { }} this.#dropped = true; + this.#notifyPeerDroppedFn(); + this.#waitable.drop(); }} }} @@ -1372,6 +1396,7 @@ impl AsyncFutureIntrinsic { // from inside the guest. hostInjectFn: args.hostInjectFn, isPeerDroppedFn: () => this.#writeEnd?.isDropped() ?? false, + notifyPeerDroppedFn: () => this.#writeEnd?.notifyDropped(), }}); this.#writeEnd = new {write_end_class}({{ @@ -1382,6 +1407,7 @@ impl AsyncFutureIntrinsic { waitable: writeWaitable, hostOwned: true, isPeerDroppedFn: () => this.#readEnd.isDropped(), + notifyPeerDroppedFn: () => this.#readEnd.notifyDropped(), }}); }} @@ -1568,6 +1594,19 @@ impl AsyncFutureIntrinsic { throw new {runtime_error_class}({CANNOT_START_CONCURRENT_OPERATION:?}); }} + if (futureEnd.hasPendingEvent()) {{ + const {{ code, payload0: index, payload1: payload }} = futureEnd.getPendingEvent(); + if (code !== {event_code}) {{ + throw new Error(`mismatched event code [${{code}}] (expected {event_code})`); + }} + if (index !== futureEnd.waitableIdx()) {{ throw new Error('mismatched future end index'); }} + if (futureEnd.isWritable() && futureEnd.isPeerDropped()) {{ + futureEnd.setCopyState({future_end_class}.CopyState.DONE); + return {future_end_class}.CopyResult.DROPPED; + }} + return payload; + }} + futureEnd.{guest_op_fn}({{ componentIdx, stringEncoding, @@ -1591,6 +1630,10 @@ impl AsyncFutureIntrinsic { throw new Error(`mismatched event code [${{code}}] (expected {event_code})`); }} if (index !== futureEnd.waitableIdx()) {{ throw new Error('mismatched future end index'); }} + if (futureEnd.isWritable() && futureEnd.isPeerDropped()) {{ + futureEnd.setCopyState({future_end_base_class}.CopyState.DONE); + return {future_end_class}.CopyResult.DROPPED; + }} return payload; }}); }} @@ -1599,9 +1642,14 @@ impl AsyncFutureIntrinsic { const {{ code, payload0: index, payload1: payload }} = futureEnd.getPendingEvent(); if (code !== {event_code}) {{ throw new Error(`mismatched event code [${{code}}] (expected {event_code})`); - }} + }} if (index !== futureEnd.waitableIdx()) {{ throw new Error('mismatched future end index'); }} + if (futureEnd.isWritable() && futureEnd.isPeerDropped()) {{ + futureEnd.setCopyState({future_end_base_class}.CopyState.DONE); + return {future_end_class}.CopyResult.DROPPED; + }} + return payload; }} "# diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs index 374aa3076..b290ad5cc 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs @@ -550,6 +550,8 @@ impl AsyncStreamIntrinsic { Self::StreamEndClass => { let debug_log_fn = render_args.require_intrinsic(Intrinsic::DebugLog); let stream_end_class = render_args.require_intrinsic(Self::StreamEndClass); + let async_event_code_enum = + render_args.require_intrinsic(Intrinsic::AsyncEventCodeEnum); output.push_str(&format!( r#" class {stream_end_class} {{ @@ -576,9 +578,9 @@ impl AsyncStreamIntrinsic { #copyState = {stream_end_class}.CopyState.IDLE; - #dropped; - #setDroppedFn; - #isDroppedFn; + #dropped = false; + #isPeerDroppedFn; + #notifyPeerDroppedFn; target; @@ -595,15 +597,14 @@ impl AsyncStreamIntrinsic { this.#tableIdx = args.tableIdx; this.#waitable = args.waitable; - if (args.setDroppedFn && args.isDroppedFn) {{ - this.#setDroppedFn = args.setDroppedFn; - this.#isDroppedFn = args.isDroppedFn; - }} else if (args.setDroppedFn === undefined && args.isDroppedFn === undefined) {{ - this.#setDroppedFn = (v) => {{ this.#dropped = v; }}; - this.#isDroppedFn = () => {{ return this.#dropped; }}; - }} else {{ - throw new TypeError('setDroppedFn and isDroppedFn must both be specified or neither'); + if (args.isPeerDroppedFn !== undefined && typeof args.isPeerDroppedFn !== 'function') {{ + throw new TypeError('isPeerDroppedFn must be a function'); }} + if (args.notifyPeerDroppedFn !== undefined && typeof args.notifyPeerDroppedFn !== 'function') {{ + throw new TypeError('notifyPeerDroppedFn must be a function'); + }} + this.#isPeerDroppedFn = args.isPeerDroppedFn ?? (() => false); + this.#notifyPeerDroppedFn = args.notifyPeerDroppedFn ?? (() => {{}}); this.target = args.target; }} @@ -668,8 +669,23 @@ impl AsyncStreamIntrinsic { return event; }} - isDropped() {{ return this.#isDroppedFn(); }} - setDropped() {{ return this.#setDroppedFn(); }} + isDropped() {{ return this.#dropped; }} + isPeerDropped() {{ return this.#isPeerDroppedFn(); }} + + notifyDropped() {{ + if (this.isDropped() || this.isDoneState() || this.hasPendingEvent()) {{ return; }} + const eventCode = this.isReadable() + ? {async_event_code_enum}.STREAM_READ + : {async_event_code_enum}.STREAM_WRITE; + this.setPendingEvent(() => {{ + this.setCopyState({stream_end_class}.CopyState.DONE); + return {{ + code: eventCode, + payload0: this.waitableIdx(), + payload1: {stream_end_class}.CopyResult.DROPPED, + }}; + }}); + }} drop(opts = {{}}) {{ {debug_log_fn}('[{stream_end_class}#drop()]', {{ @@ -687,7 +703,8 @@ impl AsyncStreamIntrinsic { return; }} - this.setDropped(); + this.#dropped = true; + this.#notifyPeerDroppedFn(); if (this.#waitable) {{ const w = this.#waitable; if (opts.allowPendingEvent) {{ @@ -1067,6 +1084,7 @@ impl AsyncStreamIntrinsic { rejectLength, elemMeta, dropAfterCopy, + deferSyncFinish, }} = args; if (eventCode === undefined) {{ throw new TypeError('missing/invalid event code'); }} @@ -1087,7 +1105,19 @@ impl AsyncStreamIntrinsic { copyElemMeta.getReallocFn = args.getReallocFn; }} - if (this.isDropped()) {{ + if (this.hasPendingEvent()) {{ + const event = this.getPendingEvent(); + if (event.code !== eventCode || event.payload0 !== this.waitableIdx()) {{ + throw new Error("invalid pending event during stream operation"); + }} + if (this.isWritable() && this.isPeerDropped()) {{ + this.setCopyState({stream_end_class}.CopyState.DONE); + return (event.payload1 & ~0xf) | {stream_end_class}.CopyResult.DROPPED; + }} + return event.payload1; + }} + + if (this.isDropped() || this.isPeerDropped()) {{ if (this.#pendingBufferMeta?.onCopyDoneFn) {{ const f = this.#pendingBufferMeta.onCopyDoneFn; this.#pendingBufferMeta.onCopyDoneFn = null; @@ -1159,6 +1189,10 @@ impl AsyncStreamIntrinsic { if (event.rejectedLength !== undefined) {{ this.#rejectedLength = event.rejectedLength; }} + if (this.isWritable() && this.isPeerDropped()) {{ + this.setCopyState({stream_end_class}.CopyState.DONE); + return (payload & ~0xf) | {stream_end_class}.CopyResult.DROPPED; + }} return payload; }}; @@ -1187,7 +1221,8 @@ impl AsyncStreamIntrinsic { const streamEnd = this; return task.suspendUntil({{ readyFn: () => streamEnd.hasPendingEvent(), - }}).then(() => {{ + }}).then(async () => {{ + if (deferSyncFinish) {{ await Promise.resolve(); }} if (!injectedWritePromise) {{ return finishCopy(); }} return injectedWritePromise.then(cleanupFn => {{ cleanupFn(); @@ -1657,6 +1692,7 @@ impl AsyncStreamIntrinsic { }} getPendingBufferMeta() {{ return this.#pendingBufferMeta; }} + hasPendingBuffer() {{ return !!this.#pendingBufferMeta?.buffer; }} resetAndNotifyPending(result) {{ const f = this.#pendingBufferMeta.onCopyDoneFn; @@ -1738,10 +1774,6 @@ impl AsyncStreamIntrinsic { this.#elemMeta = elemMeta; - let dropped = false; - const setDroppedFn = () => {{ dropped = true }}; - const isDroppedFn = () => dropped; - this.#readEnd = new {read_end_class}({{ tableIdx, elemMeta: this.#elemMeta, @@ -1752,8 +1784,8 @@ impl AsyncStreamIntrinsic { // as that function will *inject* a write when a read is performed // from inside the guest. hostInjectFn: args.hostInjectFn, - setDroppedFn, - isDroppedFn, + isPeerDroppedFn: () => this.#writeEnd?.isDropped() ?? false, + notifyPeerDroppedFn: () => this.#writeEnd?.notifyDropped(), }}); this.#writeEnd = new {write_end_class}({{ @@ -1763,8 +1795,8 @@ impl AsyncStreamIntrinsic { target: "stream write end (@ init)", waitable: writeWaitable, hostOwned: true, - setDroppedFn, - isDroppedFn, + isPeerDroppedFn: () => this.#readEnd.isDropped(), + notifyPeerDroppedFn: () => this.#readEnd.notifyDropped(), }}); }} @@ -2228,6 +2260,9 @@ impl AsyncStreamIntrinsic { }; let runtime_error_class = render_args.require_intrinsic(Intrinsic::WebAssemblyRuntimeError); + let async_blocked_const = render_args.require_intrinsic(Intrinsic::AsyncTask( + AsyncTaskIntrinsic::AsyncBlockedConstant, + )); output.push_str(&format!(r#" function {stream_op_fn}( @@ -2247,6 +2282,8 @@ impl AsyncStreamIntrinsic { isAsync, streamTableIdx, elemMeta, + syncFastOnly, + deferSyncFinish, }} = ctx; if (componentIdx === undefined) {{ throw new TypeError("missing/invalid component idx"); }} @@ -2280,6 +2317,18 @@ impl AsyncStreamIntrinsic { throw new {runtime_error_class}(message); }} + // A synchronous canonical operation is exposed through a + // plain Wasm fast path so an already-queued event can be + // consumed without entering JSPI. If it would block, the + // generated conditional trampoline retries on its + // suspending slow path. + if (syncFastOnly + && !streamEnd.hasPendingEvent() + && !streamEnd.isPeerDropped() + && !streamEnd.hasPendingBuffer()) {{ + return {async_blocked_const}; + }} + return streamEnd.copy({{ isAsync, memory: getMemoryFn?.(), @@ -2291,6 +2340,7 @@ impl AsyncStreamIntrinsic { realloc: getReallocFn?.(), getReallocFn, elemMeta, + deferSyncFinish, }}); }} "#)); @@ -2417,10 +2467,7 @@ impl AsyncStreamIntrinsic { throw new Error('invalid stream end class, expected [{stream_end_class}]'); }} - // Copy completion is not observable until the guest consumes its - // pending event, so both an active copy and an undelivered event - // keep the table-local stream handle busy. - if (streamEnd.isCopying() || streamEnd.hasPendingEvent()) {{ + if (streamEnd.isCopying()) {{ throw new {runtime_error_class}('{busy_error}'); }} diff --git a/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs b/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs index 1aa935691..b62dcc19e 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs @@ -423,9 +423,6 @@ impl WaitableIntrinsic { componentIdx: this.#componentIdx, waitable: this, }}); - if (this.hasPendingEvent()) {{ - throw new Error('waitables with pending events cannot be dropped'); - }} this.join(null); }} diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index 3dcadc842..f4c7dd5e8 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -2134,9 +2134,16 @@ impl<'a> Instantiator<'a, '_> { } else { let suspending_wrap_fn = self.bindgen.intrinsic(Intrinsic::SuspendingImportWrapperFn); + let conditional_suspending_fn = self + .bindgen + .intrinsic(Intrinsic::ConditionalSuspending3I32ToI32Fn); uwriteln!( self.src.js, - "const trampoline{i} = new WebAssembly.Suspending({suspending_wrap_fn}({component_instance_id}, {stream_read_fn}.bind(null, {ctx})));", + r#" + const trampoline{i} = {conditional_suspending_fn}( + {stream_read_fn}.bind(null, {{ ...{ctx}, isAsync: true, syncFastOnly: true }}), + {suspending_wrap_fn}({component_instance_id}, {stream_read_fn}.bind(null, {{ ...{ctx}, deferSyncFinish: true }})), + );"#, ); } } diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/idle-drop.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/idle-drop.wast new file mode 100644 index 000000000..11d20a336 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/idle-drop.wast @@ -0,0 +1,572 @@ +;; This test checks that dropping one end of a stream or future notifies the +;; other end even when that other end is idle, i.e. has no read or write in +;; flight. The notification is recorded at the time of the drop and delivered +;; exactly once, either through whatever waitable set the idle end is in or +;; inline by a subsequent read. Once it has been delivered, the end is "done" +;; and behaves like any other done end. +(component definition $Tester + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $M + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.drop" (func $waitable-set.drop (param i32))) + (import "" "stream.new" (func $stream.new (result i64))) + (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32))) + (import "" "stream.read-sync" (func $stream.read-sync (param i32 i32 i32) (result i32))) + (import "" "stream.write" (func $stream.write (param i32 i32 i32) (result i32))) + (import "" "stream.drop-readable" (func $stream.drop-readable (param i32))) + (import "" "stream.drop-writable" (func $stream.drop-writable (param i32))) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "future.drop-readable" (func $future.drop-readable (param i32))) + (import "" "future.drop-writable" (func $future.drop-writable (param i32))) + + ;; The event returned by waitable-set.{wait,poll} is stored at [0,8): the + ;; waitable index at 0 and the payload at 4. Its event code is stashed at + ;; 0x100 and stream/future payloads land at 16. + (global $ws (mut i32) (i32.const 0)) + (global $rx (mut i32) (i32.const 0)) + (global $tx (mut i32) (i32.const 0)) + + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + + (func $poll + (i32.store (i32.const 0x100) (call $waitable-set.poll (global.get $ws) (i32.const 0))) + ) + (func $wait + (i32.store (i32.const 0x100) (call $waitable-set.wait (global.get $ws) (i32.const 0))) + ) + (func $check-event (param $code i32) (param $index i32) (param $payload i32) + (if (i32.ne (local.get $code) (i32.load (i32.const 0x100))) (then unreachable)) + (if (i32.ne (local.get $index) (i32.load (i32.const 0))) (then unreachable)) + (if (i32.ne (local.get $payload) (i32.load (i32.const 4))) (then unreachable)) + ) + (func $assert-no-event + (call $poll) + (if (i32.ne (i32.const 0 (; NONE ;)) (i32.load (i32.const 0x100))) (then unreachable)) + ) + (func $new-stream + (local $ret64 i64) + (local.set $ret64 (call $stream.new)) + (global.set $rx (i32.wrap_i64 (local.get $ret64))) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + ) + (func $new-future + (local $ret64 i64) + (local.set $ret64 (call $future.new)) + (global.set $rx (i32.wrap_i64 (local.get $ret64))) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + ) + + ;; Park the readable end of a fresh stream in the waitable set, drop the + ;; writable end and consume the resulting DROPPED notification. The + ;; readable end is left joined to the set unless $unjoin. + (func $notify-reader (param $unjoin i32) + (call $new-stream) + (call $waitable.join (global.get $rx) (global.get $ws)) + (call $stream.drop-writable (global.get $tx)) + (call $poll) + (call $check-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x01 (; DROPPED=1 | (0<<4) ;))) + (if (local.get $unjoin) + (then (call $waitable.join (global.get $rx) (i32.const 0)))) + ) + ;; The mirror: park the writable end and drop the readable end. + (func $notify-writer + (call $new-stream) + (call $waitable.join (global.get $tx) (global.get $ws)) + (call $stream.drop-readable (global.get $rx)) + (call $poll) + (call $check-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x01 (; DROPPED=1 | (0<<4) ;))) + ) + ;; Same for a future: park the writable end and drop the readable end. + (func $notify-future-writer + (call $new-future) + (call $waitable.join (global.get $tx) (global.get $ws)) + (call $future.drop-readable (global.get $rx)) + (call $poll) + (call $check-event (i32.const 5 (; FUTURE_WRITE ;)) (global.get $tx) + (i32.const 1 (; DROPPED ;))) + (call $waitable.join (global.get $tx) (i32.const 0)) + ) + + ;; A parked (idle, in a waitable set) readable stream end is notified when + ;; the writable end is dropped, and the notification is one-shot. + (func (export "reader-parked-poll") (result i32) + (call $notify-reader (i32.const 0)) + (call $assert-no-event) + (call $stream.drop-readable (global.get $rx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; Same, but delivered by waitable-set.wait, which must return immediately + ;; instead of blocking forever: this is the motivating case for delivering + ;; drops to idle ends at all. + (func (export "reader-parked-wait") (result i32) + (call $new-stream) + (call $waitable.join (global.get $rx) (global.get $ws)) + (call $stream.drop-writable (global.get $tx)) + (call $wait) + (call $check-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x01 (; DROPPED=1 | (0<<4) ;))) + (call $assert-no-event) + (call $stream.drop-readable (global.get $rx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; The mirror case: a parked writable stream end is notified when the + ;; readable end is dropped. + (func (export "writer-parked") (result i32) + (call $notify-writer) + (call $assert-no-event) + (call $stream.drop-writable (global.get $tx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; A parked writable future end is notified when the readable end is + ;; dropped. (Dropping it afterwards is "future-drop-writable-after- + ;; notification" below.) + (func (export "future-writer-parked") (result i32) + (call $notify-future-writer) + (call $assert-no-event) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; With both ends in the same waitable set, dropping the writable end + ;; produces exactly one event (for the surviving readable end). + (func (export "both-ends-in-set") (result i32) + (call $new-stream) + (call $waitable.join (global.get $rx) (global.get $ws)) + (call $waitable.join (global.get $tx) (global.get $ws)) + (call $stream.drop-writable (global.get $tx)) + (call $poll) + (call $check-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x01 (; DROPPED=1 | (0<<4) ;))) + (call $assert-no-event) + (call $stream.drop-readable (global.get $rx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; The notification is set pending when the peer is dropped, independently of + ;; waitable set membership: joining the set afterwards still delivers it. + (func (export "drop-before-join") (result i32) + (call $new-stream) + (call $stream.drop-writable (global.get $tx)) + (call $waitable.join (global.get $rx) (global.get $ws)) + (call $poll) + (call $check-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x01 (; DROPPED=1 | (0<<4) ;))) + (call $assert-no-event) + (call $stream.drop-readable (global.get $rx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; A synchronous stream.read on an end that is in no waitable set: the + ;; pending notification means the read reports DROPPED without blocking. + (func (export "sync-read-consumes-notification") (result i32) + (call $new-stream) + (call $stream.drop-writable (global.get $tx)) + (if (i32.ne (i32.const 0x01 (; DROPPED=1 | (0<<4) ;)) + (call $stream.read-sync (global.get $rx) (i32.const 16) (i32.const 4))) + (then unreachable)) + (call $stream.drop-readable (global.get $rx)) + (i32.const 42) + ) + + ;; An async stream.read consumes the pending notification inline, returning + ;; DROPPED immediately; the waitable set must then be empty (no double + ;; delivery of the same drop). + (func (export "read-consumes-notification") (result i32) + (call $new-stream) + (call $waitable.join (global.get $rx) (global.get $ws)) + (call $stream.drop-writable (global.get $tx)) + (if (i32.ne (i32.const 0x01 (; DROPPED=1 | (0<<4) ;)) + (call $stream.read (global.get $rx) (i32.const 16) (i32.const 4))) + (then unreachable)) + (call $assert-no-event) + (call $stream.drop-readable (global.get $rx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; A notified writable future end may be dropped without having written. + (func (export "future-drop-writable-after-notification") (result i32) + (call $notify-future-writer) + (call $future.drop-writable (global.get $tx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + (func (export "trap-read-after-notification") + (call $notify-reader (i32.const 0)) + (drop (call $stream.read (global.get $rx) (i32.const 16) (i32.const 4))) + unreachable + ) + + (func (export "trap-write-after-notification") + (call $notify-writer) + (drop (call $stream.write (global.get $tx) (i32.const 16) (i32.const 4))) + unreachable + ) + + (func (export "trap-future-write-after-notification") + (call $notify-future-writer) + (drop (call $future.write (global.get $tx) (i32.const 16))) + unreachable + ) + + ;; Lifting a notified readable stream end out of the component traps just + ;; like lifting any other done end. + (func (export "trap-lift-after-notification") (result i32) + (call $notify-reader (i32.const 1)) + (global.get $rx) + ) + ) + (type $ST (stream u8)) + (type $FT (future u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.drop (core func $waitable-set.drop)) + (canon stream.new $ST (core func $stream.new)) + (canon stream.read $ST async (memory (core memory $memory "mem")) (core func $stream.read)) + (canon stream.read $ST (memory (core memory $memory "mem")) (core func $stream.read-sync)) + (canon stream.write $ST async (memory (core memory $memory "mem")) (core func $stream.write)) + (canon stream.drop-readable $ST (core func $stream.drop-readable)) + (canon stream.drop-writable $ST (core func $stream.drop-writable)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon future.drop-readable $FT (core func $future.drop-readable)) + (canon future.drop-writable $FT (core func $future.drop-writable)) + (core instance $m (instantiate $M (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.drop" (func $waitable-set.drop)) + (export "stream.new" (func $stream.new)) + (export "stream.read" (func $stream.read)) + (export "stream.read-sync" (func $stream.read-sync)) + (export "stream.write" (func $stream.write)) + (export "stream.drop-readable" (func $stream.drop-readable)) + (export "stream.drop-writable" (func $stream.drop-writable)) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "future.drop-readable" (func $future.drop-readable)) + (export "future.drop-writable" (func $future.drop-writable)) + )))) + (func (export "reader-parked-poll") (result u32) (canon lift (core func $m "reader-parked-poll"))) + (func (export "reader-parked-wait") (result u32) (canon lift (core func $m "reader-parked-wait"))) + (func (export "writer-parked") (result u32) (canon lift (core func $m "writer-parked"))) + (func (export "future-writer-parked") (result u32) (canon lift (core func $m "future-writer-parked"))) + (func (export "both-ends-in-set") (result u32) (canon lift (core func $m "both-ends-in-set"))) + (func (export "drop-before-join") (result u32) (canon lift (core func $m "drop-before-join"))) + (func (export "sync-read-consumes-notification") (result u32) (canon lift (core func $m "sync-read-consumes-notification"))) + (func (export "read-consumes-notification") (result u32) (canon lift (core func $m "read-consumes-notification"))) + (func (export "future-drop-writable-after-notification") (result u32) (canon lift (core func $m "future-drop-writable-after-notification"))) + (func (export "trap-read-after-notification") (canon lift (core func $m "trap-read-after-notification"))) + (func (export "trap-write-after-notification") (canon lift (core func $m "trap-write-after-notification"))) + (func (export "trap-future-write-after-notification") (canon lift (core func $m "trap-future-write-after-notification"))) + (func (export "trap-lift-after-notification") (result (stream u8)) (canon lift (core func $m "trap-lift-after-notification"))) +) + +;; $TransferTester checks that a readable stream end whose writable end has +;; already been dropped is an ordinary, transferrable value: it can be passed +;; as a parameter or returned as a result, and whichever component ends up +;; holding it observes the DROPPED notification (by reading, or by parking the +;; idle end in a waitable set). It also checks that the notification produced +;; by dropping one end reaches an idle peer held by a *different* component +;; instance, in both directions. +(component definition $TransferTester + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.drop" (func $waitable-set.drop (param i32))) + (import "" "stream.new" (func $stream.new (result i64))) + (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32))) + (import "" "stream.drop-readable" (func $stream.drop-readable (param i32))) + (import "" "stream.drop-writable" (func $stream.drop-writable (param i32))) + + (global $ws (mut i32) (i32.const 0)) + (global $tx (mut i32) (i32.const 0)) + + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + + ;; Create a stream, drop the writable end immediately and hand the + ;; readable end (which now has a pending DROPPED notification) to the + ;; caller. + (func (export "make-dropped-stream") (result i32) + (local $ret64 i64) + (local.set $ret64 (call $stream.new)) + (call $stream.drop-writable + (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (i32.wrap_i64 (local.get $ret64)) + ) + + ;; Create a stream, park the writable end in our waitable set and hand + ;; the readable end to the caller. + (func (export "make-stream") (result i32) + (local $ret64 i64) + (local.set $ret64 (call $stream.new)) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (call $waitable.join (global.get $tx) (global.get $ws)) + (i32.wrap_i64 (local.get $ret64)) + ) + + (func (export "drop-writable") + (call $stream.drop-writable (global.get $tx)) + ) + + ;; Confirm that our parked writable end was notified that the caller + ;; dropped the readable end, exactly once. + (func (export "check-writer-notified") (result i32) + (if (i32.ne (i32.const 3 (; STREAM_WRITE ;)) + (call $waitable-set.poll (global.get $ws) (i32.const 0))) + (then unreachable)) + (if (i32.ne (global.get $tx) (i32.load (i32.const 0))) (then unreachable)) + (if (i32.ne (i32.const 0x01 (; DROPPED=1 | (0<<4) ;)) (i32.load (i32.const 4))) + (then unreachable)) + (if (i32.ne (i32.const 0 (; NONE ;)) + (call $waitable-set.poll (global.get $ws) (i32.const 0))) + (then unreachable)) + (call $stream.drop-writable (global.get $tx)) + (call $waitable-set.drop (global.get $ws)) + (i32.const 42) + ) + + ;; Read from a stream handed to us whose writable end is already gone. + (func (export "consume-dropped") (param $rx i32) (result i32) + (if (i32.ne (i32.const 0x01 (; DROPPED=1 | (0<<4) ;)) + (call $stream.read (local.get $rx) (i32.const 16) (i32.const 4))) + (then unreachable)) + (call $stream.drop-readable (local.get $rx)) + (i32.const 42) + ) + ) + (type $ST (stream u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.drop (core func $waitable-set.drop)) + (canon stream.new $ST (core func $stream.new)) + (canon stream.read $ST async (memory (core memory $memory "mem")) (core func $stream.read)) + (canon stream.drop-readable $ST (core func $stream.drop-readable)) + (canon stream.drop-writable $ST (core func $stream.drop-writable)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.drop" (func $waitable-set.drop)) + (export "stream.new" (func $stream.new)) + (export "stream.read" (func $stream.read)) + (export "stream.drop-readable" (func $stream.drop-readable)) + (export "stream.drop-writable" (func $stream.drop-writable)) + )))) + (func (export "make-dropped-stream") (result (stream u8)) (canon lift (core func $cm "make-dropped-stream"))) + (func (export "make-stream") (result (stream u8)) (canon lift (core func $cm "make-stream"))) + (func (export "drop-writable") (canon lift (core func $cm "drop-writable"))) + (func (export "check-writer-notified") (result u32) (canon lift (core func $cm "check-writer-notified"))) + (func (export "consume-dropped") (param "rx" (stream u8)) (result u32) (canon lift (core func $cm "consume-dropped"))) + ) + + (component $D + (import "c" (instance $c + (export "make-dropped-stream" (func (result (stream u8)))) + (export "make-stream" (func (result (stream u8)))) + (export "drop-writable" (func)) + (export "check-writer-notified" (func (result u32))) + (export "consume-dropped" (func (param "rx" (stream u8)) (result u32))) + )) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $DM + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.drop" (func $waitable-set.drop (param i32))) + (import "" "stream.new" (func $stream.new (result i64))) + (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32))) + (import "" "stream.drop-readable" (func $stream.drop-readable (param i32))) + (import "" "stream.drop-writable" (func $stream.drop-writable (param i32))) + (import "" "make-dropped-stream" (func $make-dropped-stream (result i32))) + (import "" "make-stream" (func $make-stream (result i32))) + (import "" "drop-writable" (func $drop-writable)) + (import "" "check-writer-notified" (func $check-writer-notified (result i32))) + (import "" "consume-dropped" (func $consume-dropped (param i32) (result i32))) + + (func $check-dropped-event (param $ws i32) (param $rx i32) + (if (i32.ne (i32.const 2 (; STREAM_READ ;)) + (call $waitable-set.poll (local.get $ws) (i32.const 0))) + (then unreachable)) + (if (i32.ne (local.get $rx) (i32.load (i32.const 0))) (then unreachable)) + (if (i32.ne (i32.const 0x01 (; DROPPED=1 | (0<<4) ;)) (i32.load (i32.const 4))) + (then unreachable)) + (if (i32.ne (i32.const 0 (; NONE ;)) + (call $waitable-set.poll (local.get $ws) (i32.const 0))) + (then unreachable)) + ) + + ;; Pass an already-dropped-peer readable end to $C as a parameter. + (func (export "transfer-as-param") (result i32) + (local $ret64 i64) (local $rx i32) + (local.set $ret64 (call $stream.new)) + (local.set $rx (i32.wrap_i64 (local.get $ret64))) + (call $stream.drop-writable + (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (if (i32.ne (i32.const 42) (call $consume-dropped (local.get $rx))) + (then unreachable)) + (i32.const 42) + ) + + ;; Receive an already-dropped-peer readable end from $C as a result and + ;; read from it. + (func (export "transfer-as-result") (result i32) + (local $rx i32) + (local.set $rx (call $make-dropped-stream)) + (if (i32.ne (i32.const 0x01 (; DROPPED=1 | (0<<4) ;)) + (call $stream.read (local.get $rx) (i32.const 16) (i32.const 4))) + (then unreachable)) + (call $stream.drop-readable (local.get $rx)) + (i32.const 42) + ) + + ;; Same, but instead of reading, park the received end in a waitable set: + ;; the notification travelled with the end and is reported under the + ;; receiver's own index. + (func (export "transfer-then-park") (result i32) + (local $rx i32) (local $ws i32) + (local.set $ws (call $waitable-set.new)) + (local.set $rx (call $make-dropped-stream)) + (call $waitable.join (local.get $rx) (local.get $ws)) + (call $check-dropped-event (local.get $ws) (local.get $rx)) + (call $stream.drop-readable (local.get $rx)) + (call $waitable-set.drop (local.get $ws)) + (i32.const 42) + ) + + ;; $C parks its writable end; we drop our readable end; $C is notified. + (func (export "notify-writer-in-other-component") (result i32) + (call $stream.drop-readable (call $make-stream)) + (if (i32.ne (i32.const 42) (call $check-writer-notified)) + (then unreachable)) + (i32.const 42) + ) + + ;; We park the readable end; $C drops its writable end; we are notified. + (func (export "notify-reader-in-other-component") (result i32) + (local $rx i32) (local $ws i32) + (local.set $ws (call $waitable-set.new)) + (local.set $rx (call $make-stream)) + (call $waitable.join (local.get $rx) (local.get $ws)) + (call $drop-writable) + (call $check-dropped-event (local.get $ws) (local.get $rx)) + (call $stream.drop-readable (local.get $rx)) + (call $waitable-set.drop (local.get $ws)) + (i32.const 42) + ) + ) + (type $ST (stream u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.drop (core func $waitable-set.drop)) + (canon stream.new $ST (core func $stream.new)) + (canon stream.read $ST async (memory (core memory $memory "mem")) (core func $stream.read)) + (canon stream.drop-readable $ST (core func $stream.drop-readable)) + (canon stream.drop-writable $ST (core func $stream.drop-writable)) + (canon lower (func $c "make-dropped-stream") (core func $make-dropped-stream')) + (canon lower (func $c "make-stream") (core func $make-stream')) + (canon lower (func $c "drop-writable") (core func $drop-writable')) + (canon lower (func $c "check-writer-notified") (core func $check-writer-notified')) + (canon lower (func $c "consume-dropped") (core func $consume-dropped')) + (core instance $dm (instantiate $DM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.drop" (func $waitable-set.drop)) + (export "stream.new" (func $stream.new)) + (export "stream.read" (func $stream.read)) + (export "stream.drop-readable" (func $stream.drop-readable)) + (export "stream.drop-writable" (func $stream.drop-writable)) + (export "make-dropped-stream" (func $make-dropped-stream')) + (export "make-stream" (func $make-stream')) + (export "drop-writable" (func $drop-writable')) + (export "check-writer-notified" (func $check-writer-notified')) + (export "consume-dropped" (func $consume-dropped')) + )))) + (func (export "transfer-as-param") (result u32) (canon lift (core func $dm "transfer-as-param"))) + (func (export "transfer-as-result") (result u32) (canon lift (core func $dm "transfer-as-result"))) + (func (export "transfer-then-park") (result u32) (canon lift (core func $dm "transfer-then-park"))) + (func (export "notify-writer-in-other-component") (result u32) (canon lift (core func $dm "notify-writer-in-other-component"))) + (func (export "notify-reader-in-other-component") (result u32) (canon lift (core func $dm "notify-reader-in-other-component"))) + ) + (instance $c (instantiate $C)) + (instance $d (instantiate $D (with "c" (instance $c)))) + (func (export "transfer-as-param") (alias export $d "transfer-as-param")) + (func (export "transfer-as-result") (alias export $d "transfer-as-result")) + (func (export "transfer-then-park") (alias export $d "transfer-then-park")) + (func (export "notify-writer-in-other-component") (alias export $d "notify-writer-in-other-component")) + (func (export "notify-reader-in-other-component") (alias export $d "notify-reader-in-other-component")) +) +;; Cases that only require the idle-end notification to be delivered. +(component instance $i $Tester) +(assert_return (invoke "reader-parked-poll") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "reader-parked-wait") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "writer-parked") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "future-writer-parked") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "both-ends-in-set") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "drop-before-join") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "sync-read-consumes-notification") (u32.const 42)) +(component instance $i $TransferTester) +(assert_return (invoke "transfer-as-param") (u32.const 42)) +(component instance $i $TransferTester) +(assert_return (invoke "transfer-as-result") (u32.const 42)) +(component instance $i $TransferTester) +(assert_return (invoke "transfer-then-park") (u32.const 42)) +(component instance $i $TransferTester) +(assert_return (invoke "notify-writer-in-other-component") (u32.const 42)) +(component instance $i $TransferTester) +(assert_return (invoke "notify-reader-in-other-component") (u32.const 42)) + +;; Cases that additionally require the notified end to become "done". +(component instance $i $Tester) +(assert_return (invoke "read-consumes-notification") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "future-drop-writable-after-notification") (u32.const 42)) +(component instance $i $Tester) +(assert_trap (invoke "trap-read-after-notification") "cannot read from stream after being notified that the writable end dropped") +(component instance $i $Tester) +(assert_trap (invoke "trap-write-after-notification") "cannot write to stream after being notified that the readable end dropped") +(component instance $i $Tester) +(assert_trap (invoke "trap-future-write-after-notification") "cannot write to future after previous write succeeded or readable end dropped") +(component instance $i $Tester) +(assert_trap (invoke "trap-lift-after-notification") "cannot lift stream after being notified that the writable end dropped") diff --git a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json index 8fc58fa17..e76fbfed8 100644 --- a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json +++ b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json @@ -67,6 +67,10 @@ "name": "futures-must-write.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" }, + { + "name": "idle-drop.wast", + "revision": "5a98cd0744de401faa16d1321dbfe1c67ef5bd74" + }, { "name": "partial-stream-copies.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" diff --git a/packages/jco-transpile/test/p3/ported/component-model/wast.ts b/packages/jco-transpile/test/p3/ported/component-model/wast.ts index 964b7b492..8d79b4597 100644 --- a/packages/jco-transpile/test/p3/ported/component-model/wast.ts +++ b/packages/jco-transpile/test/p3/ported/component-model/wast.ts @@ -37,6 +37,7 @@ const WAST_TESTS: readonly WastTest[] = [ { relPath: 'async/cancel-stream.wast' }, { relPath: 'async/partial-stream-copies.wast' }, { relPath: 'async/futures-must-write.wast' }, + { relPath: 'async/idle-drop.wast' }, { relPath: 'async/empty-wait.wast' }, { relPath: 'async/zero-length.wast' }, { relPath: 'async/cancel-subtask.wast' }, From 637fe038b75ec56608bf8409a0975bec4695349f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Tue, 15 Sep 2026 15:48:42 +0000 Subject: [PATCH 02/11] fix(bindgen): preserve completed future events --- .../src/intrinsics/p3/async_future.rs | 13 - .../async/future-completion-order.wast | 119 +++++++ .../async/partial-stream-copies.wast | 132 ++++++++ .../component-model/async/zero-length.wast | 310 ++++++++++++++++++ .../test/fixtures/wast/upstream-manifest.json | 8 +- .../test/p3/ported/component-model/wast.ts | 1 + 6 files changed, 568 insertions(+), 15 deletions(-) create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/future-completion-order.wast diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs index 6006fb33d..925186cf1 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs @@ -1600,10 +1600,6 @@ impl AsyncFutureIntrinsic { throw new Error(`mismatched event code [${{code}}] (expected {event_code})`); }} if (index !== futureEnd.waitableIdx()) {{ throw new Error('mismatched future end index'); }} - if (futureEnd.isWritable() && futureEnd.isPeerDropped()) {{ - futureEnd.setCopyState({future_end_class}.CopyState.DONE); - return {future_end_class}.CopyResult.DROPPED; - }} return payload; }} @@ -1630,10 +1626,6 @@ impl AsyncFutureIntrinsic { throw new Error(`mismatched event code [${{code}}] (expected {event_code})`); }} if (index !== futureEnd.waitableIdx()) {{ throw new Error('mismatched future end index'); }} - if (futureEnd.isWritable() && futureEnd.isPeerDropped()) {{ - futureEnd.setCopyState({future_end_base_class}.CopyState.DONE); - return {future_end_class}.CopyResult.DROPPED; - }} return payload; }}); }} @@ -1645,11 +1637,6 @@ impl AsyncFutureIntrinsic { }} if (index !== futureEnd.waitableIdx()) {{ throw new Error('mismatched future end index'); }} - if (futureEnd.isWritable() && futureEnd.isPeerDropped()) {{ - futureEnd.setCopyState({future_end_base_class}.CopyState.DONE); - return {future_end_class}.CopyResult.DROPPED; - }} - return payload; }} "# diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/future-completion-order.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/future-completion-order.wast new file mode 100644 index 000000000..ac3b22d8d --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/future-completion-order.wast @@ -0,0 +1,119 @@ +;; Test that DROPPED doesn't override COMPLETED for futures. +(component definition $Tester + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $M + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "future.drop-readable" (func $future.drop-readable (param i32))) + (import "" "future.drop-writable" (func $future.drop-writable (param i32))) + + ;; The event returned by waitable-set.poll is stored at [0,8): the waitable + ;; index at 0 and the payload at 4. The source byte lives at 64, the + ;; destination byte at 128. + (global $ws (mut i32) (i32.const 0)) + (global $rx (mut i32) (i32.const 0)) + (global $tx (mut i32) (i32.const 0)) + + (func $start + (global.set $ws (call $waitable-set.new)) + (i32.store8 (i32.const 64) (i32.const 1)) + ) + (start $start) + + (func $expect-event (param $code i32) (param $index i32) (param $payload i32) + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 0)) (local.get $code)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $index)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (local.get $payload)) + (then unreachable)) + ) + (func $expect-no-event + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + ) + (func $new-future + (local $ret64 i64) + (local.set $ret64 (call $future.new)) + (global.set $rx (i32.wrap_i64 (local.get $ret64))) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + ) + + ;; The reader blocks first, so future.write consumes the writer's own + ;; completion inline. The writable end is then done and may be dropped + ;; before the reader has observed anything. + (func (export "writer-completes-inline") (result i32) + (call $new-future) + (call $waitable.join (global.get $rx) (global.get $ws)) + (if (i32.ne (call $future.read (global.get $rx) (i32.const 128)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $future.write (global.get $tx) (i32.const 64)) + (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + (call $future.drop-writable (global.get $tx)) + ;; COMPLETED, not DROPPED: the copy happened before the drop + (call $expect-event (i32.const 4 (; FUTURE_READ ;)) (global.get $rx) + (i32.const 0 (; COMPLETED ;))) + (call $expect-no-event) + (if (i32.ne (i32.load8_u (i32.const 128)) (i32.const 1)) + (then unreachable)) + (i32.const 42) + ) + + ;; The mirror: the writer blocks first, so future.read consumes the + ;; reader's completion inline and the blocked writer polls for its own. + (func (export "reader-completes-inline") (result i32) + (call $new-future) + (call $waitable.join (global.get $tx) (global.get $ws)) + (if (i32.ne (call $future.write (global.get $tx) (i32.const 64)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $future.read (global.get $rx) (i32.const 128)) + (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + (call $future.drop-readable (global.get $rx)) + (call $expect-event (i32.const 5 (; FUTURE_WRITE ;)) (global.get $tx) + (i32.const 0 (; COMPLETED ;))) + (call $expect-no-event) + (call $waitable.join (global.get $tx) (i32.const 0)) + (call $future.drop-writable (global.get $tx)) + (if (i32.ne (i32.load8_u (i32.const 128)) (i32.const 1)) + (then unreachable)) + (i32.const 42) + ) + ) + (type $FT (future u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon future.new $FT (core func $future.new)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon future.drop-readable $FT (core func $future.drop-readable)) + (canon future.drop-writable $FT (core func $future.drop-writable)) + (core instance $m (instantiate $M (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "future.new" (func $future.new)) + (export "future.read" (func $future.read)) + (export "future.write" (func $future.write)) + (export "future.drop-readable" (func $future.drop-readable)) + (export "future.drop-writable" (func $future.drop-writable)) + )))) + (func (export "writer-completes-inline") (result u32) (canon lift (core func $m "writer-completes-inline"))) + (func (export "reader-completes-inline") (result u32) (canon lift (core func $m "reader-completes-inline"))) +) +(component instance $i $Tester) +(assert_return (invoke "writer-completes-inline") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "reader-completes-inline") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/partial-stream-copies.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/partial-stream-copies.wast index f1760dd0f..db31e9a5a 100644 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/partial-stream-copies.wast +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/partial-stream-copies.wast @@ -236,3 +236,135 @@ (func (export "run") (alias export $d "run")) ) (assert_return (invoke "run") (u32.const 42)) + +;; Test where the writer has a pending buffer that is drained by several reads +;; and then the readable end is dropped (before the writer has observed its event). +(component definition $Tester + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $M + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "stream.new" (func $stream.new (result i64))) + (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32))) + (import "" "stream.write" (func $stream.write (param i32 i32 i32) (result i32))) + (import "" "stream.drop-readable" (func $stream.drop-readable (param i32))) + + (global $ws (mut i32) (i32.const 0)) + (global $rx (mut i32) (i32.const 0)) + (global $tx (mut i32) (i32.const 0)) + + (func $start + (global.set $ws (call $waitable-set.new)) + ) + (start $start) + + (func $expect-event (param $code i32) (param $index i32) (param $payload i32) + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 0)) (local.get $code)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $index)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (local.get $payload)) + (then unreachable)) + ) + (func $expect-no-event + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + ) + ;; Create a stream and block a 4-byte write on it, with the writable end in + ;; the waitable set so that its completion is observable. + (func $blocked-write (param $n i32) + (local $ret64 i64) + (local.set $ret64 (call $stream.new)) + (global.set $rx (i32.wrap_i64 (local.get $ret64))) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (call $waitable.join (global.get $tx) (global.get $ws)) + (i32.store (i32.const 64) (i32.const 0x04030201)) + (if (i32.ne (call $stream.write (global.get $tx) (i32.const 64) (local.get $n)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + ) + + ;; A blocked 3-byte write is drained by a 1-byte then a 2-byte read. The + ;; reads see the bytes in order, and the writer gets one event carrying the + ;; total progress, not one event per read. + (func (export "writer-buffer-pending") (result i32) + (call $blocked-write (i32.const 3)) + (if (i32.ne (call $stream.read (global.get $rx) (i32.const 128) (i32.const 1)) + (i32.const 0x10 (; COMPLETED=0 | (1<<4) ;))) + (then unreachable)) + ;; NB: the writer is not polled in between -- observing its event would + ;; detach its buffer and the second read would have nothing to drain. + (if (i32.ne (call $stream.read (global.get $rx) (i32.const 129) (i32.const 2)) + (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x30 (; COMPLETED=0 | (3<<4) ;))) + (call $expect-no-event) + ;; bytes 1,2,3 landed contiguously and in order + (if (i32.ne (i32.load (i32.const 128)) (i32.const 0x00030201)) + (then unreachable)) + (i32.const 42) + ) + + ;; If the readable end is dropped before the writer observes its event, the + ;; pending COMPLETED is folded into a DROPPED carrying the same progress: + ;; the writer learns both that its bytes were copied and that the stream is + ;; over, in one event. + (func (export "drop-folds-into-full-progress") (result i32) + (call $blocked-write (i32.const 4)) + (if (i32.ne (call $stream.read (global.get $rx) (i32.const 128) (i32.const 4)) + (i32.const 0x40 (; COMPLETED=0 | (4<<4) ;))) + (then unreachable)) + (call $stream.drop-readable (global.get $rx)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x41 (; DROPPED=1 | (4<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + + ;; Same, with the write only partially drained: the folded event reports + ;; the partial progress. + (func (export "drop-folds-into-partial-progress") (result i32) + (call $blocked-write (i32.const 4)) + (if (i32.ne (call $stream.read (global.get $rx) (i32.const 128) (i32.const 2)) + (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (then unreachable)) + (call $stream.drop-readable (global.get $rx)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x21 (; DROPPED=1 | (2<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + ) + (type $ST (stream u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon stream.new $ST (core func $stream.new)) + (canon stream.read $ST async (memory (core memory $memory "mem")) (core func $stream.read)) + (canon stream.write $ST async (memory (core memory $memory "mem")) (core func $stream.write)) + (canon stream.drop-readable $ST (core func $stream.drop-readable)) + (core instance $m (instantiate $M (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "stream.new" (func $stream.new)) + (export "stream.read" (func $stream.read)) + (export "stream.write" (func $stream.write)) + (export "stream.drop-readable" (func $stream.drop-readable)) + )))) + (func (export "writer-buffer-pending") (result u32) (canon lift (core func $m "writer-buffer-pending"))) + (func (export "drop-folds-into-full-progress") (result u32) (canon lift (core func $m "drop-folds-into-full-progress"))) + (func (export "drop-folds-into-partial-progress") (result u32) (canon lift (core func $m "drop-folds-into-partial-progress"))) +) +(component instance $i $Tester) +(assert_return (invoke "writer-buffer-pending") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "drop-folds-into-full-progress") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "drop-folds-into-partial-progress") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/zero-length.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/zero-length.wast index cbb5b1200..0a3a3f8a7 100644 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/zero-length.wast +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/zero-length.wast @@ -221,3 +221,313 @@ (func (export "run") (alias export $parent "run")) ) (assert_return (invoke "run") (u32.const 42)) + +;; Test a bunch of different zero-length combinations from within a single +;; component instance +(component definition $Tester + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $M + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "stream.new" (func $stream.new (result i64))) + (import "" "stream.read" (func $stream.read (param i32 i32 i32) (result i32))) + (import "" "stream.write" (func $stream.write (param i32 i32 i32) (result i32))) + (import "" "stream.cancel-read" (func $stream.cancel-read (param i32) (result i32))) + (import "" "stream.cancel-write" (func $stream.cancel-write (param i32) (result i32))) + + (global $ws (mut i32) (i32.const 0)) + (global $rx (mut i32) (i32.const 0)) + (global $tx (mut i32) (i32.const 0)) + + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + + ;; Create a fresh stream with both ends in the waitable set. + (func $new-stream + (local $ret64 i64) + (local.set $ret64 (call $stream.new)) + (global.set $rx (i32.wrap_i64 (local.get $ret64))) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (call $waitable.join (global.get $rx) (global.get $ws)) + (call $waitable.join (global.get $tx) (global.get $ws)) + ) + (func $write (param $n i32) (result i32) + (call $stream.write (global.get $tx) (i32.const 64) (local.get $n)) + ) + (func $read (param $n i32) (result i32) + (call $stream.read (global.get $rx) (i32.const 128) (local.get $n)) + ) + (func $expect-event (param $code i32) (param $index i32) (param $payload i32) + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 0)) (local.get $code)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $index)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (local.get $payload)) + (then unreachable)) + ) + (func $expect-no-event + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + ) + + ;; Two zero-length ops meet: the write completes but the read stays + ;; pending. A later real write completes the pending zero-length read and + ;; blocks, and then a real read transfers. + (func (export "zero-write-then-zero-read") (result i32) + (call $new-stream) + (if (i32.ne (call $write (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (call $expect-no-event) + (if (i32.ne (call $write (i32.const 3)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (if (i32.ne (call $read (i32.const 3)) (i32.const 0x30 (; COMPLETED=0 | (3<<4) ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x30 (; COMPLETED=0 | (3<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A zero-length write meets a pending zero-length read completes eagerly, + ;; leaving the read pending. + (func (export "zero-read-then-zero-write") (result i32) + (call $new-stream) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 0)) (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (then unreachable)) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A pending zero-length read is completed by a real write arriving, which + ;; itself blocks; the reader's subsequent real read then transfers. + (func (export "zero-read-then-write") (result i32) + (call $new-stream) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (if (i32.ne (call $read (i32.const 3)) (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A pending zero-length write is completed by a real read arriving, which + ;; itself blocks; the writer's subsequent real write then transfers. + (func (export "zero-write-then-read") (result i32) + (call $new-stream) + (if (i32.ne (call $write (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (if (i32.ne (call $write (i32.const 3)) (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A zero-length read meeting a pending real write completes eagerly and + ;; leaves the write pending. + (func (export "write-then-zero-read") (result i32) + (call $new-stream) + (if (i32.ne (call $write (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 0)) (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (then unreachable)) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A zero-length write meets a pending real read completes eagerly and + ;; leaves the read pending. + (func (export "read-then-zero-write") (result i32) + (call $new-stream) + (if (i32.ne (call $read (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 0)) (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (then unreachable)) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A zero-length op may be re-issued after completing: the peer's buffer is + ;; still at rest, so the second one completes eagerly. + (func (export "reissue-zero-write") (result i32) + (call $new-stream) + (if (i32.ne (call $write (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 3)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (if (i32.ne (call $write (i32.const 0)) (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (then unreachable)) + (call $expect-no-event) + (i32.const 42) + ) + (func (export "reissue-zero-read") (result i32) + (call $new-stream) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 3)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (if (i32.ne (call $read (i32.const 0)) (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (then unreachable)) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A peer buffer that has been filled but whose event has not yet been + ;; delivered is not considered pending: the zero-length write blocks until + ;; the reader supplies a new buffer. + (func (export "zero-read-after-full-write") (result i32) + (call $new-stream) + (if (i32.ne (call $write (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 2)) (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (call $expect-no-event) + (if (i32.ne (call $write (i32.const 1)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + (func (export "zero-write-after-full-read") (result i32) + (call $new-stream) + (if (i32.ne (call $read (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 2)) (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x20 (; COMPLETED=0 | (2<<4) ;))) + (call $expect-no-event) + (if (i32.ne (call $read (i32.const 2)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + + ;; A zero-length op that has already completed, but whose event has not + ;; been delivered yet, does not rendezvous with a zero-length write. + (func (export "zero-read-after-cancelled-read") (result i32) + (call $new-stream) + (if (i32.ne (call $write (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 4)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $stream.cancel-read (global.get $rx)) + (i32.const 0x2 (; CANCELLED=2 | (0<<4) ;))) + (then unreachable)) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 3 (; STREAM_WRITE ;)) (global.get $tx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + (func (export "zero-write-after-cancelled-write") (result i32) + (call $new-stream) + (if (i32.ne (call $read (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 4)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $stream.cancel-write (global.get $tx)) + (i32.const 0x2 (; CANCELLED=2 | (0<<4) ;))) + (then unreachable)) + (if (i32.ne (call $write (i32.const 0)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $expect-event (i32.const 2 (; STREAM_READ ;)) (global.get $rx) + (i32.const 0x00 (; COMPLETED=0 | (0<<4) ;))) + (call $expect-no-event) + (i32.const 42) + ) + ) + (type $ST (stream u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon stream.new $ST (core func $stream.new)) + (canon stream.read $ST async (memory (core memory $memory "mem")) (core func $stream.read)) + (canon stream.write $ST async (memory (core memory $memory "mem")) (core func $stream.write)) + (canon stream.cancel-read $ST async (core func $stream.cancel-read)) + (canon stream.cancel-write $ST async (core func $stream.cancel-write)) + (core instance $m (instantiate $M (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "stream.new" (func $stream.new)) + (export "stream.read" (func $stream.read)) + (export "stream.write" (func $stream.write)) + (export "stream.cancel-read" (func $stream.cancel-read)) + (export "stream.cancel-write" (func $stream.cancel-write)) + )))) + (func (export "zero-write-then-zero-read") (result u32) (canon lift (core func $m "zero-write-then-zero-read"))) + (func (export "zero-read-then-zero-write") (result u32) (canon lift (core func $m "zero-read-then-zero-write"))) + (func (export "zero-read-then-write") (result u32) (canon lift (core func $m "zero-read-then-write"))) + (func (export "zero-write-then-read") (result u32) (canon lift (core func $m "zero-write-then-read"))) + (func (export "write-then-zero-read") (result u32) (canon lift (core func $m "write-then-zero-read"))) + (func (export "read-then-zero-write") (result u32) (canon lift (core func $m "read-then-zero-write"))) + (func (export "reissue-zero-write") (result u32) (canon lift (core func $m "reissue-zero-write"))) + (func (export "reissue-zero-read") (result u32) (canon lift (core func $m "reissue-zero-read"))) + (func (export "zero-read-after-full-write") (result u32) (canon lift (core func $m "zero-read-after-full-write"))) + (func (export "zero-write-after-full-read") (result u32) (canon lift (core func $m "zero-write-after-full-read"))) + (func (export "zero-read-after-cancelled-read") (result u32) (canon lift (core func $m "zero-read-after-cancelled-read"))) + (func (export "zero-write-after-cancelled-write") (result u32) (canon lift (core func $m "zero-write-after-cancelled-write"))) +) +(component instance $i $Tester) +(assert_return (invoke "zero-write-then-zero-read") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-read-then-zero-write") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-read-then-write") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-write-then-read") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "write-then-zero-read") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "read-then-zero-write") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "reissue-zero-write") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "reissue-zero-read") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-read-after-full-write") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-write-after-full-read") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-read-after-cancelled-read") (u32.const 42)) +(component instance $i $Tester) +(assert_return (invoke "zero-write-after-cancelled-write") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json index e76fbfed8..b49f1ecd5 100644 --- a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json +++ b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json @@ -67,13 +67,17 @@ "name": "futures-must-write.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" }, + { + "name": "future-completion-order.wast", + "revision": "e6832646ee9c1e59444294a5f9f22af3d2c7f76d" + }, { "name": "idle-drop.wast", "revision": "5a98cd0744de401faa16d1321dbfe1c67ef5bd74" }, { "name": "partial-stream-copies.wast", - "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" + "revision": "e6832646ee9c1e59444294a5f9f22af3d2c7f76d" }, { "name": "passing-resources.wast", @@ -117,7 +121,7 @@ }, { "name": "zero-length.wast", - "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" + "revision": "e6832646ee9c1e59444294a5f9f22af3d2c7f76d" } ] } diff --git a/packages/jco-transpile/test/p3/ported/component-model/wast.ts b/packages/jco-transpile/test/p3/ported/component-model/wast.ts index 8d79b4597..144bf251f 100644 --- a/packages/jco-transpile/test/p3/ported/component-model/wast.ts +++ b/packages/jco-transpile/test/p3/ported/component-model/wast.ts @@ -37,6 +37,7 @@ const WAST_TESTS: readonly WastTest[] = [ { relPath: 'async/cancel-stream.wast' }, { relPath: 'async/partial-stream-copies.wast' }, { relPath: 'async/futures-must-write.wast' }, + { relPath: 'async/future-completion-order.wast' }, { relPath: 'async/idle-drop.wast' }, { relPath: 'async/empty-wait.wast' }, { relPath: 'async/zero-length.wast' }, From 8b1b4299683b0d0935a89164bcce0b7cf237672b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 17 Sep 2026 14:24:40 +0000 Subject: [PATCH 03/11] fix(bindgen): tighten subtask cancellation scheduling Drive one ready thread from the callee instance when cancellation is requested. Preserve eager cancellation-before-start and avoid resolving synchronously unwinding callees twice. Update the Wasmtime translator for wasm-tools 0.258 and adapt its trampoline representation. --- .../src/intrinsics/component.rs | 26 +++++++- .../src/intrinsics/mod.rs | 15 +++-- .../src/intrinsics/p3/async_task.rs | 66 +++++++++++-------- .../src/intrinsics/p3/host.rs | 8 ++- .../src/transpile_bindgen.rs | 8 ++- 5 files changed, 86 insertions(+), 37 deletions(-) diff --git a/crates/js-component-bindgen/src/intrinsics/component.rs b/crates/js-component-bindgen/src/intrinsics/component.rs index 1d4e03d37..914ae68e5 100644 --- a/crates/js-component-bindgen/src/intrinsics/component.rs +++ b/crates/js-component-bindgen/src/intrinsics/component.rs @@ -773,7 +773,11 @@ impl ComponentIntrinsic { if (!meta.readyFn) {{ throw new Error(`suspended task [${{taskID}}] is missing a readiness function`); }} - return meta.task.isRejected() || meta.readyFn(); + if (meta.task.isRejected()) {{ return true; }} + if (!meta.readyFn()) {{ return false; }} + return !meta.task.needsExclusiveLock() + || !this.isExclusivelyLocked() + || this.exclusivelyLockedBy(taskID); }} suspendedTaskCancellable(taskID) {{ @@ -788,6 +792,24 @@ impl ComponentIntrinsic { return this.#suspendedTasksByTaskID.values(); }} + // Resume one ready thread in this component instance and return + // a promise which settles when that execution slice either exits + // or suspends again. `subtask.cancel` uses this to implement the + // nondeterministic callee-instance scheduling step required by + // Task.request_cancellation. + resumeOneReadyTask() {{ + for (const taskID of this.#suspendedTaskIDs) {{ + if (taskID === null || !this.suspendedTaskReady(taskID)) {{ continue; }} + const meta = this.#getSuspendedTaskMeta(taskID); + const progress = meta.task.waitForProgress(); + if (!this.resumeTaskByID(taskID)) {{ + throw new Error(`failed to resume ready task [${{taskID}}]`); + }} + return {{ task: meta.task, progress }}; + }} + return null; + }} + addPendingTaskStart() {{ this.#pendingTaskStarts++; }} removePendingTaskStart() {{ this.#pendingTaskStarts--; }} @@ -839,7 +861,7 @@ impl ComponentIntrinsic { return {component_async_state_class}.TickResult.RESUMED; }} - const isReady = meta.readyFn(); + const isReady = this.suspendedTaskReady(taskID); if (!isReady) {{ continue; }} {debug_log_fn}('[{component_async_state_class}#tick()] resuming task via tick', {{ diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 536776786..5d1753fea 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1740,20 +1740,18 @@ mod tests { } #[test] - fn subtask_cancel_drives_one_cancellable_child_slice() { + fn subtask_cancel_drives_one_ready_callee_instance_slice() { let cancel = render_intrinsic_body(Intrinsic::AsyncTask(AsyncTaskIntrinsic::SubtaskCancel)); - assert!(cancel.contains("childState.suspendedTaskReady(childTask.id())")); - assert!(cancel.contains("childState.resumeTaskByID(childTask.id())")); + assert!(cancel.contains("childState.resumeOneReadyTask()")); assert!(cancel.contains("function subtaskCancel")); assert!(!cancel.contains("async function subtaskCancel")); assert!(cancel.contains(".then(finishCancel)")); assert!(cancel.contains("cancellationWillCompleteAsync ? 0xFFFFFFFE : 0xFFFFFFFF")); + assert!(cancel.contains("if (!slowOnly) { return 0xFFFFFFFE; }")); assert!(cancel.contains("childState.exclusivelyLockedBy(childTask.id())")); assert!(cancel.contains("!childState.isTaskSuspended(childTask.id())")); assert!(cancel.contains("return progress.then(() =>")); - let subscribe = cancel.find("childTask?.waitForProgress()").unwrap(); - let request = cancel.find("subtask.requestCancellation();").unwrap(); - assert!(subscribe < request); + assert!(!cancel.contains("childTask.cancel()")); let task = render_intrinsic_body(Intrinsic::AsyncTask(AsyncTaskIntrinsic::AsyncTaskClass)); assert!(task.contains("suspendUntilCallback(opts, onResume)")); @@ -1777,6 +1775,8 @@ mod tests { assert!(state.contains("suspendedTaskCancellable(taskID)")); assert!(state.contains("task.notifyProgress();")); assert!(state.contains("suspendedTaskReady(taskID)")); + assert!(state.contains("resumeOneReadyTask()")); + assert!(state.contains("const progress = meta.task.waitForProgress();")); } #[test] @@ -2001,7 +2001,8 @@ mod tests { assert!(enter.contains("isAsync: false,")); assert!(enter.contains("isAsync: !!calleeIsAsync,")); assert!(enter.contains("isManualAsync: callerTask.isManualAsync(),")); - assert!(enter.contains("syncOnly && !calleeIsAsync && cstate.isExclusivelyLocked()")); + assert!(enter.contains("syncOnly && calleeIsAsync && cstate.isExclusivelyLocked()")); + assert!(enter.contains("callingWasmExport: !!calleeIsAsync,")); assert!(enter.contains("return 0;")); assert!(enter.contains("return 1;")); assert!(enter.contains("previousTaskMayBlock: CURRENT_TASK_MAY_BLOCK.value,")); diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs index 8b53b4c58..2b116d74e 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs @@ -694,7 +694,14 @@ impl AsyncTaskIntrinsic { subtask.cancelProgress = null; return progress.then(() => {{ if (subtask.isResolved()) {{ return finishCancel(); }} - return 0xFFFFFFFF; + if (isAsync) {{ return 0xFFFFFFFF; }} + const {{ taskID }} = {get_global_current_task_meta_fn}(componentIdx); + const taskMeta = {current_task_get_fn}(componentIdx, taskID); + if (!taskMeta || !taskMeta.task) {{ throw new Error('invalid/missing async task'); }} + return taskMeta.task.waitUntil({{ + cancellable: false, + readyFn: () => subtask.isResolved(), + }}).then(finishCancel); }}); }} @@ -711,28 +718,19 @@ impl AsyncTaskIntrinsic { if (!subtask.isResolved()) {{ const childTask = subtask.getChildTask(); - // Subscribe before resuming: a synchronous callback may - // reach its next suspension during this call. - const childProgress = isAsync ? childTask?.waitForProgress() : null; subtask.requestCancellation(); if (!subtask.isResolved()) {{ - // Cancellation immediately resumes one cancellable child - // execution slice. Wait until that slice releases component - // entry by exiting or suspending again before deciding whether - // the cancel blocked. + // Cancellation may resume any ready thread in the callee's + // component instance, not only the cancelled task's implicit + // thread. Drive at most one slice: the host is allowed to stop + // after any resume, and doing so also bounds a ready yield loop. if (childTask) {{ const childState = {get_or_create_async_state_fn}(childTask.componentIdx()); - if (subtask.getStateNumber() === 0 && - childTask.deliverPendingCancel({{ cancellable: true }})) {{ - childTask.cancel(); - childState.resumeTaskByID(childTask.id()); - }} else if (childState.suspendedTaskReady(childTask.id())) {{ - cancellationWillCompleteAsync = - childState.suspendedTaskCancellable(childTask.id()); - if (!childState.resumeTaskByID(childTask.id())) {{ - throw new Error('failed to resume cancellable subtask'); - }} + const resumed = childState.resumeOneReadyTask(); + if (resumed) {{ + cancellationWillCompleteAsync = true; + subtask.cancelProgress = resumed.progress; }} else if (childTask.hasCallback() && childState.exclusivelyLockedBy(childTask.id()) && !childState.isTaskSuspended(childTask.id())) {{ @@ -751,7 +749,6 @@ impl AsyncTaskIntrinsic { // while sync-lowered cancels block the current task until the // subtask resolves. if (isAsync) {{ - if (cancellationWillCompleteAsync) {{ subtask.cancelProgress = childProgress; }} // -1 is the canonical BLOCKED status. -2 is an // internal signal consumed by the conditional JSPI // trampoline when a cancellable child was resumed but @@ -759,6 +756,11 @@ impl AsyncTaskIntrinsic { return cancellationWillCompleteAsync ? 0xFFFFFFFE : 0xFFFFFFFF; }} + // The fast half of the conditional JSPI trampoline + // cannot return a Promise. Signal that the slow, + // suspending half must finish the synchronous cancel. + if (!slowOnly) {{ return 0xFFFFFFFE; }} + const {{ taskID }} = {get_global_current_task_meta_fn}(componentIdx); const taskMeta = {current_task_get_fn}(componentIdx, taskID); if (!taskMeta || !taskMeta.task) {{ throw new Error('invalid/missing async task'); }} @@ -1668,6 +1670,16 @@ impl AsyncTaskIntrinsic { this.cancelRequested = true; if (this.#state === {task_class}.State.INITIAL) {{ this.#state = {task_class}.State.CANCEL_PENDING; + // A task still waiting for backpressure or its + // initial instance lock has no guest thread to + // resume. Cancellation is therefore delivered + // and acknowledged eagerly as + // CANCELLED_BEFORE_STARTED. + if (!this.#entered) {{ + this.deliverPendingCancel({{ cancellable: true }}); + this.cancel(); + return; + }} }} // Nudge the component's tick loop so that any suspended cancellable // wait observes the pending cancellation promptly @@ -3237,7 +3249,7 @@ impl AsyncTaskIntrinsic { // invoking its Suspending fallback. Avoid creating either // task until the probe knows that entry can complete in the // current Wasm slice. - if (syncOnly && !calleeIsAsync && cstate.isExclusivelyLocked()) {{ + if (syncOnly && calleeIsAsync && cstate.isExclusivelyLocked()) {{ return 0; }} @@ -3270,6 +3282,10 @@ impl AsyncTaskIntrinsic { 'task', 'new-sync-guest-task', ].join("/"), + // Sync-typed functions do not participate in an + // instance's async exclusive-lock protocol. This is + // what permits ordinary synchronous reentrance. + callingWasmExport: !!calleeIsAsync, }}); subtask.setChildTask(newTask); @@ -3329,8 +3345,6 @@ impl AsyncTaskIntrinsic { ); let get_current_task_fn = render_args .require_intrinsic(Intrinsic::AsyncTask(AsyncTaskIntrinsic::GetCurrentTask)); - let clear_global_current_task_meta_fn = - render_args.require_intrinsic(Intrinsic::ClearGlobalCurrentTaskMetaFn); let set_global_current_task_meta_fn = render_args.require_intrinsic(Intrinsic::SetGlobalCurrentTaskMetaFn); let symmetric_sync_guest_call_stack = @@ -3367,10 +3381,10 @@ impl AsyncTaskIntrinsic { {current_task_may_block}.value = previousTaskMayBlock; - {clear_global_current_task_meta_fn}({{ - taskID: task.id(), - componentIdx: task.componentIdx(), - }}); + // Restoring the caller directly is important for recursive + // calls into the same component: an enclosing synchronous + // metadata wrapper may already have restored its task before + // this exit trampoline runs. {set_global_current_task_meta_fn}({{ taskID: callerTask.id(), componentIdx: callerTask.componentIdx(), diff --git a/crates/js-component-bindgen/src/intrinsics/p3/host.rs b/crates/js-component-bindgen/src/intrinsics/p3/host.rs index 80bf953b2..0ab5355e8 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/host.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/host.rs @@ -538,7 +538,13 @@ impl HostIntrinsic { if (preparedTask.needsExclusiveLock()) {{ calleeComponentState.exclusiveRelease(preparedTask.id()); }} - preparedTask.resolve([callbackResult]); + // Cancellation can resolve the task while the + // synchronous callee is still unwinding. Its + // eventual return only ends the slice; it must + // not publish a second resolution. + if (!preparedTask.isResolved()) {{ + preparedTask.resolve([callbackResult]); + }} preparedTask.exit({{ skipExclusiveLockCheck: true }}); return; }} diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index f4c7dd5e8..6544e4826 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -1875,7 +1875,13 @@ impl<'a> Instantiator<'a, '_> { } else { uwriteln!( self.src.js, - "const trampoline{i} = new WebAssembly.Suspending({suspending_wrap_fn}({instance_idx}, {subtask_cancel_fn}.bind(null, {instance_idx}, false)));\n", + r#" + const trampoline{i}Cancel = {subtask_cancel_fn}.bind(null, {instance_idx}, false); + const trampoline{i} = {conditional_suspending_fn}( + trampoline{i}Cancel, + {suspending_wrap_fn}({instance_idx}, (subtaskRep) => trampoline{i}Cancel(subtaskRep, true)), + ); + "#, instance_idx = instance.as_u32(), ); } From 8d0091700a6f85460158eb66af5d02b1e61e5558 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 17 Sep 2026 14:24:49 +0000 Subject: [PATCH 04/11] test(transpile): sync tightened cancellation WASTs Import the component-model#723 cancellation cases and run the new reentrant cancellation artifact. Remove the obsolete may_enter trap fixture deleted upstream. --- .../async/big-interleaving-test.wast | 45 +- .../async/cancel-delivery.wast | 401 ++++++++ .../async/cancel-instance-wide-resume.wast | 185 ++++ .../component-model/async/cancel-subtask.wast | 66 +- .../async/cancel-yield-loop.wast | 135 +++ .../component-model/async/reentrance.wast | 864 ++++++++++++++++++ .../test/fixtures/wast/upstream-manifest.json | 20 +- .../test/p3/ported/component-model/wast.ts | 38 +- 8 files changed, 1733 insertions(+), 21 deletions(-) create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/reentrance.wast diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/big-interleaving-test.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/big-interleaving-test.wast index d6c18bdff..80365d1a3 100644 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/big-interleaving-test.wast +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/big-interleaving-test.wast @@ -222,6 +222,19 @@ (func (export "subtask-drop") (param $sub-slot i32) (call $subtask.drop (call $sub (local.get $sub-slot)))) + ;; Cancel the subtask and expect the given resolved state. 'subtask.cancel' + ;; resumes the cancelled task directly and each subtask cancelled here + ;; resolves upon receiving TASK_CANCELLED, so the cancellation completes + ;; eagerly rather than reporting BLOCKED. + (func (export "subtask-cancel-await") (param $sub-slot i32) (param $expected-state i32) + (local $st i32) (local $ret i32) + (local.set $st (call $sub (local.get $sub-slot))) + (local.set $ret (call $subtask.cancel (local.get $st))) + (if (i32.ne (local.get $ret) (local.get $expected-state)) + (then unreachable)) + (call $subtask.drop (local.get $st)) + (call $task.return)) + (func (export "mock-bp-inc") (call $bp-inc)) (func (export "mock-bp-dec") (call $bp-dec)) @@ -412,6 +425,8 @@ (func (export "mock-bp-dec") (canon lift (core func $tm "mock-bp-dec"))) (func (export "await-subtask") async (param "sub" u8) (param "state" u8) (canon lift (core func $tm "await-subtask") async (memory (core memory $memory "mem")))) + (func (export "subtask-cancel-await") async (param "sub" u8) (param "state" u8) (canon lift + (core func $tm "subtask-cancel-await") async (memory (core memory $memory "mem")))) (func (export "stream-new") (param "slot" u8) (canon lift (core func $tm "stream-new"))) (func (export "testee-write") (param "handle" u8) (param "bytes" u32) (result s32) (canon lift (core func $tm "testee-write"))) (func (export "testee-read") (param "handle" u8) (param "bytes" u32) (result s32) (canon lift (core func $tm "testee-read"))) @@ -473,7 +488,8 @@ (case "subtask-drop" u8) (case "await-subtask" $sub-expect-e) (case "mock-bp-inc") - (case "mock-bp-dec"))) + (case "mock-bp-dec") + (case "subtask-cancel-await" $sub-expect-e))) (export $command-e "command" (type $command)) (import "call-import" (func $call-import (param "slot" u8) (result s32))) (import "stream-new" (func $stream-new (param "slot" u8))) @@ -500,6 +516,7 @@ (import "subtask-cancel" (func $subtask-cancel (param "sub" u8) (result s32))) (import "subtask-drop" (func $subtask-drop (param "sub" u8))) (import "await-subtask" (func $await-subtask async (param "sub" u8) (param "state" u8))) + (import "subtask-cancel-await" (func $subtask-cancel-await async (param "sub" u8) (param "state" u8))) (import "mock-bp-inc" (func $mock-bp-inc)) (import "mock-bp-dec" (func $mock-bp-dec)) @@ -529,6 +546,7 @@ (import "" "subtask-cancel" (func $subtask-cancel (param i32) (result i32))) (import "" "subtask-drop" (func $subtask-drop (param i32))) (import "" "await-subtask" (func $await-subtask (param i32 i32) (result i32))) + (import "" "subtask-cancel-await" (func $subtask-cancel-await (param i32 i32) (result i32))) (import "" "mock-bp-inc" (func $mock-bp-inc)) (import "" "mock-bp-dec" (func $mock-bp-dec)) (memory (export "mem") 1) @@ -572,6 +590,7 @@ (global $AWAIT_SUBTASK i32 (i32.const 28)) (global $MOCK_BP_INC i32 (i32.const 29)) (global $MOCK_BP_DEC i32 (i32.const 30)) + (global $SUBTASK_CANCEL_AWAIT i32 (i32.const 31)) (global $last (mut i32) (i32.const 0)) (global $VOID_OK i32 (i32.const 1337)) @@ -692,6 +711,11 @@ (global.set $last (call $await-subtask (i32.load8_u offset=4 (local.get $insn)) (i32.load8_u offset=5 (local.get $insn)))))) + (if (i32.eq (local.get $op) (global.get $SUBTASK_CANCEL_AWAIT)) + (then + (global.set $last (call $subtask-cancel-await + (i32.load8_u offset=4 (local.get $insn)) + (i32.load8_u offset=5 (local.get $insn)))))) (if (i32.eq (local.get $op) (global.get $MOCK_BP_INC)) (then @@ -739,6 +763,7 @@ (canon lower (func $subtask-cancel) (core func $subtask-cancel')) (canon lower (func $subtask-drop) (core func $subtask-drop')) (canon lower (func $await-subtask) async (core func $await-subtask')) + (canon lower (func $subtask-cancel-await) async (core func $subtask-cancel-await')) (canon lower (func $mock-bp-inc) (core func $mock-bp-inc')) (canon lower (func $mock-bp-dec) (core func $mock-bp-dec')) (core instance $dm (instantiate $DM (with "" (instance @@ -767,6 +792,7 @@ (export "subtask-cancel" (func $subtask-cancel')) (export "subtask-drop" (func $subtask-drop')) (export "await-subtask" (func $await-subtask')) + (export "subtask-cancel-await" (func $subtask-cancel-await')) (export "mock-bp-inc" (func $mock-bp-inc')) (export "mock-bp-dec" (func $mock-bp-dec')))))) (func (export "run") (param "prog" (list $command-e)) @@ -806,6 +832,7 @@ (with "subtask-cancel" (func $testee "subtask-cancel")) (with "subtask-drop" (func $testee "subtask-drop")) (with "await-subtask" (func $testee "await-subtask")) + (with "subtask-cancel-await" (func $testee "subtask-cancel-await")) (with "mock-bp-inc" (func $testee "mock-bp-inc")) (with "mock-bp-dec" (func $testee "mock-bp-dec")))) (instance $types @@ -1586,9 +1613,7 @@ (list.const (variant.const "call-block-empty" (u8.const 0)) (variant.const "expect-code" (s32.const 1)) - (variant.const "subtask-cancel" (u8.const 0)) - (variant.const "expect-code" (s32.const 4)) - (variant.const "subtask-drop" (u8.const 0))))) + (variant.const "subtask-cancel-await" (record.const (field "sub" u8.const 0) (field "state" u8.const 4)))))) (component instance $i $Tester) (assert_trap @@ -1620,15 +1645,9 @@ (variant.const "expect-code" (s32.const 1)) (variant.const "call-block-empty" (u8.const 2)) (variant.const "expect-code" (s32.const 1)) - (variant.const "subtask-cancel" (u8.const 2)) - (variant.const "expect-code" (s32.const 4)) - (variant.const "subtask-cancel" (u8.const 0)) - (variant.const "expect-code" (s32.const 4)) - (variant.const "subtask-cancel" (u8.const 1)) - (variant.const "expect-code" (s32.const 4)) - (variant.const "subtask-drop" (u8.const 0)) - (variant.const "subtask-drop" (u8.const 1)) - (variant.const "subtask-drop" (u8.const 2))))) + (variant.const "subtask-cancel-await" (record.const (field "sub" u8.const 2) (field "state" u8.const 4))) + (variant.const "subtask-cancel-await" (record.const (field "sub" u8.const 0) (field "state" u8.const 4))) + (variant.const "subtask-cancel-await" (record.const (field "sub" u8.const 1) (field "state" u8.const 4)))))) (assert_return (invoke "run" diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast new file mode 100644 index 000000000..dcd97aea4 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast @@ -0,0 +1,401 @@ +;; This test exercises how a request for cancellation is delivered to the +;; callee: the request is recorded as pending on the callee's task and is only +;; delivered, as a TASK_CANCELLED event, via the `async callback` event loop. +;; +;; Component $C exports three async callback-lifted functions: +;; yield-until-cancel: loops returning YIELD to its event loop until the +;; TASK_CANCELLED event arrives; one more YIELD must then report NONE +;; (cancellation is delivered at most once) before it calls task.cancel +;; deferred: blocks in a synchronous future.read in its initial core +;; function, during which a cancellation request stays pending; the pending +;; request is delivered at the first return to the event loop +;; pending-survives: like deferred, but additionally calls thread.yield and +;; waitable-set.poll after the request is pending: neither may report the +;; pending cancellation (they never return TASK_CANCELLED), which must +;; survive until the task returns to its event loop +;; +;; Component $D calls each function and cancels it. `subtask.cancel async` +;; resumes the cancelled task directly, but the host is also allowed (not +;; required) to keep resuming ready threads, so yield-until-cancel may or may +;; not get far enough to resolve before the built-in returns and $D accepts +;; both outcomes. While a task is blocked in its initial core function it +;; cannot be cancelled at all, so those cancellations deterministically report +;; BLOCKED. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + + ;; Test 1: cancellation is delivered to a task parked in its event loop + ;; after returning YIELD, and is delivered at most once + (global $cancelled (mut i32) (i32.const 0)) + (func $yield-until-cancel (export "yield-until-cancel") (result i32) + (i32.const 1 (; YIELD ;)) + ) + (func $yield-until-cancel_cb (export "yield-until-cancel_cb") (param $event_code i32) (param $index i32) (param $payload i32) (result i32) + (if (i32.eq (local.get $event_code) (i32.const 6 (; TASK_CANCELLED ;))) + (then + (if (global.get $cancelled) (then unreachable)) + (global.set $cancelled (i32.const 1)) + ;; yield once more; the next event must be NONE, not a second + ;; TASK_CANCELLED + (return (i32.const 1 (; YIELD ;))))) + (if (i32.ne (local.get $event_code) (i32.const 0 (; NONE ;))) + (then unreachable)) + (if (i32.eqz (global.get $cancelled)) + ;; not yet cancelled; keep yielding + (then (return (i32.const 1 (; YIELD ;))))) + (call $task.cancel) + (i32.const 0 (; EXIT ;)) + ) + + ;; Test 2: a cancellation request arriving while this task is blocked in + ;; a synchronous future.read stays pending and is delivered at the first + ;; return to the event loop + (func $deferred (export "deferred") (param $futr i32) (result i32) + ;; blocks until the caller writes (the caller cancels first) + (if (i32.ne (call $future.read (local.get $futr) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + ;; return to the event loop; the pending cancellation must be + ;; delivered even though this waitable set is empty + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (call $waitable-set.new) (i32.const 4))) + ) + (func $expect-cancelled_cb (export "expect-cancelled_cb") (param $event_code i32) (param $index i32) (param $payload i32) (result i32) + (if (i32.ne (local.get $event_code) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;)) + ) + + ;; Test 3: a pending cancellation is not reported by the thread.yield and + ;; waitable-set.poll built-ins (which never return TASK_CANCELLED); it + ;; survives them and is delivered at the event loop + (func $pending-survives (export "pending-survives") (param $futr i32) (result i32) + (local $ws i32) + ;; blocks until the caller writes (the caller cancels first) + (if (i32.ne (call $future.read (local.get $futr) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + ;; a pending cancellation is now set; thread.yield must not report it + (if (i32.ne (call $thread.yield) (i32.const 0)) + (then unreachable)) + ;; nor may waitable-set.poll, which must report NONE for an empty set + (local.set $ws (call $waitable-set.new)) + (if (i32.ne (call $waitable-set.poll (local.get $ws) (i32.const 0)) (i32.const 0 (; NONE ;))) + (then unreachable)) + ;; only the event loop delivers the still-pending cancellation + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (local.get $ws) (i32.const 4))) + ) + ) + (type $FT (future)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon thread.yield (core func $thread.yield)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "thread.yield" (func $thread.yield)) + )))) + (func (export "yield-until-cancel") async (canon lift + (core func $cm "yield-until-cancel") + async (callback (core func $cm "yield-until-cancel_cb")) + )) + (func (export "deferred") async (param "fut" $FT) (canon lift + (core func $cm "deferred") + async (callback (core func $cm "expect-cancelled_cb")) + )) + (func (export "pending-survives") async (param "fut" $FT) (canon lift + (core func $cm "pending-survives") + async (callback (core func $cm "expect-cancelled_cb")) + )) + ) + + (component $D + (type $FT (future)) + (import "yield-until-cancel" (func $yield-until-cancel async)) + (import "deferred" (func $deferred async (param "fut" $FT))) + (import "pending-survives" (func $pending-survives async (param "fut" $FT))) + + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $DM + (import "" "mem" (memory 1)) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (import "" "yield-until-cancel" (func $yield-until-cancel (result i32))) + (import "" "deferred" (func $deferred (param i32) (result i32))) + (import "" "pending-survives" (func $pending-survives (param i32) (result i32))) + + ;; wait until $sub resolves to CANCELLED_BEFORE_RETURNED, then drop it + (func $await-cancelled (param $sub i32) + (local $ws i32) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $sub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 8)) (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 8)) (local.get $sub)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 12)) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $waitable.join (local.get $sub) (i32.const 0)) + (call $subtask.drop (local.get $sub)) + ) + + (func $run (export "run") (result i32) + (local $ret i32) (local $ret64 i64) + (local $subtask i32) + (local $futr i32) (local $futw i32) + + ;; ========================================== + ;; Test 1: delivery to a YIELD-parked task + ;; ========================================== + + ;; call yield-until-cancel; it parks in its event loop + (local.set $ret (call $yield-until-cancel)) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) + + ;; cancel; the callee is resumed to receive TASK_CANCELLED, but it + ;; needs one more turn of its event loop to resolve, which the host may + ;; or may not give it before returning from the built-in + (local.set $ret (call $subtask.cancel (local.get $subtask))) + (if (i32.eq (local.get $ret) (i32.const -1 (; BLOCKED ;))) + (then (call $await-cancelled (local.get $subtask))) + (else + (if (i32.ne (local.get $ret) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $subtask.drop (local.get $subtask)))) + + ;; ========================================== + ;; Test 2: deferral while blocked in a synchronous built-in + ;; ========================================== + + ;; create future for deferred to read + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; call deferred; it blocks in a synchronous future.read + (local.set $ret (call $deferred (local.get $futr))) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) + + ;; cancel; deterministically blocked, since the callee cannot receive + ;; the request while blocked in its initial core function + (if (i32.ne (call $subtask.cancel (local.get $subtask)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; write to the future, unblocking the callee's synchronous read + (if (i32.ne (call $future.write (local.get $futw) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + + ;; the pending cancellation is delivered once the callee returns to + ;; its event loop + (call $await-cancelled (local.get $subtask)) + + ;; ========================================== + ;; Test 3: pending cancellation survives thread.yield and + ;; waitable-set.poll + ;; ========================================== + + ;; create future for pending-survives to read + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; call pending-survives; it blocks in a synchronous future.read + (local.set $ret (call $pending-survives (local.get $futr))) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) + + ;; cancel; deterministically blocked, as in test 2 + (if (i32.ne (call $subtask.cancel (local.get $subtask)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; write to the future, unblocking the callee's synchronous read + (if (i32.ne (call $future.write (local.get $futw) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + + ;; the callee's yield and poll must not observe the pending + ;; cancellation, which is delivered at the callee's event loop + (call $await-cancelled (local.get $subtask)) + + ;; all tests passed + (i32.const 42) + ) + ) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (canon lower (func $yield-until-cancel) async (memory (core memory $memory "mem")) (core func $yield-until-cancel')) + (canon lower (func $deferred) async (memory (core memory $memory "mem")) (core func $deferred')) + (canon lower (func $pending-survives) async (memory (core memory $memory "mem")) (core func $pending-survives')) + (core instance $dm (instantiate $DM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)) + (export "yield-until-cancel" (func $yield-until-cancel')) + (export "deferred" (func $deferred')) + (export "pending-survives" (func $pending-survives')) + )))) + (func (export "run") async (result u32) (canon lift (core func $dm "run"))) + ) + + (instance $c (instantiate $C)) + (instance $d (instantiate $D + (with "yield-until-cancel" (func $c "yield-until-cancel")) + (with "deferred" (func $c "deferred")) + (with "pending-survives" (func $c "pending-survives")) + )) + (func (export "run") (alias export $d "run")) +) +(assert_return (invoke "run") (u32.const 42)) + +;; subtask.cancel only resumes ready threads. A stackful (non-callback) task +;; parked in 'waitable-set.wait' that contains no ready waitable is not ready +;; and cannot return TASK_CANCELLED. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (global $futr (mut i32) (i32.const 0)) + (func (export "stash-fut") (param $f i32) + (global.set $futr (local.get $f))) + (func (export "stackful") + (local $ws i32) + (if (i32.ne (call $future.read (global.get $futr) (i32.const 0)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (global.get $futr) (local.get $ws)) + ;; Parked here, this task is not cancellable: only a FUTURE_READ can + ;; wake it, never a TASK_CANCELLED. + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 8)) + (i32.const 4 (; FUTURE_READ ;))) + (then unreachable)) + (call $task.return (i32.const 42))) + ) + (type $FT (future)) + (canon task.return (result u32) (core func $task.return)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.return" (func $task.return)) + (export "future.read" (func $future.read)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "stash-fut") (param "fut" $FT) (canon lift (core func $cm "stash-fut"))) + (func (export "stackful") async (result u32) (canon lift (core func $cm "stackful") async)) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "stash-fut") (core func $stash-fut')) + (canon lower (func $c "stackful") async (memory (core memory $memory "mem")) (core func $stackful')) + (canon subtask.cancel async (core func $subtask.cancel-async)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "stash-fut" (func $stash-fut (param i32))) + (import "" "stackful" (func $stackful (param i32) (result i32))) + (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) + (local $packed i32) (local $sub i32) (local $ws i32) + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (call $stash-fut (local.get $futr)) + (local.set $packed (call $stackful (i32.const 24))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + ;; no ready thread to resume, so the request is only recorded + (if (i32.ne (call $subtask.cancel-async (local.get $sub)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + ;; unblock the stackful task; it never sees the cancellation + (if (i32.ne (call $future.write (local.get $futw) (i32.const 16)) + (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $sub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $sub)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 42)) + (then unreachable)) + (call $waitable.join (local.get $sub) (i32.const 0)) + (call $subtask.drop (local.get $sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "stash-fut" (func $stash-fut')) + (export "stackful" (func $stackful')) + (export "subtask.cancel-async" (func $subtask.cancel-async)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast new file mode 100644 index 000000000..7fe242a15 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast @@ -0,0 +1,185 @@ +;; The resume performed by 'subtask.cancel' may pick any ready thread of the +;; callee's instance, not just a thread of the task being cancelled. This test +;; forces that distinction by arranging for the only ready thread in $C to +;; belong to a different task than the one being cancelled. +;; +;; At least one resume always happens, so hold-lock always runs to completion +;; here, which the caller observes by polling. Whether the host then keeps +;; resuming threads is nondeterministic: it may stop there, in which case the +;; cancel reports BLOCKED and park is woken later once the caller blocks, or it +;; may go on to resume park (now that hold-lock has released the lock) and +;; report CANCELLED_BEFORE_RETURNED eagerly. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + + ;; Parks in the event loop on a waitable set that never gets an event, so + ;; only the delivery of a pending cancellation can wake this task which + ;; cannot happen while the exclusive lock is held. + (global $never (mut i32) (i32.const 0)) + (func $start (global.set $never (call $waitable-set.new))) + (start $start) + (func (export "park") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $never) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + + ;; Blocks holding the instance's exclusive lock for as long as it is + ;; blocked. Once the caller writes the future, this task's thread is ready + ;; but the lock is still held. + (func (export "hold-lock") (param $futr i32) (result i32) + (local $ws i32) + (if (i32.ne (i32.const -1 (; BLOCKED ;)) + (call $future.read (local.get $futr) (i32.const 0))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $futr) (local.get $ws)) + (if (i32.ne (i32.const 4 (; FUTURE_READ ;)) + (call $waitable-set.wait (local.get $ws) (i32.const 8))) + (then unreachable)) + (call $task.return (i32.const 43)) + (i32.const 0 (; EXIT ;))) + (func (export "unreachable-cb") (param i32 i32 i32) (result i32) + unreachable) + ) + (type $FT (future)) + (canon task.return (result u32) (core func $task.return)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.return" (func $task.return)) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "park") async + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + (func (export "hold-lock") async (param "fut" $FT) (result u32) + (canon lift (core func $cm "hold-lock") async (callback (core func $cm "unreachable-cb")))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "park") async (core func $park')) + (canon lower (func $c "hold-lock") async (memory (core memory $memory "mem")) (core func $hold-lock')) + (canon subtask.cancel async (core func $subtask.cancel-async)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "park" (func $park (result i32))) + (import "" "hold-lock" (func $hold-lock (param i32 i32) (result i32))) + (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) + (local $packed i32) (local $ret i32) + (local $park-sub i32) (local $hold-sub i32) + (local $park-ws i32) (local $hold-ws i32) + + ;; start "park"; returning WAIT releases the lock, so it is free here + (local.set $packed (call $park)) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $park-sub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; start hold-lock; it takes the lock and blocks while holding it + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (local.set $packed (call $hold-lock (local.get $futr) (i32.const 24))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $hold-sub (i32.shr_u (local.get $packed) (i32.const 4))) + (local.set $hold-ws (call $waitable-set.new)) + (call $waitable.join (local.get $hold-sub) (local.get $hold-ws)) + + ;; complete the read: hold-lock's thread is now ready, but it still + ;; holds the lock and nothing has resumed it + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $futw) (i32.const 16))) + (then unreachable)) + (if (i32.ne (call $waitable-set.poll (local.get $hold-ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + + ;; cancel park: its own thread is not ready while the lock is held, so + ;; the only candidate belongs to hold-lock + (local.set $ret (call $subtask.cancel-async (local.get $park-sub))) + + ;; hold-lock must have been resumed by the cancel and run to completion + (if (i32.ne (call $waitable-set.poll (local.get $hold-ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $hold-sub)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 43)) + (then unreachable)) + (call $waitable.join (local.get $hold-sub) (i32.const 0)) + (call $subtask.drop (local.get $hold-sub)) + + ;; if the host stopped after resuming hold-lock, the request is still + ;; only pending; with the lock now free, blocking lets park receive it + (if (i32.eq (local.get $ret) (i32.const -1 (; BLOCKED ;))) + (then + (local.set $park-ws (call $waitable-set.new)) + (call $waitable.join (local.get $park-sub) (local.get $park-ws)) + (if (i32.ne (call $waitable-set.wait (local.get $park-ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $park-sub)) + (then unreachable)) + (local.set $ret (i32.load (i32.const 4))) + (call $waitable.join (local.get $park-sub) (i32.const 0)))) + (if (i32.ne (local.get $ret) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $subtask.drop (local.get $park-sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "park" (func $park')) + (export "hold-lock" (func $hold-lock')) + (export "subtask.cancel-async" (func $subtask.cancel-async)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-subtask.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-subtask.wast index e2f87b602..f60a82f14 100644 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-subtask.wast +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-subtask.wast @@ -1,7 +1,10 @@ ;; This test contains two components $C and $D where $D imports and calls $C. ;; $D.run calls $C.f, which blocks on an empty waitable set -;; $D.run then subtask.cancels $C.f, which resumes $C.f which promptly resolves -;; without returning a value. +;; $D.run then subtask.cancels $C.f; the pending cancellation request is +;; delivered to $C.f's event loop as a TASK_CANCELLED event, whereupon $C.f +;; resolves without returning a value. `subtask.cancel async` resumes $C.f's +;; thread directly, so $C.f receives the event and resolves before +;; subtask.cancel returns, which therefore completes eagerly. (component (component $C (core module $Memory (memory (export "mem") 1)) @@ -114,7 +117,8 @@ (then unreachable)) (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) - ;; cancel 'f'; it should complete without blocking + ;; cancel 'f'; 'f' is resumed directly to receive TASK_CANCELLED and + ;; resolves before subtask.cancel returns, so this completes eagerly (local.set $ret (call $subtask.cancel (local.get $subtask))) (if (i32.ne (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;)) (local.get $ret)) (then unreachable)) @@ -199,3 +203,59 @@ (func (export "run") (alias export $d "run")) ) (assert_return (invoke "run") (u32.const 42)) + +;; Because 'subtask.cancel' resumes the cancelled task directly rather than +;; waiting for the host to schedule it, the synchronous form can resolve the +;; subtask without ever blocking. +(component + (component $C2 + (canon task.cancel (core func $task.cancel)) + (canon waitable-set.new (core func $waitable-set.new)) + (core module $CM + (import "" "task.cancel" (func $task.cancel)) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (global $never (mut i32) (i32.const 0)) + (func $start (global.set $never (call $waitable-set.new))) + (start $start) + (func (export "park") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $never) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ) + (core instance $cm (instantiate $CM (with "" (instance + (export "task.cancel" (func $task.cancel)) + (export "waitable-set.new" (func $waitable-set.new)))))) + (func (export "park") async + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + ) + (instance $c2 (instantiate $C2)) + (canon lower (func $c2 "park") async (core func $park')) + (canon subtask.cancel (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + + (core module $Main + (import "" "park" (func $park (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (func (export "run") (result i32) + (local $packed i32) (local $sub i32) + (local.set $packed (call $park)) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (if (i32.ne (call $subtask.cancel (local.get $sub)) + (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $subtask.drop (local.get $sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "park" (func $park')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)))))) + (func (export "run") (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast new file mode 100644 index 000000000..b8d4dd4dc --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast @@ -0,0 +1,135 @@ +;; 'subtask.cancel' may keep resuming ready threads of the callee's instance +;; until the cancelled task resolves, but it is only *allowed* to, never +;; required to: the host may stop at any point. This test checks that, given an +;; infinite yield loop, the host eventually declares the cancellation blocked. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (global $ws (mut i32) (i32.const 0)) + (global $cancelled (mut i32) (i32.const 0)) + + (func (export "yielder") (param $futr i32) (result i32) + (if (i32.ne (i32.const -1 (; BLOCKED ;)) + (call $future.read (local.get $futr) (i32.const 0))) + (then unreachable)) + (global.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $futr) (global.get $ws)) + (i32.const 1 (; YIELD ;))) + + (func (export "yielder-cb") (param $event i32) (param i32 i32) (result i32) + ;; A delivered cancellation is remembered but cannot be acted on yet: + ;; this task must first see its future read complete. + (if (i32.eq (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then + (if (global.get $cancelled) (then unreachable)) + (global.set $cancelled (i32.const 1)) + (return (i32.const 1 (; YIELD ;))))) + (if (i32.ne (local.get $event) (i32.const 0 (; NONE ;))) + (then unreachable)) + ;; Keep spinning until the caller writes the future. Every turn of this + ;; loop leaves the thread ready again, so a host that resumed until the + ;; task resolved would never get here. + (if (i32.eq (i32.const 0 (; NONE ;)) + (call $waitable-set.poll (global.get $ws) (i32.const 8))) + (then (return (i32.const 1 (; YIELD ;))))) + ;; the read completed; the cancellation must already have arrived + (if (i32.eqz (global.get $cancelled)) (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ) + (type $FT (future)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)))))) + (func (export "yielder") async (param "fut" $FT) + (canon lift (core func $cm "yielder") async (callback (core func $cm "yielder-cb")))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "yielder") async (core func $yielder')) + (canon subtask.cancel async (core func $subtask.cancel-async)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "yielder" (func $yielder (param i32) (result i32))) + (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) + (local $packed i32) (local $sub i32) (local $ws i32) + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + (local.set $packed (call $yielder (local.get $futr))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; The callee cannot resolve until this task writes the future, which it + ;; cannot do from inside the call, so the cancel must give up and report + ;; BLOCKED rather than resuming the yield loop forever. + (if (i32.ne (call $subtask.cancel-async (local.get $sub)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; now let the yield loop see its read complete and resolve + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $futw) (i32.const 16))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $sub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $sub)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $waitable.join (local.get $sub) (i32.const 0)) + (call $subtask.drop (local.get $sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "yielder" (func $yielder')) + (export "subtask.cancel-async" (func $subtask.cancel-async)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/reentrance.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/reentrance.wast new file mode 100644 index 000000000..b2be2ee1e --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/reentrance.wast @@ -0,0 +1,864 @@ +;; Test various case of reentrance + +;; Child-to-parent reentrance +(component + (core module $M1 + (global $entered (export "entered") (mut i32) (i32.const 0)) + (func (export "back") (result i32) + (global.set $entered (i32.add (global.get $entered) (i32.const 1))) + (i32.const 5)) + ) + (core instance $m1 (instantiate $M1)) + (func $back (export "back") (result u32) (canon lift (core func $m1 "back"))) + + (component $C + (import "back" (func $back (result u32))) + (canon lower (func $back) (core func $back')) + (core module $MC + (import "" "back" (func $back (result i32))) + (func (export "go") (result i32) (i32.add (call $back) (i32.const 1))) + ) + (core instance $mc (instantiate $MC (with "" (instance (export "back" (func $back')))))) + (func (export "go") (result u32) (canon lift (core func $mc "go"))) + ) + (instance $c (instantiate $C (with "back" (func $back)))) + (canon lower (func $c "go") (core func $go')) + + (core module $M2 + (import "" "go" (func $go (result i32))) + (import "" "entered" (global $entered (mut i32))) + (func (export "run") (result i32) + (local $r i32) + (local.set $r (call $go)) + (if (i32.ne (global.get $entered) (i32.const 1)) + (then unreachable)) + (local.get $r)) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "go" (func $go')) + (export "entered" (global $m1 "entered")))))) + (func (export "run") (result u32) (canon lift (core func $m2 "run"))) +) +(assert_return (invoke "run") (u32.const 6)) + +;; Parent-to-child +(component + (component $Child + (core module $CoreChild (func (export "f") (result i32) (i32.const 11))) + (core instance $core_child (instantiate $CoreChild)) + (func (export "f") (result u32) (canon lift (core func $core_child "f"))) + ) + (instance $child (instantiate $Child)) + (canon lower (func $child "f") (core func $f')) + (core module $CoreOuter + (import "" "f" (func $f (result i32))) + (func (export "g") (result i32) (i32.add (call $f) (i32.const 1))) + ) + (core instance $core_outer (instantiate $CoreOuter (with "" (instance (export "f" (func $f')))))) + (func (export "g") (result u32) (canon lift (core func $core_outer "g"))) +) +(assert_return (invoke "g") (u32.const 12)) + +;; Mutual recursion between a parent and its child via donut wrapping +(component + (core module $M1 + (type $ft (func (param i32) (result i32))) + (table (export "tbl") 1 1 funcref) + (func (export "f") (param i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 100)) + (else (i32.add + (call_indirect (type $ft) (i32.sub (local.get 0) (i32.const 1)) (i32.const 0)) + (i32.const 1))))) + ) + (core instance $m1 (instantiate $M1)) + (func $f (export "f") (param "n" u32) (result u32) (canon lift (core func $m1 "f"))) + + (component $B + (import "f" (func $f (param "n" u32) (result u32))) + (canon lower (func $f) (core func $f')) + (core module $MB + (import "" "f" (func $f (param i32) (result i32))) + (func (export "g") (param i32) (result i32) (call $f (local.get 0))) + ) + (core instance $mb (instantiate $MB (with "" (instance (export "f" (func $f')))))) + (func (export "g") (param "n" u32) (result u32) (canon lift (core func $mb "g"))) + ) + (instance $b (instantiate $B (with "f" (func $f)))) + (canon lower (func $b "g") (core func $g')) + (core module $M2 + (import "" "tbl" (table 1 1 funcref)) + (import "" "g" (func $g (param i32) (result i32))) + (elem (i32.const 0) func $g) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "tbl" (table $m1 "tbl")) + (export "g" (func $g')))))) +) +;; f(3) -> g(2) -> f(2) -> g(1) -> f(1) -> g(0) -> f(0) = 100, +1 per level +(assert_return (invoke "f" (u32.const 3)) (u32.const 103)) +(assert_return (invoke "f" (u32.const 0)) (u32.const 100)) + +;; Reentrance through a sibling instance +(component + (core module $M1 + (type $ft (func (param i32) (result i32))) + (table (export "tbl") 1 1 funcref) + (func (export "fwd") (param i32) (result i32) + (call_indirect (type $ft) (local.get 0) (i32.const 0))) + ) + (core instance $m1 (instantiate $M1)) + (func $fwd (export "fwd") (param "n" u32) (result u32) (canon lift (core func $m1 "fwd"))) + + (component $C2 + (import "fwd" (func $fwd (param "n" u32) (result u32))) + (canon lower (func $fwd) (core func $fwd')) + (core module $M + (import "" "fwd" (func $fwd (param i32) (result i32))) + (func (export "g") (param i32) (result i32) (call $fwd (local.get 0))) + ) + (core instance $m (instantiate $M (with "" (instance (export "fwd" (func $fwd')))))) + (func (export "g") (param "n" u32) (result u32) (canon lift (core func $m "g"))) + ) + (instance $c2 (instantiate $C2 (with "fwd" (func $fwd)))) + + (component $C1 + (import "g" (func $g (param "n" u32) (result u32))) + (canon lower (func $g) (core func $g')) + (core module $M + (import "" "g" (func $g (param i32) (result i32))) + (func (export "f") (param i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 100)) + (else (i32.add (call $g (i32.sub (local.get 0) (i32.const 1))) (i32.const 1)))))) + (core instance $m (instantiate $M (with "" (instance (export "g" (func $g')))))) + (func (export "f") (param "n" u32) (result u32) (canon lift (core func $m "f"))) + ) + (instance $c1 (instantiate $C1 (with "g" (func $c2 "g")))) + (canon lower (func $c1 "f") (core func $f')) + + (core module $M2 + (import "" "tbl" (table 1 1 funcref)) + (import "" "f" (func $f (param i32) (result i32))) + (elem (i32.const 0) func $f) + (func (export "run") (param i32) (result i32) (call $f (local.get 0))) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "tbl" (table $m1 "tbl")) + (export "f" (func $f')))))) + (func (export "run") (param "n" u32) (result u32) (canon lift (core func $m2 "run"))) +) +(assert_return (invoke "run" (u32.const 2)) (u32.const 102)) + +;; A destructor is called reentrantly +(component + (core module $Indirect + (table (export "ftbl") 1 funcref) + (type $FT (func (param i32))) + (func (export "R-dtor") (param i32) + (call_indirect (type $FT) (local.get 0) (i32.const 0))) + ) + (core instance $indirect (instantiate $Indirect)) + (type $R (resource (rep i32) (dtor (core func $indirect "R-dtor")))) + (canon resource.new $R (core func $resource.new)) + + (component $D + (import "r" (type $R (sub resource))) + (canon resource.drop $R (core func $resource.drop)) + (core module $DM + (import "" "resource.drop" (func $resource.drop (param i32))) + (func (export "drop-it") (param i32) (call $resource.drop (local.get 0))) + ) + (core instance $dm (instantiate $DM (with "" (instance + (export "resource.drop" (func $resource.drop)))))) + (func (export "drop-it") (param "r" (own $R)) (canon lift (core func $dm "drop-it"))) + ) + (instance $d (instantiate $D (with "r" (type $R)))) + (canon lower (func $d "drop-it") (core func $drop-it')) + + (core module $CM + (import "" "ftbl" (table 1 funcref)) + (import "" "resource.new" (func $resource.new (param i32) (result i32))) + (import "" "drop-it" (func $drop-it (param i32))) + (global $dropped (mut i32) (i32.const 0)) + (func $dtor (param $rep i32) + (if (i32.ne (local.get $rep) (i32.const 7)) (then unreachable)) + (global.set $dropped (i32.add (global.get $dropped) (i32.const 1)))) + (elem (i32.const 0) $dtor) + (func (export "run") (result i32) + (call $drop-it (call $resource.new (i32.const 7))) + (global.get $dropped)) + ) + (core instance $cm (instantiate $CM (with "" (instance + (export "ftbl" (table $indirect "ftbl")) + (export "resource.new" (func $resource.new)) + (export "drop-it" (func $drop-it')))))) + (func (export "run") (result u32) (canon lift (core func $cm "run"))) +) +(assert_return (invoke "run") (u32.const 1)) + +;; A non-async-typed export can be reentered even while the instance's +;; exclusive lock is held +(component + (canon task.return (result u32) (core func $task.return)) + (core module $M1 + (func (export "s") (result i32) (i32.const 7)) + ) + (core instance $m1 (instantiate $M1)) + (func $s (export "s") (result u32) (canon lift (core func $m1 "s"))) + + (component $C + (import "s" (func $s (result u32))) + (canon lower (func $s) (core func $s')) + (core module $MC + (import "" "s" (func $s (result i32))) + (func (export "mid") (result i32) (i32.add (call $s) (i32.const 1))) + ) + (core instance $mc (instantiate $MC (with "" (instance (export "s" (func $s')))))) + (func (export "mid") (result u32) (canon lift (core func $mc "mid"))) + ) + (instance $c (instantiate $C (with "s" (func $s)))) + (canon lower (func $c "mid") (core func $mid')) + + (core module $M2 + (import "" "mid" (func $mid (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (func (export "a") (result i32) + (call $task.return (call $mid)) + (i32.const 0 (; EXIT ;))) + (func (export "a-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "mid" (func $mid')) + (export "task.return" (func $task.return)))))) + (func (export "a") async (result u32) + (canon lift (core func $m2 "a") async (callback (core func $m2 "a-cb")))) +) +(assert_return (invoke "a") (u32.const 8)) + +;; Reentering an async-typed export lifted with the callback ABI blocks on +;; automatic backpressure instead of trapping, and completes once the +;; outstanding task releases the instance's exclusive lock +(component + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon task.return (result u32) (core func $task.return)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon subtask.drop (core func $subtask.drop)) + + (core module $M1 + (import "" "task.return" (func $task.return (param i32))) + (func (export "b") (result i32) + (call $task.return (i32.const 42)) + (i32.const 0 (; EXIT ;))) + (func (export "b-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $m1 (instantiate $M1 (with "" (instance + (export "task.return" (func $task.return)))))) + (func $b (export "b") async (result u32) + (canon lift (core func $m1 "b") async (callback (core func $m1 "b-cb")))) + + (component $C + (import "b" (func $b async (result u32))) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon lower (func $b) async (memory (core memory $memory "mem")) (core func $b')) + (canon task.return (result u32) (core func $task.return)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon subtask.drop (core func $subtask.drop)) + (core module $MC + (import "" "mem" (memory 1)) + (import "" "b" (func $b (param i32) (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func (export "c") (result i32) + (local $packed i32) + (global.set $ws (call $waitable-set.new)) + (local.set $packed (call $b (i32.const 0))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 0 (; STARTING ;))) + (then unreachable)) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "c-cb") (param i32 i32 i32) (result i32) + (if (i32.ne (local.get 0) (i32.const 1 (; SUBTASK ;))) (then unreachable)) + (if (i32.ne (local.get 1) (global.get $sub)) (then unreachable)) + (if (i32.ne (local.get 2) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (call $subtask.drop (global.get $sub)) + (call $task.return (i32.load (i32.const 0))) + (i32.const 0 (; EXIT ;))) + ) + (core instance $mc (instantiate $MC (with "" (instance + (export "mem" (memory $memory "mem")) + (export "b" (func $b')) + (export "task.return" (func $task.return)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "subtask.drop" (func $subtask.drop)))))) + (func (export "c") async (result u32) + (canon lift (core func $mc "c") async (callback (core func $mc "c-cb")))) + ) + (instance $c (instantiate $C (with "b" (func $b)))) + (canon lower (func $c "c") async (memory (core memory $memory "mem")) (core func $c')) + + (core module $M2 + (import "" "mem" (memory 1)) + (import "" "c" (func $c (param i32) (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func (export "a") (result i32) + (local $packed i32) + (global.set $ws (call $waitable-set.new)) + (local.set $packed (call $c (i32.const 0))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "a-cb") (param i32 i32 i32) (result i32) + (if (i32.ne (local.get 0) (i32.const 1 (; SUBTASK ;))) (then unreachable)) + (if (i32.ne (local.get 1) (global.get $sub)) (then unreachable)) + (if (i32.ne (local.get 2) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (call $subtask.drop (global.get $sub)) + (call $task.return (i32.load (i32.const 0))) + (i32.const 0 (; EXIT ;))) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "mem" (memory $memory "mem")) + (export "c" (func $c')) + (export "task.return" (func $task.return)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "subtask.drop" (func $subtask.drop)))))) + (func (export "a") async (result u32) + (canon lift (core func $m2 "a") async (callback (core func $m2 "a-cb")))) +) +(assert_return (invoke "a") (u32.const 42)) + +;; The same shape as test 7, but with "a" lifted using the sync ABI, which +;; holds the exclusive lock for the whole call instead of releasing it at +;; each event-loop turn. Reentrance still doesn't trap, but "b" can now +;; never acquire the lock, so the cycle deadlocks. +(component + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon task.return (result u32) (core func $task.return)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $M1 + (import "" "task.return" (func $task.return (param i32))) + (func (export "b") (result i32) + (call $task.return (i32.const 42)) + (i32.const 0 (; EXIT ;))) + (func (export "b-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $m1 (instantiate $M1 (with "" (instance + (export "task.return" (func $task.return)))))) + (func $b (export "b") async (result u32) + (canon lift (core func $m1 "b") async (callback (core func $m1 "b-cb")))) + + (component $C + (import "b" (func $b async (result u32))) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon lower (func $b) async (memory (core memory $memory "mem")) (core func $b')) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (core module $MC + (import "" "b" (func $b (param i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func (export "c") (result i32) + (local $packed i32) + (global.set $ws (call $waitable-set.new)) + (local.set $packed (call $b (i32.const 0))) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "c-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (core instance $mc (instantiate $MC (with "" (instance + (export "b" (func $b')) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)))))) + (func (export "c") async (result u32) + (canon lift (core func $mc "c") async (callback (core func $mc "c-cb")))) + ) + (instance $c (instantiate $C (with "b" (func $b)))) + (canon lower (func $c "c") async (memory (core memory $memory "mem")) (core func $c')) + + (core module $M2 + (import "" "mem" (memory 1)) + (import "" "c" (func $c (param i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "a") (result i32) + (local $packed i32) (local $ws i32) (local $sub i32) + (local.set $ws (call $waitable-set.new)) + (local.set $packed (call $c (i32.const 0))) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (local.get $sub) (local.get $ws)) + ;; "c" is waiting on "b", "b" is waiting for this task to release the + ;; exclusive lock, and a sync-ABI lift only releases it once it returns + (drop (call $waitable-set.wait (local.get $ws) (i32.const 8))) + unreachable) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "mem" (memory $memory "mem")) + (export "c" (func $c')) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "a") async (result u32) (canon lift (core func $m2 "a"))) +) +(assert_trap (invoke "a") "deadlock detected: event loop cannot make further progress") + +;; 'subtask.cancel' may be called reentrantly, while the subtask's own +;; component instance is on the stack. Here "run" starts $Callee's "park", +;; which blocks in $Callee's event loop, and then calls back into $Callee via +;; "reenter", which reenters the root instance to cancel the parked subtask. +;; "reenter" is not async-typed, so it doesn't take $Callee's exclusive lock +;; and thus doesn't keep "park" from being resumed to receive the +;; cancellation. 'subtask.cancel' therefore resumes "park"'s thread directly, +;; delivering TASK_CANCELLED so that "park" resolves before the built-in +;; returns. The cancellation thus completes eagerly, reporting +;; CANCELLED_BEFORE_RETURNED rather than BLOCKED. +(component + (canon subtask.cancel async (core func $subtask.cancel-async)) + (canon subtask.drop (core func $subtask.drop)) + + ;; Called by $Callee.reenter, and thus reached while $Callee is on the + ;; stack, to cancel the subtask of $Callee.park started by "run" below. + (core module $Canceller + (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) + (global $parked-subtask (export "parked-subtask") (mut i32) (i32.const 0)) + (func (export "cancel-parked") (result i32) + (call $subtask.cancel-async (global.get $parked-subtask))) + ) + (core instance $canceller (instantiate $Canceller (with "" (instance + (export "subtask.cancel-async" (func $subtask.cancel-async)))))) + (func $cancel-parked (result u32) (canon lift (core func $canceller "cancel-parked"))) + + (component $Callee + (import "cancel-parked" (func $cancel-parked (result u32))) + (canon lower (func $cancel-parked) (core func $cancel-parked')) + (canon task.cancel (core func $task.cancel)) + (canon waitable-set.new (core func $waitable-set.new)) + (core module $CalleeModule + (import "" "cancel-parked" (func $cancel-parked (result i32))) + (import "" "task.cancel" (func $task.cancel)) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (global $never-signalled (mut i32) (i32.const 0)) + (func $start (global.set $never-signalled (call $waitable-set.new))) + (start $start) + ;; Parks in the event loop on a waitable set that never gets an event, + ;; so only the delivery of a pending cancellation can wake this task. + (func (export "park") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) + (i32.shl (global.get $never-signalled) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ;; Not async-typed, so it doesn't take this instance's exclusive lock + ;; and "park" above stays resumable while this call is on the stack. + (func (export "reenter") (result i32) (call $cancel-parked)) + ) + (core instance $callee (instantiate $CalleeModule (with "" (instance + (export "cancel-parked" (func $cancel-parked')) + (export "task.cancel" (func $task.cancel)) + (export "waitable-set.new" (func $waitable-set.new)))))) + (func (export "park") async + (canon lift (core func $callee "park") async (callback (core func $callee "park-cb")))) + (func (export "reenter") (result u32) (canon lift (core func $callee "reenter"))) + ) + (instance $c (instantiate $Callee (with "cancel-parked" (func $cancel-parked)))) + (canon lower (func $c "park") async (core func $park')) + (canon lower (func $c "reenter") (core func $reenter')) + + (core module $Main + (import "" "park" (func $park (result i32))) + (import "" "reenter" (func $reenter (result i32))) + (import "" "parked-subtask" (global $parked-subtask (mut i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (func (export "run") (result i32) + (local $packed i32) (local $state i32) + (local.set $packed (call $park)) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (global.set $parked-subtask (i32.shr_u (local.get $packed) (i32.const 4))) + ;; reentrantly cancel the parked subtask from inside $Callee + (local.set $state (call $reenter)) + (if (i32.ne (local.get $state) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $subtask.drop (global.get $parked-subtask)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "park" (func $park')) + (export "reenter" (func $reenter')) + (export "parked-subtask" (global $canceller "parked-subtask")) + (export "subtask.drop" (func $subtask.drop)))))) + ;; async-typed (but sync-ABI-lifted) so that "run" may block + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) + +;; Reentrance between two sibling instances, where there is an outer sync +;; call and an inner async call that blocks: +;; $A.run -> $B.mid -> $A.inner (blocks) -> run yield-then-resumes inner then returns 42 +;; "inner" parks itself with a thread.suspend-then-promote that targets run's +;; thread which, at that point, is *running* (on the stack below the reentrant +;; call). A running thread is not "ready", so the promote must not switch to +;; it and instead falls back to a plain suspend. +(component + (core module $Wiring + (type $ft (func (result i32))) + (table (export "tbl") 2 2 funcref) + (func (export "to-mid") (result i32) (call_indirect (type $ft) (i32.const 0))) + (func (export "to-check") (result i32) (call_indirect (type $ft) (i32.const 1))) + ) + (core instance $wiring (instantiate $Wiring)) + (func $to-mid (result u32) (canon lift (core func $wiring "to-mid"))) + (func $to-check (result u32) (canon lift (core func $wiring "to-check"))) + + (component $A + (import "mid" (func $mid (result u32))) + (import "check" (func $check (result u32))) + (canon lower (func $mid) (core func $mid')) + (canon lower (func $check) (core func $check')) + (canon thread.index (core func $thread.index)) + (canon thread.suspend-then-promote (core func $thread.suspend-then-promote)) + (canon thread.yield-then-resume (core func $thread.yield-then-resume)) + (canon thread.suspend-then-resume (core func $thread.suspend-then-resume)) + (core module $AM + (import "" "mid" (func $mid (result i32))) + (import "" "check" (func $check (result i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.suspend-then-promote" (func $thread.suspend-then-promote (param i32) (result i32))) + (import "" "thread.yield-then-resume" (func $thread.yield-then-resume (param i32) (result i32))) + (import "" "thread.suspend-then-resume" (func $thread.suspend-then-resume (param i32) (result i32))) + (global $run-thread (mut i32) (i32.const 0)) + (global $inner-thread (mut i32) (i32.const 0)) + ;; reenters $A through $B; "mid" hands back the state of its call to + ;; "inner", which must be STARTED because "inner" blocked + (func (export "run") (result i32) + (global.set $run-thread (call $thread.index)) + (if (i32.ne (call $mid) (i32.const 1 (; STARTED ;))) + (then unreachable)) + ;; control is back here with "inner" still suspended, still holding + ;; $A's exclusive lock; switch to it, staying ready ourselves + (if (call $thread.yield-then-resume (global.get $inner-thread)) + (then unreachable)) + ;; "inner" exited and this thread was picked back up + (call $check)) + ;; async-typed, but lifted with the sync ABI: takes $A's exclusive lock + ;; and holds it until it returns + (func (export "inner") (result i32) + (global.set $inner-thread (call $thread.index)) + ;; run's thread is running (this reentrant call is on top of it), so + ;; it is not "ready" and this must fall back to a plain suspend + (if (call $thread.suspend-then-promote (global.get $run-thread)) + (then unreachable)) + (i32.const 42)) + ) + (core instance $am (instantiate $AM (with "" (instance + (export "mid" (func $mid')) + (export "check" (func $check')) + (export "thread.index" (func $thread.index)) + (export "thread.suspend-then-promote" (func $thread.suspend-then-promote)) + (export "thread.yield-then-resume" (func $thread.yield-then-resume)) + (export "thread.suspend-then-resume" (func $thread.suspend-then-resume)))))) + ;; sync-typed: doesn't take $A's exclusive lock + (func (export "run") (result u32) (canon lift (core func $am "run"))) + (func (export "inner") async (result u32) (canon lift (core func $am "inner"))) + ) + (instance $a (instantiate $A (with "mid" (func $to-mid)) (with "check" (func $to-check)))) + + (component $B + (import "inner" (func $inner async (result u32))) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon lower (func $inner) async (memory (core memory $memory "mem")) (core func $inner')) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon subtask.drop (core func $subtask.drop)) + (core module $BM + (import "" "mem" (memory 1)) + (import "" "inner" (func $inner (param i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (mut i32) (i32.const 0)) + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + ;; sync-typed export calling an async-typed import + (func (export "mid") (result i32) + (local $packed i32) + (local.set $packed (call $inner (i32.const 4))) + (global.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (call $waitable.join (global.get $sub) (global.get $ws)) + (i32.and (local.get $packed) (i32.const 0xf))) + ;; called once "inner" has exited, so the poll must report it returned + (func (export "check") (result i32) + (if (i32.ne (call $waitable-set.poll (global.get $ws) (i32.const 8)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 8)) (global.get $sub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 12)) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (call $subtask.drop (global.get $sub)) + (i32.load (i32.const 4))) + ) + (core instance $bm (instantiate $BM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "inner" (func $inner')) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "subtask.drop" (func $subtask.drop)))))) + (func (export "mid") (result u32) (canon lift (core func $bm "mid"))) + (func (export "check") (result u32) (canon lift (core func $bm "check"))) + ) + (instance $b (instantiate $B (with "inner" (func $a "inner")))) + + (canon lower (func $b "mid") (core func $mid')) + (canon lower (func $b "check") (core func $check')) + (core module $Elem + (import "" "tbl" (table 2 2 funcref)) + (import "" "mid" (func $mid (result i32))) + (import "" "check" (func $check (result i32))) + (elem (i32.const 0) func $mid $check) + ) + (core instance $elem (instantiate $Elem (with "" (instance + (export "tbl" (table $wiring "tbl")) + (export "mid" (func $mid')) + (export "check" (func $check')))))) + + (export "run" (func $a "run")) +) +(assert_return (invoke "run") (u32.const 42)) + +;; Reentrance between two sibling instances, where the outer sync task returns +;; while a reentrant call's implicit thread is sitting ready: +;; $A.run spawns an explicit thread and suspend-then-resumes it +;; -> the explicit thread calls $B.mid +;; -> $B.mid reenters $A.inner, creating a new task/implicit thread +;; -> this new implicit thread yield-then-resumes run's implicit thread, which returns +;; Returning from "run" resolves the outer task, but the call can't return to +;; the caller: the explicit thread is still on the stack underneath. Instead, +;; the sync mini-scheduler driving the reentrant "inner" call must switch back +;; to inner's implicit thread, which stayed ready. +;; Additionally, "inner" starts by thread.yield-then-promote-ing the explicit +;; thread which, at that point, is *running* (on the stack below the reentrant +;; call). A running thread is not "ready", so the promote must not switch to +;; it and instead falls back to a plain yield. +(component + (core module $Wiring + (type $ft (func (result i32))) + (table (export "tbl") 1 1 funcref) + (func (export "to-mid") (result i32) (call_indirect (type $ft) (i32.const 0))) + ) + (core instance $wiring (instantiate $Wiring)) + (func $to-mid (result u32) (canon lift (core func $wiring "to-mid"))) + + (component $A + (import "mid" (func $mid (result u32))) + (canon lower (func $mid) (core func $mid')) + (core module $Table (table (export "tbl") 1 1 funcref)) + (core instance $table (instantiate $Table)) + (core type $start-func-ty (func (param i32))) + (alias core export $table "tbl" (core table $tbl)) + (core func $thread.new-indirect + (canon thread.new-indirect $start-func-ty (core table $tbl))) + (canon thread.index (core func $thread.index)) + (canon thread.suspend-then-resume (core func $thread.suspend-then-resume)) + (canon thread.yield-then-resume (core func $thread.yield-then-resume)) + (canon thread.yield-then-promote (core func $thread.yield-then-promote)) + (core module $AM + (import "" "tbl" (table $tbl 1 1 funcref)) + (import "" "mid" (func $mid (result i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.suspend-then-resume" (func $thread.suspend-then-resume (param i32) (result i32))) + (import "" "thread.yield-then-resume" (func $thread.yield-then-resume (param i32) (result i32))) + (import "" "thread.yield-then-promote" (func $thread.yield-then-promote (param i32) (result i32))) + (global $outer-thread (mut i32) (i32.const 0)) + (global $explicit-thread (mut i32) (i32.const 0)) + (global $phase (mut i32) (i32.const 0)) + + ;; the explicit thread spawned by "run"; the call into $B only returns + ;; once the reentrant "inner" call has run all the way to completion + (func $explicit (param i32) + (if (i32.ne (global.get $phase) (i32.const 0)) (then unreachable)) + (if (i32.ne (call $mid) (i32.const 43)) (then unreachable)) + (if (i32.ne (global.get $phase) (i32.const 3)) (then unreachable))) + (elem (table $tbl) (i32.const 0) func $explicit) + + (func (export "run") (result i32) + (global.set $outer-thread (call $thread.index)) + (global.set $explicit-thread (call $thread.new-indirect (i32.const 0) (i32.const 0))) + ;; switch to a fresh explicit thread of this same task, suspending + (if (call $thread.suspend-then-resume (global.get $explicit-thread)) + (then unreachable)) + ;; resumed by "inner"'s implicit thread, which stayed ready + (if (i32.ne (global.get $phase) (i32.const 1)) (then unreachable)) + (global.set $phase (i32.const 2)) + ;; resolves this task, but the explicit thread is still on the stack + (i32.const 42)) + + ;; sync-typed, so reentering $A doesn't need the exclusive lock; runs on + ;; the implicit thread of a fresh task + (func (export "inner") (result i32) + (if (i32.ne (global.get $phase) (i32.const 0)) (then unreachable)) + ;; the explicit thread is running (this reentrant call is on top of + ;; it), so it is not "ready" and this must fall back to a plain yield + ;; without switching + (if (call $thread.yield-then-promote (global.get $explicit-thread)) + (then unreachable)) + (global.set $phase (i32.const 1)) + ;; switch to run's implicit thread, staying ready ourselves + (if (call $thread.yield-then-resume (global.get $outer-thread)) + (then unreachable)) + ;; "run" returned, and instead of returning to the caller the sync + ;; mini-scheduler picked this thread back up + (if (i32.ne (global.get $phase) (i32.const 2)) (then unreachable)) + (global.set $phase (i32.const 3)) + (i32.const 43)) + ) + (core instance $am (instantiate $AM (with "" (instance + (export "tbl" (table $tbl)) + (export "mid" (func $mid')) + (export "thread.index" (func $thread.index)) + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.suspend-then-resume" (func $thread.suspend-then-resume)) + (export "thread.yield-then-resume" (func $thread.yield-then-resume)) + (export "thread.yield-then-promote" (func $thread.yield-then-promote)))))) + (func (export "run") (result u32) (canon lift (core func $am "run"))) + (func (export "inner") (result u32) (canon lift (core func $am "inner"))) + ) + (instance $a (instantiate $A (with "mid" (func $to-mid)))) + + (component $B + (import "inner" (func $inner (result u32))) + (canon lower (func $inner) (core func $inner')) + (core module $BM + (import "" "inner" (func $inner (result i32))) + ;; sync-typed export making a sync call back into $A + (func (export "mid") (result i32) (call $inner)) + ) + (core instance $bm (instantiate $BM (with "" (instance + (export "inner" (func $inner')))))) + (func (export "mid") (result u32) (canon lift (core func $bm "mid"))) + ) + (instance $b (instantiate $B (with "inner" (func $a "inner")))) + + (canon lower (func $b "mid") (core func $mid')) + (core module $Elem + (import "" "tbl" (table 1 1 funcref)) + (import "" "mid" (func $mid (result i32))) + (elem (i32.const 0) func $mid) + ) + (core instance $elem (instantiate $Elem (with "" (instance + (export "tbl" (table $wiring "tbl")) + (export "mid" (func $mid')))))) + + (export "run" (func $a "run")) +) +(assert_return (invoke "run") (u32.const 42)) + +;; While 'subtask.cancel' is claiming the subtask being cancelled, a reentrant +;; call into the cancelling instance cannot 'waitable.join' that subtask. +(component + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable.join (core func $waitable.join)) + (canon subtask.cancel (core func $subtask.cancel)) + (core module $M1 + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (global $ws (mut i32) (i32.const 0)) + (global $sub (export "sub") (mut i32) (i32.const 0)) + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + ;; called by the cancelled subtask while "run" is inside 'subtask.cancel' + (func (export "back") + (call $waitable.join (global.get $sub) (global.get $ws)) + unreachable) + ) + (core instance $m1 (instantiate $M1 (with "" (instance + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable.join" (func $waitable.join)))))) + (func $back (canon lift (core func $m1 "back"))) + + (component $C + (import "back" (func $back)) + (canon lower (func $back) (core func $back')) + (canon task.cancel (core func $task.cancel)) + (canon waitable-set.new (core func $waitable-set.new)) + (core module $CM + (import "" "back" (func $back)) + (import "" "task.cancel" (func $task.cancel)) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (global $ws (mut i32) (i32.const 0)) + (func $start (global.set $ws (call $waitable-set.new))) + (start $start) + ;; parks in its event loop on a set that never gets an event, so only the + ;; delivery of a pending cancellation can wake this task + (func (export "f") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $ws) (i32.const 4)))) + (func (export "f-cb") (param i32 i32 i32) (result i32) + (if (i32.ne (local.get 0) (i32.const 6 (; TASK_CANCELLED ;))) (then unreachable)) + (call $task.cancel) + ;; reenter the parent, which is still inside 'subtask.cancel' + (call $back) + unreachable) + ) + (core instance $cm (instantiate $CM (with "" (instance + (export "back" (func $back')) + (export "task.cancel" (func $task.cancel)) + (export "waitable-set.new" (func $waitable-set.new)))))) + (func (export "f") async + (canon lift (core func $cm "f") async (callback (core func $cm "f-cb")))) + ) + (instance $c (instantiate $C (with "back" (func $back)))) + (canon lower (func $c "f") async (core func $f')) + + (core module $M2 + (import "" "f" (func $f (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "sub" (global $sub (mut i32))) + (func (export "run") (result i32) + (local $p i32) + (local.set $p (call $f)) + (if (i32.ne (i32.and (local.get $p) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (global.set $sub (i32.shr_u (local.get $p) (i32.const 4))) + ;; the cancelled subtask reenters this instance and traps in "back" + (drop (call $subtask.cancel (global.get $sub))) + unreachable) + ) + (core instance $m2 (instantiate $M2 (with "" (instance + (export "f" (func $f')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "sub" (global $m1 "sub")))))) + (func (export "run") (result u32) (canon lift (core func $m2 "run"))) +) +(assert_trap (invoke "run") "waitable cannot be used synchronously while added to a waitable set") diff --git a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json index b49f1ecd5..061f8b6e2 100644 --- a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json +++ b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json @@ -9,19 +9,31 @@ }, { "name": "big-interleaving-test.wast", - "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" + "revision": "ea917982dc142251157dfbb15fdf834ff509e604" }, { "name": "builtin-trap-poisons-instance.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" }, + { + "name": "cancel-delivery.wast", + "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + }, + { + "name": "cancel-instance-wide-resume.wast", + "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + }, { "name": "cancel-stream.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" }, { "name": "cancel-subtask.wast", - "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" + "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + }, + { + "name": "cancel-yield-loop.wast", + "revision": "ea917982dc142251157dfbb15fdf834ff509e604" }, { "name": "closed-stream.wast", @@ -83,6 +95,10 @@ "name": "passing-resources.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" }, + { + "name": "reentrance.wast", + "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + }, { "name": "same-component-stream-future.wast", "revision": "7c676115e93cd7d54c1732d95c54c6a3de7c5ae0" diff --git a/packages/jco-transpile/test/p3/ported/component-model/wast.ts b/packages/jco-transpile/test/p3/ported/component-model/wast.ts index 144bf251f..7c935ba6a 100644 --- a/packages/jco-transpile/test/p3/ported/component-model/wast.ts +++ b/packages/jco-transpile/test/p3/ported/component-model/wast.ts @@ -12,6 +12,11 @@ import { fileExists, getTmpDir, readComponentBytes, setupAsyncTest } from '../.. interface WastTest { relPath: string; skip?: boolean; + artifactCase?: { + index: number; + exportName: string; + expected: unknown; + }; } interface WastTestModule { @@ -34,6 +39,8 @@ interface WastTestArtifact { const WAST_TESTS: readonly WastTest[] = [ // Running tests { relPath: 'async/wait-during-callback.wast' }, + { relPath: 'async/cancel-delivery.wast' }, + { relPath: 'async/cancel-instance-wide-resume.wast' }, { relPath: 'async/cancel-stream.wast' }, { relPath: 'async/partial-stream-copies.wast' }, { relPath: 'async/futures-must-write.wast' }, @@ -42,7 +49,14 @@ const WAST_TESTS: readonly WastTest[] = [ { relPath: 'async/empty-wait.wast' }, { relPath: 'async/zero-length.wast' }, { relPath: 'async/cancel-subtask.wast' }, + { relPath: 'async/cancel-yield-loop.wast' }, { relPath: 'async/passing-resources.wast' }, + // Only artifact 8 was added by component-model#723. Later artifacts use + // thread.* built-ins, which jco does not support yet. + { + relPath: 'async/reentrance.wast', + artifactCase: { index: 8, exportName: 'run', expected: 42 }, + }, { relPath: 'async/drop-waitable-set.wast' }, { relPath: 'async/drop-subtask.wast' }, { relPath: 'async/async-calls-sync.wast' }, @@ -100,7 +114,7 @@ suite('component-model WAST', () => { const wastPath = join(COMPONENT_MODEL_FIXTURES_WAST_DIR, relPath); await buildWastFixture(wastPath); } - }); + }, 600_000); test.each([false, true])('deadlock diagnostics preserve the trap (minify=%s)', async (minify) => { const { instance, cleanup } = await setupAsyncTest({ @@ -165,7 +179,7 @@ suite('component-model WAST', () => { } }); - for (const { relPath, skip } of WAST_TESTS) { + for (const { relPath, skip, artifactCase } of WAST_TESTS) { const wastPath = join(COMPONENT_MODEL_FIXTURES_WAST_DIR, relPath); const wasmPath = wastPath.replace(/\.wast$/, '.wast.wasm'); const scriptPath = `${wastPath}.js`; @@ -176,10 +190,28 @@ suite('component-model WAST', () => { const cleanups: (() => Promise)[] = []; try { + const componentName = basename(relPath).replace('.wast', ''); + if (artifactCase) { + const artifactPath = wastPath.replace(/\.wast$/, `.wast.${artifactCase.index}.wasm`); + assert(await fileExists(artifactPath), `missing generated WAT artifact @ [${artifactPath}]`); + const outputDir = await getTmpDir(); + const setup = await setupAsyncTest({ + asyncMode: 'jspi', + component: { + name: `${componentName}-artifact-${artifactCase.index}`, + path: artifactPath, + outputDir, + }, + }); + cleanups.push(setup.cleanup); + const artifactInstance = setup.instance as Record Promise>; + assert.deepEqual(await artifactInstance[artifactCase.exportName](), artifactCase.expected); + return; + } + const mod = (await import(pathToFileURL(scriptPath).href)) as WastTestModule; const artifacts = mod.wastTestArtifacts ?? []; const requiresInstance = mod.wastTestRequiresInstance ?? true; - const componentName = basename(relPath).replace('.wast', ''); const artifactInstantiators = new Map Promise>>(); let instance; From f23e410619cc9c1752da67e14c31f06f305ae5a8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 18 Sep 2026 01:11:37 +0000 Subject: [PATCH 05/11] fix(bindgen): target callback cancellation delivery --- .../src/intrinsics/component.rs | 18 --------- .../src/intrinsics/mod.rs | 21 ++++++----- .../src/intrinsics/p3/async_task.rs | 37 +++++++++---------- .../src/intrinsics/p3/waitable.rs | 16 +++----- 4 files changed, 34 insertions(+), 58 deletions(-) diff --git a/crates/js-component-bindgen/src/intrinsics/component.rs b/crates/js-component-bindgen/src/intrinsics/component.rs index 914ae68e5..fa60a7441 100644 --- a/crates/js-component-bindgen/src/intrinsics/component.rs +++ b/crates/js-component-bindgen/src/intrinsics/component.rs @@ -792,24 +792,6 @@ impl ComponentIntrinsic { return this.#suspendedTasksByTaskID.values(); }} - // Resume one ready thread in this component instance and return - // a promise which settles when that execution slice either exits - // or suspends again. `subtask.cancel` uses this to implement the - // nondeterministic callee-instance scheduling step required by - // Task.request_cancellation. - resumeOneReadyTask() {{ - for (const taskID of this.#suspendedTaskIDs) {{ - if (taskID === null || !this.suspendedTaskReady(taskID)) {{ continue; }} - const meta = this.#getSuspendedTaskMeta(taskID); - const progress = meta.task.waitForProgress(); - if (!this.resumeTaskByID(taskID)) {{ - throw new Error(`failed to resume ready task [${{taskID}}]`); - }} - return {{ task: meta.task, progress }}; - }} - return null; - }} - addPendingTaskStart() {{ this.#pendingTaskStarts++; }} removePendingTaskStart() {{ this.#pendingTaskStarts--; }} diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 5d1753fea..02873a77a 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1740,18 +1740,20 @@ mod tests { } #[test] - fn subtask_cancel_drives_one_ready_callee_instance_slice() { + fn subtask_cancel_drives_only_the_target_callback_slice() { let cancel = render_intrinsic_body(Intrinsic::AsyncTask(AsyncTaskIntrinsic::SubtaskCancel)); - assert!(cancel.contains("childState.resumeOneReadyTask()")); + assert!(cancel.contains("childTask?.hasCallback()")); + assert!(cancel.contains("childState.suspendedTaskCancellable(childTask.id())")); + assert!(cancel.contains("childState.suspendedTaskReady(childTask.id())")); + assert!(cancel.contains("childState.resumeTaskByID(childTask.id())")); assert!(cancel.contains("function subtaskCancel")); assert!(!cancel.contains("async function subtaskCancel")); assert!(cancel.contains(".then(finishCancel)")); assert!(cancel.contains("cancellationWillCompleteAsync ? 0xFFFFFFFE : 0xFFFFFFFF")); assert!(cancel.contains("if (!slowOnly) { return 0xFFFFFFFE; }")); - assert!(cancel.contains("childState.exclusivelyLockedBy(childTask.id())")); - assert!(cancel.contains("!childState.isTaskSuspended(childTask.id())")); assert!(cancel.contains("return progress.then(() =>")); assert!(!cancel.contains("childTask.cancel()")); + assert!(!cancel.contains("resumeOneReadyTask()")); let task = render_intrinsic_body(Intrinsic::AsyncTask(AsyncTaskIntrinsic::AsyncTaskClass)); assert!(task.contains("suspendUntilCallback(opts, onResume)")); @@ -1775,8 +1777,7 @@ mod tests { assert!(state.contains("suspendedTaskCancellable(taskID)")); assert!(state.contains("task.notifyProgress();")); assert!(state.contains("suspendedTaskReady(taskID)")); - assert!(state.contains("resumeOneReadyTask()")); - assert!(state.contains("const progress = meta.task.waitForProgress();")); + assert!(!state.contains("resumeOneReadyTask()")); } #[test] @@ -1788,7 +1789,7 @@ mod tests { } #[test] - fn cancellable_wait_poll_and_yield_reach_task_state() { + fn only_callback_event_loop_waits_deliver_cancellation() { let waitable_set = render_intrinsic_body(Intrinsic::Waitable(WaitableIntrinsic::WaitableSetClass)); assert!(waitable_set.contains("tryWait(opts)")); @@ -1799,7 +1800,7 @@ mod tests { let wait = render_intrinsic_body(Intrinsic::Waitable(WaitableIntrinsic::WaitableSetWait)); assert!(wait.contains("const wset = cstate.handles.get(waitableSetRep);")); assert!(!wait.contains("await cstate.handles.get(waitableSetRep);")); - assert!(wait.contains("cancellable: isCancellable")); + assert!(wait.contains("cancellable: false")); assert!(wait.contains("function waitableSetWait")); assert!(!wait.contains("async function waitableSetWait")); assert!(wait.contains("syncOnly ? wset.tryWait(waitOpts) : wset.waitUntil(waitOpts)")); @@ -1814,11 +1815,11 @@ mod tests { assert!(conditional.contains("0x41, 0x7e")); let poll = render_intrinsic_body(Intrinsic::Waitable(WaitableIntrinsic::WaitableSetPoll)); - assert!(poll.contains("deliverPendingCancel({ cancellable: isCancellable })")); + assert!(!poll.contains("deliverPendingCancel")); let yield_ = render_intrinsic_body(Intrinsic::AsyncTask(AsyncTaskIntrinsic::Yield)); assert!(yield_.contains("const keepGoing = await task.immediateSuspend({")); - assert!(yield_.contains("cancellable: isCancellable")); + assert!(yield_.contains("cancellable: false")); assert!(yield_.contains("return keepGoing ? 0 : 1;")); } diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs index 2b116d74e..0d98a77ea 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs @@ -616,7 +616,7 @@ impl AsyncTaskIntrinsic { " async function {yield_fn}(ctx) {{ {debug_log_fn}('[{yield_fn}()] args', {{ ctx }}); - const {{ componentIdx, isCancellable }} = ctx; + const {{ componentIdx }} = ctx; const {{ taskID }} = {get_global_current_task_meta_fn}(componentIdx); const taskMeta = {current_task_get_fn}(componentIdx, taskID); @@ -630,7 +630,9 @@ impl AsyncTaskIntrinsic { // ordinary ready wait, it cannot take the immediate-completion // shortcut. const keepGoing = await task.immediateSuspend({{ - cancellable: isCancellable, + // Cancellation is delivered only while an implicit + // callback thread is parked in its event loop. + cancellable: false, readyFn: () => true, }}); return keepGoing ? 0 : 1; @@ -721,24 +723,20 @@ impl AsyncTaskIntrinsic { subtask.requestCancellation(); if (!subtask.isResolved()) {{ - // Cancellation may resume any ready thread in the callee's - // component instance, not only the cancelled task's implicit - // thread. Drive at most one slice: the host is allowed to stop - // after any resume, and doing so also bounds a ready yield loop. - if (childTask) {{ + // Cancellation may resume only the cancelled task's + // implicit callback thread, and only while that thread is + // parked cancellably in its event loop. No other ready + // thread in the component instance may run here. + if (childTask?.hasCallback()) {{ const childState = {get_or_create_async_state_fn}(childTask.componentIdx()); - const resumed = childState.resumeOneReadyTask(); - if (resumed) {{ - cancellationWillCompleteAsync = true; - subtask.cancelProgress = resumed.progress; - }} else if (childTask.hasCallback() && - childState.exclusivelyLockedBy(childTask.id()) && - !childState.isTaskSuspended(childTask.id())) {{ - // JSPI can still be returning the initial guest - // slice, before its callback wait is registered. - // An existing non-cancellable wait must instead - // report BLOCKED without waiting for progress. + if (childState.suspendedTaskCancellable(childTask.id()) && + childState.suspendedTaskReady(childTask.id())) {{ + const progress = childTask.waitForProgress(); + if (!childState.resumeTaskByID(childTask.id())) {{ + throw new Error('failed to resume cancelled callback task'); + }} cancellationWillCompleteAsync = true; + subtask.cancelProgress = progress; }} }} }} @@ -1689,7 +1687,7 @@ impl AsyncTaskIntrinsic { cancel(args) {{ {debug_log_fn}('[{task_class}#cancel()] args', {{ }}); if (this.taskState() !== {task_class}.State.CANCEL_DELIVERED) {{ - throw new Error(`(component [${{this.#componentIdx}}]) task [${{this.#id}}] invalid task state [${{this.taskState()}}] for cancellation`); + throw new Error('`task.cancel` called by task which has not been cancelled'); }} this.validateResourceBorrowScope(); this.cancelled = true; @@ -1831,6 +1829,7 @@ impl AsyncTaskIntrinsic { }}); if (this.#state === {task_class}.State.RESOLVED) {{ + if (this.#errored !== null) {{ throw this.#errored; }} throw new Error(`(component [${{this.#componentIdx}}]) task [${{this.#id}}] is already resolved (did you forget to wait for an import?)`); }} diff --git a/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs b/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs index b62dcc19e..0947050c8 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/waitable.rs @@ -488,7 +488,6 @@ impl WaitableIntrinsic { const {{ componentIdx, isAsync, - isCancellable, memoryIdx, getMemoryFn, }} = ctx; @@ -519,7 +518,9 @@ impl WaitableIntrinsic { task, memoryIdx, }}); - const waitOpts = {{ readyFn: () => true, task, cancellable: isCancellable }}; + // Built-in waits never deliver cancellation. Only the + // callback event loop's wait is cancellable. + const waitOpts = {{ readyFn: () => true, task, cancellable: false }}; const event = syncOnly ? wset.tryWait(waitOpts) : wset.waitUntil(waitOpts); if (event === null) {{ return -1; }} if (event && typeof event.then === 'function') {{ @@ -544,7 +545,7 @@ impl WaitableIntrinsic { render_args.require_intrinsic(Intrinsic::AsyncEventCodeEnum); output.push_str(&format!(r#" function {waitable_set_poll_fn}(ctx, waitableSetRep, resultPtr) {{ - const {{ componentIdx, memoryIdx, getMemoryFn, isAsync, isCancellable }} = ctx; + const {{ componentIdx, memoryIdx, getMemoryFn, isAsync }} = ctx; {debug_log_fn}('[{waitable_set_poll_fn}()] args', {{ componentIdx, memoryIdx, @@ -572,14 +573,7 @@ impl WaitableIntrinsic { }} let event; - const cancelDelivered = task.deliverPendingCancel({{ cancellable: isCancellable }}); - if (cancelDelivered) {{ - {debug_log_fn}('[{waitable_set_poll_fn}()] detected cancel delivered', {{ - componentIdx, - waitableSetRep, - }}); - event = {{ code: {async_event_code_enum}.TASK_CANCELLED, payload0: 0, payload1: 0 }}; - }} else if (!wset.hasPendingEvent()) {{ + if (!wset.hasPendingEvent()) {{ {debug_log_fn}('[{waitable_set_poll_fn}()] no pending event', {{ componentIdx, waitableSetRep, From e7ea63f4ff4e8e28c6fba4a3d2fe927f1fd2e771 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 18 Sep 2026 01:11:44 +0000 Subject: [PATCH 06/11] test(transpile): sync targeted cancellation WASTs --- .../async/cancel-delivery.wast | 174 ++++- .../async/cancel-instance-wide-resume.wast | 185 ----- .../async/cancel-not-delivered.wast | 101 +++ .../async/cancel-resumed-callback-switch.wast | 331 +++++++++ .../async/cancel-targeted-resume.wast | 699 ++++++++++++++++++ .../async/cancel-yield-loop.wast | 135 ---- .../test/fixtures/wast/upstream-manifest.json | 14 +- .../test/p3/ported/component-model/wast.ts | 49 +- 8 files changed, 1312 insertions(+), 376 deletions(-) delete mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-not-delivered.wast create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-resumed-callback-switch.wast create mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-targeted-resume.wast delete mode 100644 packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast index dcd97aea4..4b3ab56c0 100644 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-delivery.wast @@ -1,26 +1,6 @@ ;; This test exercises how a request for cancellation is delivered to the ;; callee: the request is recorded as pending on the callee's task and is only -;; delivered, as a TASK_CANCELLED event, via the `async callback` event loop. -;; -;; Component $C exports three async callback-lifted functions: -;; yield-until-cancel: loops returning YIELD to its event loop until the -;; TASK_CANCELLED event arrives; one more YIELD must then report NONE -;; (cancellation is delivered at most once) before it calls task.cancel -;; deferred: blocks in a synchronous future.read in its initial core -;; function, during which a cancellation request stays pending; the pending -;; request is delivered at the first return to the event loop -;; pending-survives: like deferred, but additionally calls thread.yield and -;; waitable-set.poll after the request is pending: neither may report the -;; pending cancellation (they never return TASK_CANCELLED), which must -;; survive until the task returns to its event loop -;; -;; Component $D calls each function and cancels it. `subtask.cancel async` -;; resumes the cancelled task directly, but the host is also allowed (not -;; required) to keep resuming ready threads, so yield-until-cancel may or may -;; not get far enough to resolve before the built-in returns and $D accepts -;; both outcomes. While a task is blocked in its initial core function it -;; cannot be cancelled at all, so those cancellations deterministically report -;; BLOCKED. +;; delivered via the `async callback` event loop. (component (component $C (core module $Memory (memory (export "mem") 1)) @@ -32,6 +12,8 @@ (import "" "waitable-set.new" (func $waitable-set.new (result i32))) (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) (import "" "thread.yield" (func $thread.yield (result i32))) + (import "" "future.read-async" (func $future.read-async (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) ;; Test 1: cancellation is delivered to a task parked in its event loop ;; after returning YIELD, and is delivered at most once @@ -92,6 +74,41 @@ ;; only the event loop delivers the still-pending cancellation (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (local.get $ws) (i32.const 4))) ) + + ;; Test 5: a callee that still needs an external event after receiving + ;; TASK_CANCELLED cannot resolve during the cancellation, so the request + ;; is reported BLOCKED even though it has already been delivered + (global $gated-delivered (mut i32) (i32.const 0)) + (global $gated-ws (mut i32) (i32.const 0)) + (func $gated-delivered (export "gated-delivered") (result i32) + (global.get $gated-delivered)) + (func $gated (export "gated") (param $futr i32) (result i32) + ;; arm a read that only the caller can complete, then park in the + ;; event loop + (if (i32.ne (call $future.read-async (local.get $futr) (i32.const 0)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (global.set $gated-ws (call $waitable-set.new)) + (call $waitable.join (local.get $futr) (global.get $gated-ws)) + (i32.const 1 (; YIELD ;)) + ) + (func $gated_cb (export "gated_cb") (param $event_code i32) (param $index i32) (param $payload i32) (result i32) + (if (i32.eq (local.get $event_code) (i32.const 6 (; TASK_CANCELLED ;))) + (then + (if (global.get $gated-delivered) (then unreachable)) + (global.set $gated-delivered (i32.const 1)) + ;; the read is still outstanding, so this task cannot resolve yet + (return (i32.const 1 (; YIELD ;))))) + (if (i32.ne (local.get $event_code) (i32.const 0 (; NONE ;))) + (then unreachable)) + (if (i32.eq (i32.const 0 (; NONE ;)) + (call $waitable-set.poll (global.get $gated-ws) (i32.const 8))) + (then (return (i32.const 1 (; YIELD ;))))) + ;; the read completed; the cancellation must already have arrived + (if (i32.eqz (global.get $gated-delivered)) (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;)) + ) ) (type $FT (future)) (canon task.cancel (core func $task.cancel)) @@ -99,6 +116,8 @@ (canon waitable-set.new (core func $waitable-set.new)) (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) (canon thread.yield (core func $thread.yield)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read-async)) + (canon waitable.join (core func $waitable.join)) (core instance $cm (instantiate $CM (with "" (instance (export "mem" (memory $memory "mem")) (export "task.cancel" (func $task.cancel)) @@ -106,6 +125,8 @@ (export "waitable-set.new" (func $waitable-set.new)) (export "waitable-set.poll" (func $waitable-set.poll)) (export "thread.yield" (func $thread.yield)) + (export "future.read-async" (func $future.read-async)) + (export "waitable.join" (func $waitable.join)) )))) (func (export "yield-until-cancel") async (canon lift (core func $cm "yield-until-cancel") @@ -119,6 +140,11 @@ (core func $cm "pending-survives") async (callback (core func $cm "expect-cancelled_cb")) )) + (func (export "gated") async (param "fut" $FT) (canon lift + (core func $cm "gated") + async (callback (core func $cm "gated_cb")) + )) + (func (export "gated-delivered") (result u32) (canon lift (core func $cm "gated-delivered"))) ) (component $D @@ -126,6 +152,8 @@ (import "yield-until-cancel" (func $yield-until-cancel async)) (import "deferred" (func $deferred async (param "fut" $FT))) (import "pending-survives" (func $pending-survives async (param "fut" $FT))) + (import "gated" (func $gated async (param "fut" $FT))) + (import "gated-delivered" (func $gated-delivered (result u32))) (core module $Memory (memory (export "mem") 1)) (core instance $memory (instantiate $Memory)) @@ -141,6 +169,8 @@ (import "" "yield-until-cancel" (func $yield-until-cancel (result i32))) (import "" "deferred" (func $deferred (param i32) (result i32))) (import "" "pending-survives" (func $pending-survives (param i32) (result i32))) + (import "" "gated" (func $gated (param i32) (result i32))) + (import "" "gated-delivered" (func $gated-delivered (result i32))) ;; wait until $sub resolves to CANCELLED_BEFORE_RETURNED, then drop it (func $await-cancelled (param $sub i32) @@ -239,6 +269,65 @@ ;; cancellation, which is delivered at the callee's event loop (call $await-cancelled (local.get $subtask)) + ;; ========================================== + ;; Test 4: a thread that has not returned to its event loop stays + ;; uncancellable even once it is ready to run again + ;; ========================================== + + ;; create future for deferred to read + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; call deferred; it blocks in a synchronous future.read + (local.set $ret (call $deferred (local.get $futr))) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) + + ;; write first, so that the callee's thread is ready before the cancel + (if (i32.ne (call $future.write (local.get $futw) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + + ;; still BLOCKED: a ready thread is only resumed while parked in its + ;; event loop, and deferred would resolve immediately if it were + ;; resumed here + (if (i32.ne (call $subtask.cancel (local.get $subtask)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; blocking lets the callee reach its event loop and resolve + (call $await-cancelled (local.get $subtask)) + + ;; ========================================== + ;; Test 5: a delivered cancellation still reports BLOCKED when the + ;; callee needs an external event before it can resolve + ;; ========================================== + + ;; create future for gated to read + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; call gated; it parks in its event loop with the read outstanding + (local.set $ret (call $gated (local.get $futr))) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) + + ;; unlike test 1, this callee cannot resolve on its own, so the outcome + ;; is deterministic + (if (i32.ne (call $subtask.cancel (local.get $subtask)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; ...yet the request was already delivered before BLOCKED was reported + (if (i32.ne (call $gated-delivered) (i32.const 1)) + (then unreachable)) + + ;; writing the future now lets it resolve + (if (i32.ne (call $future.write (local.get $futw) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + (call $await-cancelled (local.get $subtask)) + ;; all tests passed (i32.const 42) ) @@ -253,6 +342,8 @@ (canon lower (func $yield-until-cancel) async (memory (core memory $memory "mem")) (core func $yield-until-cancel')) (canon lower (func $deferred) async (memory (core memory $memory "mem")) (core func $deferred')) (canon lower (func $pending-survives) async (memory (core memory $memory "mem")) (core func $pending-survives')) + (canon lower (func $gated) async (memory (core memory $memory "mem")) (core func $gated')) + (canon lower (func $gated-delivered) (core func $gated-delivered')) (core instance $dm (instantiate $DM (with "" (instance (export "mem" (memory $memory "mem")) (export "subtask.cancel" (func $subtask.cancel)) @@ -265,6 +356,8 @@ (export "yield-until-cancel" (func $yield-until-cancel')) (export "deferred" (func $deferred')) (export "pending-survives" (func $pending-survives')) + (export "gated" (func $gated')) + (export "gated-delivered" (func $gated-delivered')) )))) (func (export "run") async (result u32) (canon lift (core func $dm "run"))) ) @@ -274,14 +367,18 @@ (with "yield-until-cancel" (func $c "yield-until-cancel")) (with "deferred" (func $c "deferred")) (with "pending-survives" (func $c "pending-survives")) + (with "gated" (func $c "gated")) + (with "gated-delivered" (func $c "gated-delivered")) )) (func (export "run") (alias export $d "run")) ) (assert_return (invoke "run") (u32.const 42)) -;; subtask.cancel only resumes ready threads. A stackful (non-callback) task -;; parked in 'waitable-set.wait' that contains no ready waitable is not ready -;; and cannot return TASK_CANCELLED. +;; subtask.cancel only resumes a thread that is both ready and cancellable, and +;; only a `callback` thread parked in its event loop is ever cancellable. A +;; stackful (non-callback) task therefore can never be cancelled: neither while +;; parked in 'waitable-set.wait' with no ready waitable (not ready), nor once +;; the caller has completed its read (ready, but still not cancellable). (component (component $C (core module $Memory (memory (export "mem") 1)) @@ -351,9 +448,10 @@ (import "" "waitable.join" (func $waitable.join (param i32 i32))) (import "" "waitable-set.new" (func $waitable-set.new (result i32))) (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) - (func (export "run") (result i32) + (func $round (param $write-first i32) (local $ret64 i64) (local $futr i32) (local $futw i32) (local $packed i32) (local $sub i32) (local $ws i32) + (i32.store (i32.const 24) (i32.const 0xbad0bad0)) (local.set $ret64 (call $future.new)) (local.set $futr (i32.wrap_i64 (local.get $ret64))) (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) @@ -362,14 +460,26 @@ (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) (then unreachable)) (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) - ;; no ready thread to resume, so the request is only recorded + ;; optionally complete the read first, which leaves the stackful task's + ;; thread ready to run + (if (local.get $write-first) + (then + (if (i32.ne (call $future.write (local.get $futw) (i32.const 16)) + (i32.const 0 (; COMPLETED ;))) + (then unreachable)))) + + ;; a stackful thread is never cancellable, ready or not, so the request + ;; is only recorded (if (i32.ne (call $subtask.cancel-async (local.get $sub)) (i32.const -1 (; BLOCKED ;))) (then unreachable)) + ;; unblock the stackful task; it never sees the cancellation - (if (i32.ne (call $future.write (local.get $futw) (i32.const 16)) - (i32.const 0 (; COMPLETED ;))) - (then unreachable)) + (if (i32.eqz (local.get $write-first)) + (then + (if (i32.ne (call $future.write (local.get $futw) (i32.const 16)) + (i32.const 0 (; COMPLETED ;))) + (then unreachable)))) (local.set $ws (call $waitable-set.new)) (call $waitable.join (local.get $sub) (local.get $ws)) (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) @@ -382,7 +492,11 @@ (if (i32.ne (i32.load (i32.const 24)) (i32.const 42)) (then unreachable)) (call $waitable.join (local.get $sub) (i32.const 0)) - (call $subtask.drop (local.get $sub)) + (call $subtask.drop (local.get $sub))) + + (func (export "run") (result i32) + (call $round (i32.const 0 (; not ready when cancelled ;))) + (call $round (i32.const 1 (; ready when cancelled ;))) (i32.const 42)) ) (core instance $main (instantiate $Main (with "" (instance diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast deleted file mode 100644 index 7fe242a15..000000000 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-instance-wide-resume.wast +++ /dev/null @@ -1,185 +0,0 @@ -;; The resume performed by 'subtask.cancel' may pick any ready thread of the -;; callee's instance, not just a thread of the task being cancelled. This test -;; forces that distinction by arranging for the only ready thread in $C to -;; belong to a different task than the one being cancelled. -;; -;; At least one resume always happens, so hold-lock always runs to completion -;; here, which the caller observes by polling. Whether the host then keeps -;; resuming threads is nondeterministic: it may stop there, in which case the -;; cancel reports BLOCKED and park is woken later once the caller blocks, or it -;; may go on to resume park (now that hold-lock has released the lock) and -;; report CANCELLED_BEFORE_RETURNED eagerly. -(component - (component $C - (core module $Memory (memory (export "mem") 1)) - (core instance $memory (instantiate $Memory)) - (core module $CM - (import "" "mem" (memory 1)) - (import "" "task.return" (func $task.return (param i32))) - (import "" "task.cancel" (func $task.cancel)) - (import "" "future.read" (func $future.read (param i32 i32) (result i32))) - (import "" "waitable.join" (func $waitable.join (param i32 i32))) - (import "" "waitable-set.new" (func $waitable-set.new (result i32))) - (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) - - ;; Parks in the event loop on a waitable set that never gets an event, so - ;; only the delivery of a pending cancellation can wake this task which - ;; cannot happen while the exclusive lock is held. - (global $never (mut i32) (i32.const 0)) - (func $start (global.set $never (call $waitable-set.new))) - (start $start) - (func (export "park") (result i32) - (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $never) (i32.const 4)))) - (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) - (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) - (then unreachable)) - (call $task.cancel) - (i32.const 0 (; EXIT ;))) - - ;; Blocks holding the instance's exclusive lock for as long as it is - ;; blocked. Once the caller writes the future, this task's thread is ready - ;; but the lock is still held. - (func (export "hold-lock") (param $futr i32) (result i32) - (local $ws i32) - (if (i32.ne (i32.const -1 (; BLOCKED ;)) - (call $future.read (local.get $futr) (i32.const 0))) - (then unreachable)) - (local.set $ws (call $waitable-set.new)) - (call $waitable.join (local.get $futr) (local.get $ws)) - (if (i32.ne (i32.const 4 (; FUTURE_READ ;)) - (call $waitable-set.wait (local.get $ws) (i32.const 8))) - (then unreachable)) - (call $task.return (i32.const 43)) - (i32.const 0 (; EXIT ;))) - (func (export "unreachable-cb") (param i32 i32 i32) (result i32) - unreachable) - ) - (type $FT (future)) - (canon task.return (result u32) (core func $task.return)) - (canon task.cancel (core func $task.cancel)) - (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) - (canon waitable.join (core func $waitable.join)) - (canon waitable-set.new (core func $waitable-set.new)) - (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) - (core instance $cm (instantiate $CM (with "" (instance - (export "mem" (memory $memory "mem")) - (export "task.return" (func $task.return)) - (export "task.cancel" (func $task.cancel)) - (export "future.read" (func $future.read)) - (export "waitable.join" (func $waitable.join)) - (export "waitable-set.new" (func $waitable-set.new)) - (export "waitable-set.wait" (func $waitable-set.wait)))))) - (func (export "park") async - (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) - (func (export "hold-lock") async (param "fut" $FT) (result u32) - (canon lift (core func $cm "hold-lock") async (callback (core func $cm "unreachable-cb")))) - ) - (instance $c (instantiate $C)) - (core module $Memory (memory (export "mem") 1)) - (core instance $memory (instantiate $Memory)) - (type $FT (future)) - (canon future.new $FT (core func $future.new)) - (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) - (canon lower (func $c "park") async (core func $park')) - (canon lower (func $c "hold-lock") async (memory (core memory $memory "mem")) (core func $hold-lock')) - (canon subtask.cancel async (core func $subtask.cancel-async)) - (canon subtask.drop (core func $subtask.drop)) - (canon waitable.join (core func $waitable.join)) - (canon waitable-set.new (core func $waitable-set.new)) - (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) - (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) - - (core module $Main - (import "" "mem" (memory 1)) - (import "" "future.new" (func $future.new (result i64))) - (import "" "future.write" (func $future.write (param i32 i32) (result i32))) - (import "" "park" (func $park (result i32))) - (import "" "hold-lock" (func $hold-lock (param i32 i32) (result i32))) - (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) - (import "" "subtask.drop" (func $subtask.drop (param i32))) - (import "" "waitable.join" (func $waitable.join (param i32 i32))) - (import "" "waitable-set.new" (func $waitable-set.new (result i32))) - (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) - (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) - (func (export "run") (result i32) - (local $ret64 i64) (local $futr i32) (local $futw i32) - (local $packed i32) (local $ret i32) - (local $park-sub i32) (local $hold-sub i32) - (local $park-ws i32) (local $hold-ws i32) - - ;; start "park"; returning WAIT releases the lock, so it is free here - (local.set $packed (call $park)) - (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) - (then unreachable)) - (local.set $park-sub (i32.shr_u (local.get $packed) (i32.const 4))) - - ;; start hold-lock; it takes the lock and blocks while holding it - (local.set $ret64 (call $future.new)) - (local.set $futr (i32.wrap_i64 (local.get $ret64))) - (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) - (local.set $packed (call $hold-lock (local.get $futr) (i32.const 24))) - (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) - (then unreachable)) - (local.set $hold-sub (i32.shr_u (local.get $packed) (i32.const 4))) - (local.set $hold-ws (call $waitable-set.new)) - (call $waitable.join (local.get $hold-sub) (local.get $hold-ws)) - - ;; complete the read: hold-lock's thread is now ready, but it still - ;; holds the lock and nothing has resumed it - (if (i32.ne (i32.const 0 (; COMPLETED ;)) - (call $future.write (local.get $futw) (i32.const 16))) - (then unreachable)) - (if (i32.ne (call $waitable-set.poll (local.get $hold-ws) (i32.const 0)) - (i32.const 0 (; NONE ;))) - (then unreachable)) - - ;; cancel park: its own thread is not ready while the lock is held, so - ;; the only candidate belongs to hold-lock - (local.set $ret (call $subtask.cancel-async (local.get $park-sub))) - - ;; hold-lock must have been resumed by the cancel and run to completion - (if (i32.ne (call $waitable-set.poll (local.get $hold-ws) (i32.const 0)) - (i32.const 1 (; SUBTASK ;))) - (then unreachable)) - (if (i32.ne (i32.load (i32.const 0)) (local.get $hold-sub)) - (then unreachable)) - (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) - (then unreachable)) - (if (i32.ne (i32.load (i32.const 24)) (i32.const 43)) - (then unreachable)) - (call $waitable.join (local.get $hold-sub) (i32.const 0)) - (call $subtask.drop (local.get $hold-sub)) - - ;; if the host stopped after resuming hold-lock, the request is still - ;; only pending; with the lock now free, blocking lets park receive it - (if (i32.eq (local.get $ret) (i32.const -1 (; BLOCKED ;))) - (then - (local.set $park-ws (call $waitable-set.new)) - (call $waitable.join (local.get $park-sub) (local.get $park-ws)) - (if (i32.ne (call $waitable-set.wait (local.get $park-ws) (i32.const 0)) - (i32.const 1 (; SUBTASK ;))) - (then unreachable)) - (if (i32.ne (i32.load (i32.const 0)) (local.get $park-sub)) - (then unreachable)) - (local.set $ret (i32.load (i32.const 4))) - (call $waitable.join (local.get $park-sub) (i32.const 0)))) - (if (i32.ne (local.get $ret) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) - (then unreachable)) - (call $subtask.drop (local.get $park-sub)) - (i32.const 42)) - ) - (core instance $main (instantiate $Main (with "" (instance - (export "mem" (memory $memory "mem")) - (export "future.new" (func $future.new)) - (export "future.write" (func $future.write)) - (export "park" (func $park')) - (export "hold-lock" (func $hold-lock')) - (export "subtask.cancel-async" (func $subtask.cancel-async)) - (export "subtask.drop" (func $subtask.drop)) - (export "waitable.join" (func $waitable.join)) - (export "waitable-set.new" (func $waitable-set.new)) - (export "waitable-set.poll" (func $waitable-set.poll)) - (export "waitable-set.wait" (func $waitable-set.wait)))))) - (func (export "run") async (result u32) (canon lift (core func $main "run"))) -) -(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-not-delivered.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-not-delivered.wast new file mode 100644 index 000000000..df42388d9 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-not-delivered.wast @@ -0,0 +1,101 @@ +;; Test that a cancellation request that is *pending* but not yet *delivered* +;; traps if the task tries to call `task.cancel` early. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + + (func $f (export "f") (param $futr i32) + (if (i32.ne (call $future.read (local.get $futr) (i32.const 0)) + (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + (call $task.cancel) ;; boom + unreachable + ) + ) + (type $FT (future)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT (memory (core memory $memory "mem")) (core func $future.read)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + )))) + (func (export "f") async (param "fut" $FT) (canon lift (core func $cm "f") async)) + ) + + (component $D + (type $FT (future)) + (import "f" (func $f async (param "fut" $FT))) + + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $DM + (import "" "mem" (memory 1)) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (import "" "f" (func $f (param i32) (result i32))) + + (func $run (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) (local $ret i32) (local $subtask i32) + (local $ws i32) + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; call f; it blocks in the sync future.read + (local.set $ret (call $f (local.get $futr))) + (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $ret) (i32.const 4))) + + ;; cancel; deterministically BLOCKED since the callee is blocked in + ;; its initial core function and cannot receive the request + (if (i32.ne (call $subtask.cancel (local.get $subtask)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; write the future, unblocking the callee, which now calls + ;; task.cancel directly while its cancellation is still pending; per + ;; spec this must trap before the callee ever resolves + (if (i32.ne (call $future.write (local.get $futw) (i32.const 0)) (i32.const 0 (; COMPLETED ;))) + (then unreachable)) + + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $subtask) (local.get $ws)) + (drop (call $waitable-set.wait (local.get $ws) (i32.const 8))) + (i32.const 42) + ) + ) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (canon lower (func $f) async (memory (core memory $memory "mem")) (core func $f')) + (core instance $dm (instantiate $DM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "subtask.cancel" (func $subtask.cancel)) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)) + (export "f" (func $f')) + )))) + (func (export "run") async (result u32) (canon lift (core func $dm "run"))) + ) + + (instance $c (instantiate $C)) + (instance $d (instantiate $D (with "f" (func $c "f")))) + (func (export "run") (alias export $d "run")) +) +(assert_trap (invoke "run") "`task.cancel` called by task which has not been cancelled") diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-resumed-callback-switch.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-resumed-callback-switch.wast new file mode 100644 index 000000000..9741375a3 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-resumed-callback-switch.wast @@ -0,0 +1,331 @@ +;; A `callback` function resumed by 'subtask.cancel' to receive TASK_CANCELLED +;; runs like any other callback: it may switch to an explicit thread with +;; 'thread.suspend-then-resume', including one belonging to a different task of +;; the same component instance. If the thread it switches to then blocks, +;; control flows back out through 'subtask.cancel', which reports BLOCKED +;; because the cancelled task has not resolved; its callback is still +;; suspended part-way through. + +;; The explicit thread belongs to the cancelled task itself. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $Table (table (export "tbl") 1 funcref)) + (core instance $table (instantiate $Table)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "tbl" (table $tbl 1 funcref)) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + (import "" "thread.suspend-then-resume" (func $thread.suspend-then-resume (param i32) (result i32))) + + (global $futr (mut i32) (i32.const 0)) + (global $park-thread (mut i32) (i32.const 0xdead)) + (global $worker-ran (mut i32) (i32.const 0)) + (func (export "ran") (result i32) (global.get $worker-ran)) + + (func $worker (param i32) + (global.set $worker-ran (i32.const 1)) + ;; block; control returns out through 'subtask.cancel' + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.read (global.get $futr) (i32.const 16))) + (then unreachable)) + ;; hand control back to the parked callback + (call $thread.resume-later (global.get $park-thread))) + (elem (table $tbl) (i32.const 0) func $worker) + + (func (export "park") (param $f i32) (result i32) + (global.set $futr (local.get $f)) + (global.set $park-thread (call $thread.index)) + (i32.or (i32.const 2 (; WAIT ;)) + (i32.shl (call $waitable-set.new) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (drop (call $thread.suspend-then-resume + (call $thread.new-indirect (i32.const 0) (i32.const 0)))) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ) + (type $FT (future)) + (core type $ThreadFT (func (param i32))) + (alias core export $table "tbl" (core table $tbl)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon thread.index (core func $thread.index)) + (canon thread.new-indirect $ThreadFT (core table $tbl) (core func $thread.new-indirect)) + (canon thread.resume-later (core func $thread.resume-later)) + (canon thread.suspend-then-resume (core func $thread.suspend-then-resume)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "tbl" (table $tbl)) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "thread.index" (func $thread.index)) + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.resume-later" (func $thread.resume-later)) + (export "thread.suspend-then-resume" (func $thread.suspend-then-resume)))))) + (func (export "park") async (param "fut" $FT) + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + (func (export "ran") (result u32) (canon lift (core func $cm "ran"))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "park") async (core func $park')) + (canon lower (func $c "ran") (core func $ran')) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "park" (func $park (param i32) (result i32))) + (import "" "ran" (func $ran (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) + (local $packed i32) (local $sub i32) (local $ws i32) + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + (local.set $packed (call $park (local.get $futr))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; the callback is resumed, switches to the explicit thread, and that + ;; thread blocks, so control lands back here + (if (i32.ne (call $subtask.cancel (local.get $sub)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.eqz (call $ran)) (then unreachable)) + + ;; unblock the explicit thread, which hands control back to the callback + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $futw) (i32.const 16))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $sub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $sub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $waitable.join (local.get $sub) (i32.const 0)) + (call $subtask.drop (local.get $sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "park" (func $park')) + (export "ran" (func $ran')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) + +;; The same, but the explicit thread belongs to a different task of the same +;; component instance: 'holder', which spawned it and then blocked. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $Table (table (export "tbl") 1 funcref)) + (core instance $table (instantiate $Table)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "tbl" (table $tbl 1 funcref)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "thread.index" (func $thread.index (result i32))) + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + (import "" "thread.suspend-then-resume" (func $thread.suspend-then-resume (param i32) (result i32))) + + (global $worker-futr (mut i32) (i32.const 0)) + (global $worker-thread (mut i32) (i32.const 0xdead)) + (global $park-thread (mut i32) (i32.const 0xdead)) + (global $worker-ran (mut i32) (i32.const 0)) + (func (export "ran") (result i32) (global.get $worker-ran)) + + ;; an explicit thread of 'holder', left suspended until 'park's callback + ;; switches to it + (func $worker (param i32) + (global.set $worker-ran (i32.const 1)) + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.read (global.get $worker-futr) (i32.const 16))) + (then unreachable)) + (call $thread.resume-later (global.get $park-thread))) + (elem (table $tbl) (i32.const 0) func $worker) + + (func (export "holder") (param $f i32) + (global.set $worker-thread (call $thread.new-indirect (i32.const 0) (i32.const 0))) + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.read (local.get $f) (i32.const 16))) + (then unreachable)) + (call $task.return (i32.const 43))) + + (func (export "park") (param $f i32) (result i32) + (global.set $worker-futr (local.get $f)) + (global.set $park-thread (call $thread.index)) + (i32.or (i32.const 2 (; WAIT ;)) + (i32.shl (call $waitable-set.new) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (drop (call $thread.suspend-then-resume (global.get $worker-thread))) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ) + (type $FT (future)) + (core type $ThreadFT (func (param i32))) + (alias core export $table "tbl" (core table $tbl)) + (canon task.return (result u32) (core func $task.return)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon thread.index (core func $thread.index)) + (canon thread.new-indirect $ThreadFT (core table $tbl) (core func $thread.new-indirect)) + (canon thread.resume-later (core func $thread.resume-later)) + (canon thread.suspend-then-resume (core func $thread.suspend-then-resume)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "tbl" (table $tbl)) + (export "task.return" (func $task.return)) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "thread.index" (func $thread.index)) + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.resume-later" (func $thread.resume-later)) + (export "thread.suspend-then-resume" (func $thread.suspend-then-resume)))))) + (func (export "holder") async (param "fut" $FT) (result u32) + (canon lift (core func $cm "holder") async)) + (func (export "park") async (param "fut" $FT) + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + (func (export "ran") (result u32) (canon lift (core func $cm "ran"))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "holder") async (memory (core memory $memory "mem")) (core func $holder')) + (canon lower (func $c "park") async (core func $park')) + (canon lower (func $c "ran") (core func $ran')) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "holder" (func $holder (param i32 i32) (result i32))) + (import "" "park" (func $park (param i32) (result i32))) + (import "" "ran" (func $ran (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) + (local $hr i32) (local $hw i32) (local $wr i32) (local $ww i32) + (local $packed i32) (local $hsub i32) (local $psub i32) (local $ws i32) + (local.set $ret64 (call $future.new)) + (local.set $hr (i32.wrap_i64 (local.get $ret64))) + (local.set $hw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (local.set $ret64 (call $future.new)) + (local.set $wr (i32.wrap_i64 (local.get $ret64))) + (local.set $ww (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; holder spawns the explicit thread and then blocks + (local.set $packed (call $holder (local.get $hr) (i32.const 24))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $hsub (i32.shr_u (local.get $packed) (i32.const 4))) + (local.set $packed (call $park (local.get $wr))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $psub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; park's callback switches to holder's explicit thread, which blocks + (if (i32.ne (call $subtask.cancel (local.get $psub)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.eqz (call $ran)) (then unreachable)) + + (local.set $ws (call $waitable-set.new)) + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $ww) (i32.const 16))) + (then unreachable)) + (call $waitable.join (local.get $psub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $psub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $waitable.join (local.get $psub) (i32.const 0)) + (call $subtask.drop (local.get $psub)) + + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $hw) (i32.const 16))) + (then unreachable)) + (call $waitable.join (local.get $hsub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $hsub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 43)) (then unreachable)) + (call $waitable.join (local.get $hsub) (i32.const 0)) + (call $subtask.drop (local.get $hsub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "holder" (func $holder')) + (export "park" (func $park')) + (export "ran" (func $ran')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-targeted-resume.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-targeted-resume.wast new file mode 100644 index 000000000..997fa22c5 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-targeted-resume.wast @@ -0,0 +1,699 @@ +;; 'subtask.cancel' resumes at most one thread: the implicit thread of the task +;; being cancelled, and only while that thread is parked in its `callback` event +;; loop; nothing else. Each case checks that the thread in question did not run, +;; whether the cancellation ends up BLOCKED or the targeted thread resolves it. + +;; The implicit thread of a `callback` task that has already returned EXIT is +;; gone, so there is nothing for the cancellation to resume, even though the +;; task is still unresolved because an explicit thread is keeping it alive. +;; 'exiter' never blocks, so it has left its event loop by the time the caller +;; regains control. +(component + (component $C + (core module $Table (table (export "tbl") 1 funcref)) + (core instance $table (instantiate $Table)) + (core module $CM + (import "" "tbl" (table $tbl 1 funcref)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + + ;; keeps the task alive once the implicit thread is gone, then resolves it + (func $keeper (param i32) (call $task.return (i32.const 43))) + (elem (table $tbl) (i32.const 0) func $keeper) + + (func (export "exiter") (result i32) + (call $thread.resume-later (call $thread.new-indirect (i32.const 0) (i32.const 0))) + (i32.const 0 (; EXIT ;))) + (func (export "unreachable-cb") (param i32 i32 i32) (result i32) + unreachable) + ) + (core type $ThreadFT (func (param i32))) + (alias core export $table "tbl" (core table $tbl)) + (canon task.return (result u32) (core func $task.return)) + (canon thread.new-indirect $ThreadFT (core table $tbl) (core func $thread.new-indirect)) + (canon thread.resume-later (core func $thread.resume-later)) + (core instance $cm (instantiate $CM (with "" (instance + (export "tbl" (table $tbl)) + (export "task.return" (func $task.return)) + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.resume-later" (func $thread.resume-later)))))) + (func (export "exiter") async (result u32) + (canon lift (core func $cm "exiter") async (callback (core func $cm "unreachable-cb")))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (canon lower (func $c "exiter") async (memory (core memory $memory "mem")) (core func $exiter')) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "exiter" (func $exiter (param i32) (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $packed i32) (local $sub i32) (local $ws i32) + (local.set $packed (call $exiter (i32.const 24))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; nothing to resume: the implicit thread is gone and the explicit thread + ;; is not it + (if (i32.ne (call $subtask.cancel (local.get $sub)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + + ;; the explicit thread resolves the task, never seeing the cancellation + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $sub) (local.get $ws)) + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $sub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 43)) (then unreachable)) + (call $waitable.join (local.get $sub) (i32.const 0)) + (call $subtask.drop (local.get $sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "exiter" (func $exiter')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) + +;; Cancelling a task must not resume a ready thread that belongs to a different +;; task, whether that task lives in the same component instance as the cancelled +;; one or in another instance. $C is instantiated twice so that both hold a +;; 'worker' parked in its event loop with a pending FUTURE_READ event, ready to +;; run as soon as the caller writes its future. Cancelling 'stuck', which is +;; stackful and blocked and so neither cancellable nor ready, must leave both +;; workers exactly where they are. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "future.read-sync" (func $future.read-sync (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + + ;; Parks in its event loop with a *pending* FUTURE_READ event, so its + ;; thread is ready as soon as the caller writes the future. + (func (export "worker") (param $futr i32) (result i32) + (local $ws i32) + (if (i32.ne (i32.const -1 (; BLOCKED ;)) + (call $future.read (local.get $futr) (i32.const 16))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $futr) (local.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (local.get $ws) (i32.const 4)))) + (func (export "worker-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 4 (; FUTURE_READ ;))) + (then unreachable)) + (call $task.return (i32.const 43)) + (i32.const 0 (; EXIT ;))) + + ;; The cancellation target: stackful, so never cancellable, and blocked + ;; on a future nobody has written, so not ready either. + (func (export "stuck") (param $futr i32) + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.read-sync (local.get $futr) (i32.const 16))) + (then unreachable)) + (call $task.return (i32.const 44))) + ) + (type $FT (future)) + (canon task.return (result u32) (core func $task.return)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon future.read $FT (memory (core memory $memory "mem")) (core func $future.read-sync)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.return" (func $task.return)) + (export "future.read" (func $future.read)) + (export "future.read-sync" (func $future.read-sync)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)))))) + (func (export "worker") async (param "fut" $FT) (result u32) + (canon lift (core func $cm "worker") async (callback (core func $cm "worker-cb")))) + (func (export "stuck") async (param "fut" $FT) (result u32) + (canon lift (core func $cm "stuck") async)) + ) + (instance $c1 (instantiate $C)) + (instance $c2 (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c1 "worker") async (memory (core memory $memory "mem")) (core func $worker-same')) + (canon lower (func $c2 "worker") async (memory (core memory $memory "mem")) (core func $worker-other')) + (canon lower (func $c1 "stuck") async (memory (core memory $memory "mem")) (core func $stuck')) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "worker-same" (func $worker-same (param i32 i32) (result i32))) + (import "" "worker-other" (func $worker-other (param i32 i32) (result i32))) + (import "" "stuck" (func $stuck (param i32 i32) (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + + ;; checks that a callee started and returns its subtask index + (func $start (param $packed i32) (result i32) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (i32.shr_u (local.get $packed) (i32.const 4))) + (func $ready (param $futw i32) + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $futw) (i32.const 16))) + (then unreachable))) + (func $watch (param $sub i32) (result i32) + (local $set i32) + (local.set $set (call $waitable-set.new)) + (call $waitable.join (local.get $sub) (local.get $set)) + (local.get $set)) + (func $assert-quiet (param $set i32) + (if (i32.ne (call $waitable-set.poll (local.get $set) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable))) + (func $await-returned (param $set i32) (param $sub i32) (param $retp i32) (param $expected i32) + (if (i32.ne (call $waitable-set.wait (local.get $set) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $sub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (if (i32.ne (i32.load (local.get $retp)) (local.get $expected)) (then unreachable)) + (call $waitable.join (local.get $sub) (i32.const 0)) + (call $subtask.drop (local.get $sub))) + + (func (export "run") (result i32) + (local $ret64 i64) + (local $same-r i32) (local $same-w i32) + (local $other-r i32) (local $other-w i32) + (local $stuck-r i32) (local $stuck-w i32) + (local $same i32) (local $other i32) (local $stuck-sub i32) + (local $same-set i32) (local $other-set i32) (local $stuck-set i32) + + (local.set $ret64 (call $future.new)) + (local.set $same-r (i32.wrap_i64 (local.get $ret64))) + (local.set $same-w (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (local.set $ret64 (call $future.new)) + (local.set $other-r (i32.wrap_i64 (local.get $ret64))) + (local.set $other-w (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (local.set $ret64 (call $future.new)) + (local.set $stuck-r (i32.wrap_i64 (local.get $ret64))) + (local.set $stuck-w (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + ;; both workers park in their event loops, releasing their instances' + ;; exclusive locks + (local.set $same (call $start (call $worker-same (local.get $same-r) (i32.const 24)))) + (local.set $other (call $start (call $worker-other (local.get $other-r) (i32.const 28)))) + (local.set $stuck-sub (call $start (call $stuck (local.get $stuck-r) (i32.const 32)))) + + ;; make both workers' threads ready without resuming them + (call $ready (local.get $same-w)) + (call $ready (local.get $other-w)) + (local.set $same-set (call $watch (local.get $same))) + (local.set $other-set (call $watch (local.get $other))) + (call $assert-quiet (local.get $same-set)) + (call $assert-quiet (local.get $other-set)) + + ;; stuck is neither cancellable nor ready, and both workers belong to + ;; other tasks, so nothing is resumed + (if (i32.ne (call $subtask.cancel (local.get $stuck-sub)) (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (call $assert-quiet (local.get $same-set)) + (call $assert-quiet (local.get $other-set)) + + ;; all three still finish normally once this task blocks + (call $await-returned (local.get $same-set) (local.get $same) (i32.const 24) (i32.const 43)) + (call $await-returned (local.get $other-set) (local.get $other) (i32.const 28) (i32.const 43)) + (call $ready (local.get $stuck-w)) + (local.set $stuck-set (call $watch (local.get $stuck-sub))) + (call $await-returned (local.get $stuck-set) (local.get $stuck-sub) (i32.const 32) (i32.const 44)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "worker-same" (func $worker-same')) + (export "worker-other" (func $worker-other')) + (export "stuck" (func $stuck')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) + +;; Test that a callback is not called while another thread is holding +;; the exclusive lock. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + + ;; Parks in the event loop on a waitable set that never gets an event, so + ;; only the delivery of a pending cancellation can wake this task which + ;; cannot happen while the exclusive lock is held. + (global $never (mut i32) (i32.const 0)) + (func $start (global.set $never (call $waitable-set.new))) + (start $start) + (func (export "park") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.get $never) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + + ;; Blocks holding the instance's exclusive lock for as long as it is + ;; blocked. Once the caller writes the future, this task's thread is ready + ;; but the lock is still held. + (func (export "hold-lock") (param $futr i32) (result i32) + (local $ws i32) + (if (i32.ne (i32.const -1 (; BLOCKED ;)) + (call $future.read (local.get $futr) (i32.const 0))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $futr) (local.get $ws)) + (if (i32.ne (i32.const 4 (; FUTURE_READ ;)) + (call $waitable-set.wait (local.get $ws) (i32.const 8))) + (then unreachable)) + (call $task.return (i32.const 43)) + (i32.const 0 (; EXIT ;))) + (func (export "unreachable-cb") (param i32 i32 i32) (result i32) + unreachable) + ) + (type $FT (future)) + (canon task.return (result u32) (core func $task.return)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.return" (func $task.return)) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "park") async + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + (func (export "hold-lock") async (param "fut" $FT) (result u32) + (canon lift (core func $cm "hold-lock") async (callback (core func $cm "unreachable-cb")))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "park") async (core func $park')) + (canon lower (func $c "hold-lock") async (memory (core memory $memory "mem")) (core func $hold-lock')) + (canon subtask.cancel async (core func $subtask.cancel-async)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "park" (func $park (result i32))) + (import "" "hold-lock" (func $hold-lock (param i32 i32) (result i32))) + (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) + (local $packed i32) + (local $park-sub i32) (local $hold-sub i32) + (local $park-ws i32) (local $hold-ws i32) + + ;; start "park"; returning WAIT releases the lock, so it is free here + (local.set $packed (call $park)) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $park-sub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; start hold-lock; it takes the lock and blocks while holding it + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (local.set $packed (call $hold-lock (local.get $futr) (i32.const 24))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $hold-sub (i32.shr_u (local.get $packed) (i32.const 4))) + (local.set $hold-ws (call $waitable-set.new)) + (call $waitable.join (local.get $hold-sub) (local.get $hold-ws)) + + ;; complete the read: hold-lock's thread is now ready, but it still + ;; holds the lock and nothing has resumed it + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $futw) (i32.const 16))) + (then unreachable)) + (if (i32.ne (call $waitable-set.poll (local.get $hold-ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + + ;; cancel park: its own thread is not ready while the lock is held, and + ;; hold-lock's ready thread belongs to a different task, so nothing at + ;; all is resumed + (if (i32.ne (call $subtask.cancel-async (local.get $park-sub)) + (i32.const -1 (; BLOCKED ;))) + (then unreachable)) + (if (i32.ne (call $waitable-set.poll (local.get $hold-ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + + ;; blocking here lets hold-lock finish and release the lock + (if (i32.ne (call $waitable-set.wait (local.get $hold-ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $hold-sub)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 43)) + (then unreachable)) + (call $waitable.join (local.get $hold-sub) (i32.const 0)) + (call $subtask.drop (local.get $hold-sub)) + + ;; with the lock now free, blocking lets park receive the request + (local.set $park-ws (call $waitable-set.new)) + (call $waitable.join (local.get $park-sub) (local.get $park-ws)) + (if (i32.ne (call $waitable-set.wait (local.get $park-ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $park-sub)) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (call $waitable.join (local.get $park-sub) (i32.const 0)) + (call $subtask.drop (local.get $park-sub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "park" (func $park')) + (export "hold-lock" (func $hold-lock')) + (export "subtask.cancel-async" (func $subtask.cancel-async)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) + +;; Once the cancelled task's own callback thread has been resumed and the task +;; has resolved, 'subtask.cancel' returns without resuming anything else, even +;; though 'worker' has been sitting ready in its event loop the whole time. +(component + (component $C + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $CM + (import "" "mem" (memory 1)) + (import "" "task.return" (func $task.return (param i32))) + (import "" "task.cancel" (func $task.cancel)) + (import "" "future.read" (func $future.read (param i32 i32) (result i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + + ;; ready before the cancel: parked in its event loop with a pending event + (func (export "worker") (param $futr i32) (result i32) + (local $ws i32) + (if (i32.ne (i32.const -1 (; BLOCKED ;)) + (call $future.read (local.get $futr) (i32.const 16))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $futr) (local.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (local.get $ws) (i32.const 4)))) + (func (export "worker-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 4 (; FUTURE_READ ;))) + (then unreachable)) + (call $task.return (i32.const 43)) + (i32.const 0 (; EXIT ;))) + + ;; the cancellation target + (func (export "park") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) + (i32.shl (call $waitable-set.new) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ) + (type $FT (future)) + (canon task.return (result u32) (core func $task.return)) + (canon task.cancel (core func $task.cancel)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (core instance $cm (instantiate $CM (with "" (instance + (export "mem" (memory $memory "mem")) + (export "task.return" (func $task.return)) + (export "task.cancel" (func $task.cancel)) + (export "future.read" (func $future.read)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)))))) + (func (export "worker") async (param "fut" $FT) (result u32) + (canon lift (core func $cm "worker") async (callback (core func $cm "worker-cb")))) + (func (export "park") async + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + ) + (instance $c (instantiate $C)) + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (type $FT (future)) + (canon future.new $FT (core func $future.new)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon lower (func $c "worker") async (memory (core memory $memory "mem")) (core func $worker')) + (canon lower (func $c "park") async (core func $park')) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) + (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) + + (core module $Main + (import "" "mem" (memory 1)) + (import "" "future.new" (func $future.new (result i64))) + (import "" "future.write" (func $future.write (param i32 i32) (result i32))) + (import "" "worker" (func $worker (param i32 i32) (result i32))) + (import "" "park" (func $park (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) + (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) + (func (export "run") (result i32) + (local $ret64 i64) (local $futr i32) (local $futw i32) + (local $packed i32) (local $wsub i32) (local $psub i32) (local $ws i32) + (local.set $ret64 (call $future.new)) + (local.set $futr (i32.wrap_i64 (local.get $ret64))) + (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + + (local.set $packed (call $worker (local.get $futr) (i32.const 24))) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $wsub (i32.shr_u (local.get $packed) (i32.const 4))) + (local.set $packed (call $park)) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $psub (i32.shr_u (local.get $packed) (i32.const 4))) + + ;; worker's thread is now ready, but nothing has resumed it + (if (i32.ne (i32.const 0 (; COMPLETED ;)) + (call $future.write (local.get $futw) (i32.const 16))) + (then unreachable)) + (local.set $ws (call $waitable-set.new)) + (call $waitable.join (local.get $wsub) (local.get $ws)) + (if (i32.ne (call $waitable-set.poll (local.get $ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + + ;; park is resumed and resolves eagerly, but worker stays put + (if (i32.ne (call $subtask.cancel (local.get $psub)) + (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (if (i32.ne (call $waitable-set.poll (local.get $ws) (i32.const 0)) + (i32.const 0 (; NONE ;))) + (then unreachable)) + (call $subtask.drop (local.get $psub)) + + ;; worker still runs once this task blocks + (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) + (i32.const 1 (; SUBTASK ;))) + (then unreachable)) + (if (i32.ne (i32.load (i32.const 0)) (local.get $wsub)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 4)) (i32.const 2 (; RETURNED ;))) (then unreachable)) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 43)) (then unreachable)) + (call $waitable.join (local.get $wsub) (i32.const 0)) + (call $subtask.drop (local.get $wsub)) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "mem" (memory $memory "mem")) + (export "future.new" (func $future.new)) + (export "future.write" (func $future.write)) + (export "worker" (func $worker')) + (export "park" (func $park')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "waitable-set.poll" (func $waitable-set.poll)) + (export "waitable-set.wait" (func $waitable-set.wait)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) + +;; A thread made ready by the resumed callback itself, from inside the +;; cancellation, is not resumed either: 'subtask.cancel' returns as soon as the +;; callback is done. Yielding afterwards lets the host schedule it as it would +;; any other ready thread. +(component + (component $C + (core module $Table (table (export "tbl") 1 funcref)) + (core instance $table (instantiate $Table)) + (core module $CM + (import "" "tbl" (table $tbl 1 funcref)) + (import "" "task.cancel" (func $task.cancel)) + (import "" "waitable-set.new" (func $waitable-set.new (result i32))) + (import "" "thread.new-indirect" (func $thread.new-indirect (param i32 i32) (result i32))) + (import "" "thread.resume-later" (func $thread.resume-later (param i32))) + + (global $spawned-ran (mut i32) (i32.const 0)) + (func $spawned (param i32) (global.set $spawned-ran (i32.const 1))) + (elem (table $tbl) (i32.const 0) func $spawned) + (func (export "ran") (result i32) (global.get $spawned-ran)) + + (func (export "park") (result i32) + (i32.or (i32.const 2 (; WAIT ;)) + (i32.shl (call $waitable-set.new) (i32.const 4)))) + (func (export "park-cb") (param $event i32) (param i32 i32) (result i32) + (if (i32.ne (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) + (then unreachable)) + ;; make a fresh thread ready from inside the cancellation itself + (call $thread.resume-later (call $thread.new-indirect (i32.const 0) (i32.const 0))) + (call $task.cancel) + (i32.const 0 (; EXIT ;))) + ) + (core type $ThreadFT (func (param i32))) + (alias core export $table "tbl" (core table $tbl)) + (canon task.cancel (core func $task.cancel)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon thread.new-indirect $ThreadFT (core table $tbl) (core func $thread.new-indirect)) + (canon thread.resume-later (core func $thread.resume-later)) + (core instance $cm (instantiate $CM (with "" (instance + (export "tbl" (table $tbl)) + (export "task.cancel" (func $task.cancel)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "thread.new-indirect" (func $thread.new-indirect)) + (export "thread.resume-later" (func $thread.resume-later)))))) + (func (export "park") async + (canon lift (core func $cm "park") async (callback (core func $cm "park-cb")))) + (func (export "ran") (result u32) (canon lift (core func $cm "ran"))) + ) + (instance $c (instantiate $C)) + (canon lower (func $c "park") async (core func $park')) + (canon lower (func $c "ran") (core func $ran')) + (canon subtask.cancel async (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (canon thread.yield (core func $thread.yield)) + + (core module $Main + (import "" "park" (func $park (result i32))) + (import "" "ran" (func $ran (result i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + (import "" "thread.yield" (func $thread.yield (result i32))) + (func (export "run") (result i32) + (local $packed i32) (local $sub i32) (local $i i32) + (local.set $packed (call $park)) + (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) + (then unreachable)) + (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) + (if (i32.ne (call $subtask.cancel (local.get $sub)) + (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) + (then unreachable)) + (if (call $ran) (then unreachable)) + (call $subtask.drop (local.get $sub)) + + ;; yielding hands the deferred thread to the host, which runs it + (block $done + (loop $again + (br_if $done (call $ran)) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (if (i32.gt_u (local.get $i) (i32.const 32)) (then unreachable)) + (drop (call $thread.yield)) + (br $again))) + (i32.const 42)) + ) + (core instance $main (instantiate $Main (with "" (instance + (export "park" (func $park')) + (export "ran" (func $ran')) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + (export "thread.yield" (func $thread.yield)))))) + (func (export "run") async (result u32) (canon lift (core func $main "run"))) +) +(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast b/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast deleted file mode 100644 index b8d4dd4dc..000000000 --- a/packages/jco-transpile/test/fixtures/wast/component-model/async/cancel-yield-loop.wast +++ /dev/null @@ -1,135 +0,0 @@ -;; 'subtask.cancel' may keep resuming ready threads of the callee's instance -;; until the cancelled task resolves, but it is only *allowed* to, never -;; required to: the host may stop at any point. This test checks that, given an -;; infinite yield loop, the host eventually declares the cancellation blocked. -(component - (component $C - (core module $Memory (memory (export "mem") 1)) - (core instance $memory (instantiate $Memory)) - (core module $CM - (import "" "mem" (memory 1)) - (import "" "task.cancel" (func $task.cancel)) - (import "" "future.read" (func $future.read (param i32 i32) (result i32))) - (import "" "waitable.join" (func $waitable.join (param i32 i32))) - (import "" "waitable-set.new" (func $waitable-set.new (result i32))) - (import "" "waitable-set.poll" (func $waitable-set.poll (param i32 i32) (result i32))) - (global $ws (mut i32) (i32.const 0)) - (global $cancelled (mut i32) (i32.const 0)) - - (func (export "yielder") (param $futr i32) (result i32) - (if (i32.ne (i32.const -1 (; BLOCKED ;)) - (call $future.read (local.get $futr) (i32.const 0))) - (then unreachable)) - (global.set $ws (call $waitable-set.new)) - (call $waitable.join (local.get $futr) (global.get $ws)) - (i32.const 1 (; YIELD ;))) - - (func (export "yielder-cb") (param $event i32) (param i32 i32) (result i32) - ;; A delivered cancellation is remembered but cannot be acted on yet: - ;; this task must first see its future read complete. - (if (i32.eq (local.get $event) (i32.const 6 (; TASK_CANCELLED ;))) - (then - (if (global.get $cancelled) (then unreachable)) - (global.set $cancelled (i32.const 1)) - (return (i32.const 1 (; YIELD ;))))) - (if (i32.ne (local.get $event) (i32.const 0 (; NONE ;))) - (then unreachable)) - ;; Keep spinning until the caller writes the future. Every turn of this - ;; loop leaves the thread ready again, so a host that resumed until the - ;; task resolved would never get here. - (if (i32.eq (i32.const 0 (; NONE ;)) - (call $waitable-set.poll (global.get $ws) (i32.const 8))) - (then (return (i32.const 1 (; YIELD ;))))) - ;; the read completed; the cancellation must already have arrived - (if (i32.eqz (global.get $cancelled)) (then unreachable)) - (call $task.cancel) - (i32.const 0 (; EXIT ;))) - ) - (type $FT (future)) - (canon task.cancel (core func $task.cancel)) - (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) - (canon waitable.join (core func $waitable.join)) - (canon waitable-set.new (core func $waitable-set.new)) - (canon waitable-set.poll (memory (core memory $memory "mem")) (core func $waitable-set.poll)) - (core instance $cm (instantiate $CM (with "" (instance - (export "mem" (memory $memory "mem")) - (export "task.cancel" (func $task.cancel)) - (export "future.read" (func $future.read)) - (export "waitable.join" (func $waitable.join)) - (export "waitable-set.new" (func $waitable-set.new)) - (export "waitable-set.poll" (func $waitable-set.poll)))))) - (func (export "yielder") async (param "fut" $FT) - (canon lift (core func $cm "yielder") async (callback (core func $cm "yielder-cb")))) - ) - (instance $c (instantiate $C)) - (core module $Memory (memory (export "mem") 1)) - (core instance $memory (instantiate $Memory)) - (type $FT (future)) - (canon future.new $FT (core func $future.new)) - (canon future.write $FT (memory (core memory $memory "mem")) (core func $future.write)) - (canon lower (func $c "yielder") async (core func $yielder')) - (canon subtask.cancel async (core func $subtask.cancel-async)) - (canon subtask.drop (core func $subtask.drop)) - (canon waitable.join (core func $waitable.join)) - (canon waitable-set.new (core func $waitable-set.new)) - (canon waitable-set.wait (memory (core memory $memory "mem")) (core func $waitable-set.wait)) - - (core module $Main - (import "" "mem" (memory 1)) - (import "" "future.new" (func $future.new (result i64))) - (import "" "future.write" (func $future.write (param i32 i32) (result i32))) - (import "" "yielder" (func $yielder (param i32) (result i32))) - (import "" "subtask.cancel-async" (func $subtask.cancel-async (param i32) (result i32))) - (import "" "subtask.drop" (func $subtask.drop (param i32))) - (import "" "waitable.join" (func $waitable.join (param i32 i32))) - (import "" "waitable-set.new" (func $waitable-set.new (result i32))) - (import "" "waitable-set.wait" (func $waitable-set.wait (param i32 i32) (result i32))) - (func (export "run") (result i32) - (local $ret64 i64) (local $futr i32) (local $futw i32) - (local $packed i32) (local $sub i32) (local $ws i32) - (local.set $ret64 (call $future.new)) - (local.set $futr (i32.wrap_i64 (local.get $ret64))) - (local.set $futw (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) - - (local.set $packed (call $yielder (local.get $futr))) - (if (i32.ne (i32.and (local.get $packed) (i32.const 0xf)) (i32.const 1 (; STARTED ;))) - (then unreachable)) - (local.set $sub (i32.shr_u (local.get $packed) (i32.const 4))) - - ;; The callee cannot resolve until this task writes the future, which it - ;; cannot do from inside the call, so the cancel must give up and report - ;; BLOCKED rather than resuming the yield loop forever. - (if (i32.ne (call $subtask.cancel-async (local.get $sub)) - (i32.const -1 (; BLOCKED ;))) - (then unreachable)) - - ;; now let the yield loop see its read complete and resolve - (if (i32.ne (i32.const 0 (; COMPLETED ;)) - (call $future.write (local.get $futw) (i32.const 16))) - (then unreachable)) - (local.set $ws (call $waitable-set.new)) - (call $waitable.join (local.get $sub) (local.get $ws)) - (if (i32.ne (call $waitable-set.wait (local.get $ws) (i32.const 0)) - (i32.const 1 (; SUBTASK ;))) - (then unreachable)) - (if (i32.ne (i32.load (i32.const 0)) (local.get $sub)) - (then unreachable)) - (if (i32.ne (i32.load (i32.const 4)) (i32.const 4 (; CANCELLED_BEFORE_RETURNED ;))) - (then unreachable)) - (call $waitable.join (local.get $sub) (i32.const 0)) - (call $subtask.drop (local.get $sub)) - (i32.const 42)) - ) - (core instance $main (instantiate $Main (with "" (instance - (export "mem" (memory $memory "mem")) - (export "future.new" (func $future.new)) - (export "future.write" (func $future.write)) - (export "yielder" (func $yielder')) - (export "subtask.cancel-async" (func $subtask.cancel-async)) - (export "subtask.drop" (func $subtask.drop)) - (export "waitable.join" (func $waitable.join)) - (export "waitable-set.new" (func $waitable-set.new)) - (export "waitable-set.wait" (func $waitable-set.wait)))))) - (func (export "run") async (result u32) (canon lift (core func $main "run"))) -) -(assert_return (invoke "run") (u32.const 42)) diff --git a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json index 061f8b6e2..4ed00a937 100644 --- a/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json +++ b/packages/jco-transpile/test/fixtures/wast/upstream-manifest.json @@ -17,11 +17,15 @@ }, { "name": "cancel-delivery.wast", - "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + "revision": "c52323b20e0717368a67a7f309c6751675333317" }, { - "name": "cancel-instance-wide-resume.wast", - "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + "name": "cancel-not-delivered.wast", + "revision": "13ec1b34f4ada6d8a22602b3ddaec8e1167c543e" + }, + { + "name": "cancel-resumed-callback-switch.wast", + "revision": "c52323b20e0717368a67a7f309c6751675333317" }, { "name": "cancel-stream.wast", @@ -32,8 +36,8 @@ "revision": "ea917982dc142251157dfbb15fdf834ff509e604" }, { - "name": "cancel-yield-loop.wast", - "revision": "ea917982dc142251157dfbb15fdf834ff509e604" + "name": "cancel-targeted-resume.wast", + "revision": "c52323b20e0717368a67a7f309c6751675333317" }, { "name": "closed-stream.wast", diff --git a/packages/jco-transpile/test/p3/ported/component-model/wast.ts b/packages/jco-transpile/test/p3/ported/component-model/wast.ts index 7c935ba6a..fb9043320 100644 --- a/packages/jco-transpile/test/p3/ported/component-model/wast.ts +++ b/packages/jco-transpile/test/p3/ported/component-model/wast.ts @@ -12,11 +12,11 @@ import { fileExists, getTmpDir, readComponentBytes, setupAsyncTest } from '../.. interface WastTest { relPath: string; skip?: boolean; - artifactCase?: { + artifactCases?: readonly { index: number; exportName: string; expected: unknown; - }; + }[]; } interface WastTestModule { @@ -40,7 +40,13 @@ const WAST_TESTS: readonly WastTest[] = [ // Running tests { relPath: 'async/wait-during-callback.wast' }, { relPath: 'async/cancel-delivery.wast' }, - { relPath: 'async/cancel-instance-wide-resume.wast' }, + { relPath: 'async/cancel-not-delivered.wast' }, + // The remaining artifacts use thread.* built-ins, which jco does not + // support yet. These three cover targeted callback resumption without them. + { + relPath: 'async/cancel-targeted-resume.wast', + artifactCases: [1, 2, 3].map((index) => ({ index, exportName: 'run', expected: 42 })), + }, { relPath: 'async/cancel-stream.wast' }, { relPath: 'async/partial-stream-copies.wast' }, { relPath: 'async/futures-must-write.wast' }, @@ -49,13 +55,12 @@ const WAST_TESTS: readonly WastTest[] = [ { relPath: 'async/empty-wait.wast' }, { relPath: 'async/zero-length.wast' }, { relPath: 'async/cancel-subtask.wast' }, - { relPath: 'async/cancel-yield-loop.wast' }, { relPath: 'async/passing-resources.wast' }, // Only artifact 8 was added by component-model#723. Later artifacts use // thread.* built-ins, which jco does not support yet. { relPath: 'async/reentrance.wast', - artifactCase: { index: 8, exportName: 'run', expected: 42 }, + artifactCases: [{ index: 8, exportName: 'run', expected: 42 }], }, { relPath: 'async/drop-waitable-set.wast' }, { relPath: 'async/drop-subtask.wast' }, @@ -179,7 +184,7 @@ suite('component-model WAST', () => { } }); - for (const { relPath, skip, artifactCase } of WAST_TESTS) { + for (const { relPath, skip, artifactCases } of WAST_TESTS) { const wastPath = join(COMPONENT_MODEL_FIXTURES_WAST_DIR, relPath); const wasmPath = wastPath.replace(/\.wast$/, '.wast.wasm'); const scriptPath = `${wastPath}.js`; @@ -191,21 +196,23 @@ suite('component-model WAST', () => { const cleanups: (() => Promise)[] = []; try { const componentName = basename(relPath).replace('.wast', ''); - if (artifactCase) { - const artifactPath = wastPath.replace(/\.wast$/, `.wast.${artifactCase.index}.wasm`); - assert(await fileExists(artifactPath), `missing generated WAT artifact @ [${artifactPath}]`); - const outputDir = await getTmpDir(); - const setup = await setupAsyncTest({ - asyncMode: 'jspi', - component: { - name: `${componentName}-artifact-${artifactCase.index}`, - path: artifactPath, - outputDir, - }, - }); - cleanups.push(setup.cleanup); - const artifactInstance = setup.instance as Record Promise>; - assert.deepEqual(await artifactInstance[artifactCase.exportName](), artifactCase.expected); + if (artifactCases) { + for (const artifactCase of artifactCases) { + const artifactPath = wastPath.replace(/\.wast$/, `.wast.${artifactCase.index}.wasm`); + assert(await fileExists(artifactPath), `missing generated WAT artifact @ [${artifactPath}]`); + const outputDir = await getTmpDir(); + const setup = await setupAsyncTest({ + asyncMode: 'jspi', + component: { + name: `${componentName}-artifact-${artifactCase.index}`, + path: artifactPath, + outputDir, + }, + }); + cleanups.push(setup.cleanup); + const artifactInstance = setup.instance as Record Promise>; + assert.deepEqual(await artifactInstance[artifactCase.exportName](), artifactCase.expected); + } return; } From 8482c2507537c7c4c88442227e811cf0ca025b6e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 18 Sep 2026 11:45:41 +0000 Subject: [PATCH 07/11] fix(bindgen): resolve live FACT context tasks --- .../src/intrinsics/p3/async_task.rs | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs index 0d98a77ea..3650d29aa 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs @@ -395,14 +395,23 @@ impl AsyncTaskIntrinsic { // adapters have no component index; post-return also runs // after the callee register is cleared. Both use the executing // task, whose context FACT temporarily saves and restores. - const currentTaskMeta = {get_global_current_task_meta_fn}(ctx.componentIdx) - ?? {get_global_current_task_meta_fn}(); + let currentTaskMeta = {get_global_current_task_meta_fn}(ctx.componentIdx); + let taskMeta = currentTaskMeta + ? {current_task_get_fn}(currentTaskMeta.componentIdx, currentTaskMeta.taskID) + : undefined; + + // A post-return may run after the callee has exited. Its + // per-component register can therefore name a task which is + // no longer live; fall back to FACT's executing task. + if (!taskMeta) {{ + currentTaskMeta = {get_global_current_task_meta_fn}(); + taskMeta = currentTaskMeta + ? {current_task_get_fn}(currentTaskMeta.componentIdx, currentTaskMeta.taskID) + : undefined; + }} if (!currentTaskMeta) {{ throw new Error(`missing/incomplete global current task meta for component idx [${{ctx.componentIdx}}] during context set`); }} - const {{ taskID, componentIdx }} = currentTaskMeta; - - const taskMeta = {current_task_get_fn}(componentIdx, taskID); if (!taskMeta) {{ throw new Error('failed to retrieve current task'); }} let task = taskMeta.task; @@ -441,14 +450,23 @@ impl AsyncTaskIntrinsic { // adapters have no component index; post-return also runs // after the callee register is cleared. Both use the executing // task, whose context FACT temporarily saves and restores. - const currentTaskMeta = {get_global_current_task_meta_fn}(ctx.componentIdx) - ?? {get_global_current_task_meta_fn}(); + let currentTaskMeta = {get_global_current_task_meta_fn}(ctx.componentIdx); + let taskMeta = currentTaskMeta + ? {current_task_get_fn}(currentTaskMeta.componentIdx, currentTaskMeta.taskID) + : undefined; + + // A post-return may run after the callee has exited. Its + // per-component register can therefore name a task which is + // no longer live; fall back to FACT's executing task. + if (!taskMeta) {{ + currentTaskMeta = {get_global_current_task_meta_fn}(); + taskMeta = currentTaskMeta + ? {current_task_get_fn}(currentTaskMeta.componentIdx, currentTaskMeta.taskID) + : undefined; + }} if (!currentTaskMeta) {{ throw new Error(`missing/incomplete global current task meta for component idx [${{ctx.componentIdx}}] during context get`); }} - const {{ taskID, componentIdx }} = currentTaskMeta; - - const taskMeta = {current_task_get_fn}(componentIdx, taskID); if (!taskMeta) {{ throw new Error('failed to retrieve current task'); }} let task = taskMeta.task; From 7dc8f87a36a4bbf0b32d061170e661a262272f90 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 18 Sep 2026 11:45:46 +0000 Subject: [PATCH 08/11] test(transpile): retire obsolete cancellation cases --- .../test/p3/ported/wasmtime/component-async/cancel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/jco-transpile/test/p3/ported/wasmtime/component-async/cancel.ts b/packages/jco-transpile/test/p3/ported/wasmtime/component-async/cancel.ts index 0eb1acbd1..d62f3c817 100644 --- a/packages/jco-transpile/test/p3/ported/wasmtime/component-async/cancel.ts +++ b/packages/jco-transpile/test/p3/ported/wasmtime/component-async/cancel.ts @@ -63,12 +63,15 @@ suite('cancel scenario', () => { } } - test('normal', async () => { + // These fixtures predate component-model#726 and assert that cancelling a + // task blocked in a host import immediately resumes it. Cancellation may + // now resume only the target's callback event-loop thread. + test.skip('normal (obsolete pre-component-model#726 semantics)', async () => { await runCancel('normal'); await runCancel('leak-task-after-cancel'); }); - test('trap', async () => { + test.skip('trap (obsolete pre-component-model#726 semantics)', async () => { for (const mode of [ 'trap-cancel-guest-after-start-cancelled', 'trap-cancel-guest-after-return-cancelled', From 19010d560663bd7422c488cfa9572102426df8b2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 24 Sep 2026 10:43:13 +0000 Subject: [PATCH 09/11] fix(bindgen): retire cancelled tasks before entry --- .../src/intrinsics/component.rs | 11 +++++++ .../src/intrinsics/p3/async_task.rs | 33 ++++++++++++------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/js-component-bindgen/src/intrinsics/component.rs b/crates/js-component-bindgen/src/intrinsics/component.rs index fa60a7441..3f60b9c57 100644 --- a/crates/js-component-bindgen/src/intrinsics/component.rs +++ b/crates/js-component-bindgen/src/intrinsics/component.rs @@ -607,6 +607,17 @@ impl ComponentIntrinsic { }}); }} + cancelExclusiveLockWaiter(taskID) {{ + const idx = this.#lockWaiters.findIndex(waiter => waiter.taskID === taskID); + if (idx === -1) {{ return false; }} + const [waiter] = this.#lockWaiters.splice(idx, 1); + // Release the awaiting `enter()` continuation without granting + // ownership. It observes the task's resolved cancellation state + // before attempting to execute guest code. + waiter.resolve(); + return true; + }} + exclusiveRelease(taskID) {{ {debug_log_fn}('[{component_async_state_class}#exclusiveRelease()] args', {{ holder: this.#lockHolderTaskID, diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs index 3650d29aa..4da49cd25 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs @@ -958,18 +958,17 @@ impl AsyncTaskIntrinsic { throw new Error(`no current tasks for component instance [${{componentIdx}}] while ending task`); }} - if (taskID !== undefined) {{ - const last = tasks[tasks.length - 1]; - if (last.id !== taskID) {{ - // throw new Error('current task does not match expected task ID'); - return; - }} - }} + const taskIdx = taskID === undefined + ? tasks.length - 1 + : tasks.findIndex(meta => meta.id === taskID); + if (taskIdx === -1) {{ return; }} - {task_id_globals}.pop(); - {component_idx_globals}.pop(); - - const taskMeta = tasks.pop(); + const taskMeta = tasks.splice(taskIdx, 1)[0]; + const globalTaskIdx = {task_id_globals}.lastIndexOf(taskMeta.id); + if (globalTaskIdx !== -1) {{ + {task_id_globals}.splice(globalTaskIdx, 1); + {component_idx_globals}.splice(globalTaskIdx, 1); + }} return taskMeta.task; }} "#, @@ -1694,6 +1693,18 @@ impl AsyncTaskIntrinsic { if (!this.#entered) {{ this.deliverPendingCancel({{ cancellable: true }}); this.cancel(); + + const cstate = {get_or_create_async_state_fn}(this.#componentIdx); + // `enter()` may already be parked either in the scheduler's + // explicit-backpressure wait or in the exclusive-lock FIFO. + // Wake/remove that entry work now so it cannot retain a timer, + // waiter count, or lock-queue slot after cancellation. + cstate.resumeTaskByID(this.#id); + cstate.cancelExclusiveLockWaiter(this.#id); + + // No guest thread was registered, so no driver loop will call + // `exit()`. Retire the task's JS bookkeeping explicitly. + this.exit({{ skipExclusiveLockCheck: true }}); return; }} }} From 05a16e65ce7b3fa0f579657ae8858cbe31629382 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 24 Sep 2026 10:43:17 +0000 Subject: [PATCH 10/11] test(transpile): cover pre-entry cancellation cleanup --- .../test/fixtures/wast/jco/.gitignore | 3 + .../wast/jco/cancel-before-start-cleanup.wast | 98 +++++++++++++ .../jco-transpile/test/p3/cancellation.ts | 133 +++++++++++++++++- 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 packages/jco-transpile/test/fixtures/wast/jco/.gitignore create mode 100644 packages/jco-transpile/test/fixtures/wast/jco/cancel-before-start-cleanup.wast diff --git a/packages/jco-transpile/test/fixtures/wast/jco/.gitignore b/packages/jco-transpile/test/fixtures/wast/jco/.gitignore new file mode 100644 index 000000000..e3569d36f --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/jco/.gitignore @@ -0,0 +1,3 @@ +*.wast.wasm +*.wast.*.wasm +*.wast.js diff --git a/packages/jco-transpile/test/fixtures/wast/jco/cancel-before-start-cleanup.wast b/packages/jco-transpile/test/fixtures/wast/jco/cancel-before-start-cleanup.wast new file mode 100644 index 000000000..f66e9d69a --- /dev/null +++ b/packages/jco-transpile/test/fixtures/wast/jco/cancel-before-start-cleanup.wast @@ -0,0 +1,98 @@ +(component + ;; The callee exposes explicit backpressure controls around an async export. + ;; The export body is unreachable: both test paths cancel it before entry. + (component $C + (core module $M + (import "" "backpressure.inc" (func $backpressure.inc)) + (import "" "backpressure.dec" (func $backpressure.dec)) + (func (export "inc") (call $backpressure.inc)) + (func (export "dec") (call $backpressure.dec)) + (func (export "f") (result i32) unreachable) + (func (export "f-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (canon backpressure.inc (core func $backpressure.inc)) + (canon backpressure.dec (core func $backpressure.dec)) + (core instance $m (instantiate $M (with "" (instance + (export "backpressure.inc" (func $backpressure.inc)) + (export "backpressure.dec" (func $backpressure.dec)) + )))) + (func (export "inc") (canon lift (core func $m "inc"))) + (func (export "dec") (canon lift (core func $m "dec"))) + (func (export "f") async (canon lift + (core func $m "f") async (callback (core func $m "f-cb")))) + ) + (instance $c (instantiate $C)) + + (component $D + (import "inc" (func $inc)) + (import "dec" (func $dec)) + (import "f" (func $f async)) + (core module $M + (import "" "inc" (func $inc)) + (import "" "dec" (func $dec)) + (import "" "f" (func $f (result i32))) + (import "" "task.return" (func $task.return (param i32))) + (import "" "subtask.cancel" (func $subtask.cancel (param i32) (result i32))) + (import "" "subtask.drop" (func $subtask.drop (param i32))) + + (func $cancel (result i32) + (local $packed i32) + (local $subtask i32) + (local.set $packed (call $f)) + (if (i32.ne (i32.const 0 (; STARTING ;)) + (i32.and (local.get $packed) (i32.const 0xf))) + (then unreachable)) + (local.set $subtask (i32.shr_u (local.get $packed) (i32.const 4))) + (if (i32.ne (i32.const 3 (; CANCELLED_BEFORE_STARTED ;)) + (call $subtask.cancel (local.get $subtask))) + (then unreachable)) + (call $subtask.drop (local.get $subtask)) + (i32.const 42) + ) + + ;; Leaves backpressure enabled after cancellation. The runtime must still + ;; discard its suspended entry and allow the host process to become idle. + (func (export "run-persistent") (result i32) + (call $inc) + (call $task.return (call $cancel)) + (i32.const 0 (; EXIT ;))) + + ;; Clears backpressure after cancellation so repeated calls can verify + ;; that each canceled task is removed from the runtime task registry. + (func (export "run-release") (result i32) + (local $result i32) + (call $inc) + (local.set $result (call $cancel)) + (call $dec) + (call $task.return (local.get $result)) + (i32.const 0 (; EXIT ;))) + + (func (export "callback") (param i32 i32 i32) (result i32) unreachable) + ) + (canon lower (func $inc) (core func $inc')) + (canon lower (func $dec) (core func $dec')) + (canon lower (func $f) async (core func $f')) + (canon task.return (result u32) (core func $task.return)) + (canon subtask.cancel (core func $subtask.cancel)) + (canon subtask.drop (core func $subtask.drop)) + (core instance $m (instantiate $M (with "" (instance + (export "inc" (func $inc')) + (export "dec" (func $dec')) + (export "f" (func $f')) + (export "task.return" (func $task.return)) + (export "subtask.cancel" (func $subtask.cancel)) + (export "subtask.drop" (func $subtask.drop)) + )))) + (func (export "run-persistent") async (result u32) (canon lift + (core func $m "run-persistent") async (callback (core func $m "callback")))) + (func (export "run-release") async (result u32) (canon lift + (core func $m "run-release") async (callback (core func $m "callback")))) + ) + (instance $d (instantiate $D + (with "inc" (func $c "inc")) + (with "dec" (func $c "dec")) + (with "f" (func $c "f")))) + + (func (export "run-persistent") (alias export $d "run-persistent")) + (func (export "run-release") (alias export $d "run-release")) +) diff --git a/packages/jco-transpile/test/p3/cancellation.ts b/packages/jco-transpile/test/p3/cancellation.ts index 652a409bd..187e72529 100644 --- a/packages/jco-transpile/test/p3/cancellation.ts +++ b/packages/jco-transpile/test/p3/cancellation.ts @@ -1,13 +1,75 @@ +import { execArgv, execPath } from 'node:process'; +import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { suite, test, assert } from 'vitest'; +import { beforeAll, suite, test, assert } from 'vitest'; import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; import { setupAsyncTest, composeCallerCallee } from '../helpers.js'; import { LOCAL_TEST_COMPONENTS_DIR } from '../common.js'; +const CANCEL_BEFORE_START_CLEANUP_WAST = fileURLToPath( + new URL('../fixtures/wast/jco/cancel-before-start-cleanup.wast', import.meta.url), +); + +function buildWastFixture(wastPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('cargo', ['xtask', 'build-wast-fixture', wastPath], { + stdio: 'inherit', + }); + child.on('error', reject); + child.on('close', (code, signal) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error(`WAST fixture build failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`), + ); + } + }); + }); +} + +function runNodeToNaturalExit(scriptPath: string, timeoutMs = 2_000): Promise { + return new Promise((resolve, reject) => { + const child = spawn(execPath, ['--no-warnings', ...execArgv, scriptPath], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + + const timeout = setTimeout(() => { + child.kill(); + reject(new Error(`child process did not become idle within ${timeoutMs}ms\n${stdout}${stderr}`)); + }, timeoutMs); + + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('close', (code, signal) => { + clearTimeout(timeout); + if (code === 0) { + resolve(); + } else { + reject( + new Error(`child exited with ${signal ? `signal ${signal}` : `code ${code}`}\n${stdout}${stderr}`), + ); + } + }); + }); +} + suite('subtask cancellation', () => { + beforeAll(async () => { + await buildWastFixture(CANCEL_BEFORE_START_CLEANUP_WAST); + }); + // Dropping a pending async import future in a Rust guest lowers to the // `subtask.cancel` canonical built-in (wit-bindgen's cooperative // cancellation drop path). @@ -142,4 +204,73 @@ suite('subtask cancellation', () => { await cleanup(); } }); + + test('cancelling under persistent backpressure releases the scheduler', async () => { + const setup = await setupAsyncTest({ + asyncMode: 'jspi', + jco: { transpile: { extraArgs: { minify: false } } }, + component: { + path: `${CANCEL_BEFORE_START_CLEANUP_WAST}.wasm`, + skipInstantiation: true, + }, + }); + + try { + const runnerPath = join(setup.outputDir, 'cancel-before-start-runner.mjs'); + await writeFile( + runnerPath, + [ + `const { instantiate } = await import(${JSON.stringify(setup.esModuleSourcePathURL.href)});`, + 'const instance = await instantiate();', + 'const result = await instance.runPersistent();', + 'if (result !== 42) throw new Error(`unexpected result [${result}]`);', + ].join('\n'), + ); + await runNodeToNaturalExit(runnerPath); + } finally { + await setup.cleanup(); + } + }); + + test('cancelling before start retires each callee task', async () => { + const setup = await setupAsyncTest({ + asyncMode: 'jspi', + jco: { transpile: { extraArgs: { minify: false } } }, + component: { + path: `${CANCEL_BEFORE_START_CLEANUP_WAST}.wasm`, + skipInstantiation: true, + }, + }); + const taskCountKey = '__jcoCancelBeforeStartLiveTaskCount'; + const testGlobal = globalThis as typeof globalThis & Record number>; + + try { + const source = await readFile(setup.esModuleOutputPath, 'utf8'); + const taskMapDeclaration = 'const ASYNC_TASKS_BY_COMPONENT_IDX = new Map();'; + const instrumented = source.replace( + taskMapDeclaration, + `${taskMapDeclaration}\n` + + `globalThis.${taskCountKey} = () => ` + + 'Array.from(ASYNC_TASKS_BY_COMPONENT_IDX.values(), tasks => tasks.length)' + + '.reduce((total, count) => total + count, 0);', + ); + assert.notEqual(instrumented, source, 'failed to install the runtime task-count probe'); + await writeFile(setup.esModuleOutputPath, instrumented); + + const instrumentedUrl = new URL(setup.esModuleSourcePathURL); + instrumentedUrl.searchParams.set('task-cleanup-probe', String(Date.now())); + const { instantiate } = await import(instrumentedUrl.href); + const instance = await instantiate(); + + for (let i = 0; i < 3; i++) { + assert.equal(await instance.runRelease(), 42); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(testGlobal[taskCountKey](), 0); + } finally { + delete testGlobal[taskCountKey]; + await setup.cleanup(); + } + }); }); From 3b13cfbd637150c50016743ccf300c5e178de066 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 24 Sep 2026 11:41:13 +0000 Subject: [PATCH 11/11] test(transpile): compile cancellation fixture in process --- .../jco-transpile/test/p3/cancellation.ts | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/jco-transpile/test/p3/cancellation.ts b/packages/jco-transpile/test/p3/cancellation.ts index 187e72529..d64ed96f1 100644 --- a/packages/jco-transpile/test/p3/cancellation.ts +++ b/packages/jco-transpile/test/p3/cancellation.ts @@ -1,38 +1,21 @@ import { execArgv, execPath } from 'node:process'; import { spawn } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { beforeAll, suite, test, assert } from 'vitest'; +import { afterAll, beforeAll, suite, test, assert } from 'vitest'; import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; -import { setupAsyncTest, composeCallerCallee } from '../helpers.js'; +import { parse } from '../../src/wasm-tools.js'; +import { setupAsyncTest, composeCallerCallee, getTmpDir } from '../helpers.js'; import { LOCAL_TEST_COMPONENTS_DIR } from '../common.js'; const CANCEL_BEFORE_START_CLEANUP_WAST = fileURLToPath( new URL('../fixtures/wast/jco/cancel-before-start-cleanup.wast', import.meta.url), ); -function buildWastFixture(wastPath: string): Promise { - return new Promise((resolve, reject) => { - const child = spawn('cargo', ['xtask', 'build-wast-fixture', wastPath], { - stdio: 'inherit', - }); - child.on('error', reject); - child.on('close', (code, signal) => { - if (code === 0) { - resolve(); - } else { - reject( - new Error(`WAST fixture build failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`), - ); - } - }); - }); -} - function runNodeToNaturalExit(scriptPath: string, timeoutMs = 2_000): Promise { return new Promise((resolve, reject) => { const child = spawn(execPath, ['--no-warnings', ...execArgv, scriptPath], { @@ -66,8 +49,20 @@ function runNodeToNaturalExit(scriptPath: string, timeoutMs = 2_000): Promise { + let cancelBeforeStartCleanupWasm: string; + let fixtureDir: string; + beforeAll(async () => { - await buildWastFixture(CANCEL_BEFORE_START_CLEANUP_WAST); + fixtureDir = await getTmpDir(); + cancelBeforeStartCleanupWasm = join(fixtureDir, 'cancel-before-start-cleanup.wasm'); + await writeFile( + cancelBeforeStartCleanupWasm, + await parse(await readFile(CANCEL_BEFORE_START_CLEANUP_WAST, 'utf8')), + ); + }); + + afterAll(async () => { + await rm(fixtureDir, { recursive: true, force: true }); }); // Dropping a pending async import future in a Rust guest lowers to the @@ -210,7 +205,7 @@ suite('subtask cancellation', () => { asyncMode: 'jspi', jco: { transpile: { extraArgs: { minify: false } } }, component: { - path: `${CANCEL_BEFORE_START_CLEANUP_WAST}.wasm`, + path: cancelBeforeStartCleanupWasm, skipInstantiation: true, }, }); @@ -237,7 +232,7 @@ suite('subtask cancellation', () => { asyncMode: 'jspi', jco: { transpile: { extraArgs: { minify: false } } }, component: { - path: `${CANCEL_BEFORE_START_CLEANUP_WAST}.wasm`, + path: cancelBeforeStartCleanupWasm, skipInstantiation: true, }, });