diff --git a/examples/build.sh b/examples/build.sh index fdd4ec9..b4ffa47 100755 --- a/examples/build.sh +++ b/examples/build.sh @@ -20,7 +20,7 @@ TARGET=wasm32-unknown-unknown BUILD_DIR=guests/build export CARGO_TARGET_DIR="$PWD/guests/target" -GUESTS="hello values resources async-probe yield-only context-user backpressure-probe stream-echo stream-pass future-user future-import resource-stream tcp-echo http-fetch test-suite fs-probe net-probe" +GUESTS="hello values resources async-probe yield-only context-user backpressure-probe stream-echo stream-pass future-user future-import resource-stream tcp-echo http-fetch test-suite fs-probe net-probe cancel-import" # Most guests are pure computational reactors on wasm32-unknown-unknown; # fs-probe and net-probe build for wasm32-wasip2 ON PURPOSE — std::fs / @@ -37,7 +37,7 @@ target_for() { # CM 0.3 async guests additionally need the cm-async feature). features_for() { case "$1" in - async-probe|yield-only|context-user|backpressure-probe|stream-echo|stream-pass|future-user|future-import|resource-stream|tcp-echo|http-fetch|test-suite) + async-probe|yield-only|context-user|backpressure-probe|stream-echo|stream-pass|future-user|future-import|resource-stream|tcp-echo|http-fetch|test-suite|cancel-import) echo "component-model,cm-async" ;; *) echo "component-model" ;; esac diff --git a/examples/guests/cancel-import/Cargo.lock b/examples/guests/cancel-import/Cargo.lock new file mode 100644 index 0000000..aa25d46 --- /dev/null +++ b/examples/guests/cancel-import/Cargo.lock @@ -0,0 +1,424 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "guest-cancel-import" +version = "0.1.0" +dependencies = [ + "futures", + "wit-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01df5f3b4ca7881e843f3bc0fb8a3905d79c68692250dcb8e33e698705ccdb6" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d" +dependencies = [ + "bitflags", + "futures", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3fe9b9a0066f82fd0c2f07f79d0ffc0f85c53fa5f998516d8722712479042" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d689c4bc9d6af067c651cfba444766343c9a4bdef15fad1e2efab95c0c277af0" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e65bb94c369b3c4741ce3d1d2704b1fec93db7c540df0e521a097e7ceeb5be" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1655131e4f7d3f0cb141f6eca71315ca40eff0f3d4de7cff0a82bacedd8c89b4" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/guests/cancel-import/Cargo.toml b/examples/guests/cancel-import/Cargo.toml new file mode 100644 index 0000000..4cd2d50 --- /dev/null +++ b/examples/guests/cancel-import/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "guest-cancel-import" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { version = "=0.60.0", features = ["async-spawn", "inter-task-wakeup"] } +futures = { version = "0.3", default-features = false, features = ["async-await"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = "debuginfo" + +# Opt out of the repository's root cargo workspace. +[workspace] diff --git a/examples/guests/cancel-import/src/lib.rs b/examples/guests/cancel-import/src/lib.rs new file mode 100644 index 0000000..c402572 --- /dev/null +++ b/examples/guests/cancel-import/src/lib.rs @@ -0,0 +1,96 @@ +//! `cancel-import` guest: the issue #239 corpus. +//! +//! Every export here models one route into the same runtime defect: two +//! `driveAsync` loops live on one store while one of them is parked in its +//! awaiting-race. That race used to hold a store-wide scheduling gate +//! (`Store.pendingResumptions`) for as long as the HOST took to answer, so the +//! second loop spun at its own top of loop and died in ~311ms on +//! +//! driveAsync: a resumed-activation claim was never released +//! +//! `ping` is the cheap health poll whose failure surfaces that. +//! +//! Two ways to get a second driver, both here: an overlapping export call +//! (`block-for` + `ping`), and a DETACHED guest task — `spawn_local` work is +//! polled inside the exporting component-model task, which outlives the +//! export's `task.return`, so it runs while the host is idle and only the +//! settlement pump is driving. +//! +//! Two ways for such a task to park its wasm frame mid-activation, which is +//! what puts a thread in the race: a sync-typed host import that suspends +//! (`block`), and — the shape actually reported — dropping an in-flight async +//! import future. That drop is wit-bindgen's specified cancellation path: the +//! `Drop` impl on `WaitableOperation` (`rt/async_support/waitable.rs`) issues +//! the component model's `subtask.cancel`, synchronously, because "that's the +//! only way for this to be sound" in Rust. + +wit_bindgen::generate!({ + world: "cancel-import", +}); + +use wit_bindgen::rt::async_support::spawn_local; + +struct Component; + +impl Guest for Component { + /// The reported shape, minimized: ONE async import, polled once so the + /// subtask is genuinely in flight, dropped later from a detached task. + /// No select, no sockets, no retry loop — one drop suffices. + async fn start_poll_drop(hold_ms: u64, drop_after_ms: u64) { + spawn_local(async move { + let mut held = Box::pin(sleep(hold_ms)); + // One poll starts the subtask: STARTED, handle live, host promise + // outstanding. (`Box::pin`, not `pin!`: dropping a `Pin<&mut _>` + // drops the pointer, and the cancellation never happens.) + let _ = futures::poll!(held.as_mut()); + // Come back later, with the export call long since returned and + // the host otherwise idle. + sleep(drop_after_ms).await; + // THE DROP: `subtask.cancel` on an import still in flight. + drop(held); + // Outlive the drop, so this is an in-task cancellation rather than + // whole-task teardown — teardown drops are routine and harmless. + sleep(hold_ms).await; + }); + } + + /// The same cancellation reached the way real code reaches it: a timeout + /// race in a detached task, where the loser is dropped in flight. + async fn start_race_drop(slow_ms: u64, fast_ms: u64) { + spawn_local(async move { + { + let slow = Box::pin(sleep(slow_ms)); + let fast = Box::pin(sleep(fast_ms)); + futures::future::select(slow, fast).await; + // The loser is dropped at the end of this scope. + } + sleep(slow_ms).await; + }); + } + + /// Control: the same mid-frame park with NO cancellation anywhere. Pins + /// that the defect is about concurrent drivers, not about `subtask.cancel`. + async fn start_block(ms: u64) { + spawn_local(async move { + // Yield through an async import first so `task.return` happens and + // the export call finishes: the park below must land with no + // export call outstanding. + sleep(300).await; + block(ms); + }); + } + + /// The reduced form: an ordinary export call that parks mid-frame. Call it + /// without awaiting and poll `ping` alongside, and there are two export + /// drivers on one store with no detached task in sight. + async fn block_for(ms: u64) { + block(ms); + } + + /// The health poll. Cheap, synchronous, and unrelated to everything above. + fn ping() -> u32 { + 42 + } +} + +export!(Component); diff --git a/examples/guests/cancel-import/wit/world.wit b/examples/guests/cancel-import/wit/world.wit new file mode 100644 index 0000000..7d1d01f --- /dev/null +++ b/examples/guests/cancel-import/wit/world.wit @@ -0,0 +1,38 @@ +package polyengine:cancel-import; + +/// The issue #239 corpus: a store with two live `driveAsync` loops, one of +/// them parked in its awaiting-race. Every export is one route into that +/// state; `ping` is the health poll whose failure surfaces the wedge. +/// +/// The reported route was a detached (`spawn_local`) guest task dropping an +/// in-flight async-import future — wit-bindgen's specified cancellation path, +/// which issues the component model's `subtask.cancel`. `block-for` shows the +/// same defect with no cancellation and no detached task at all. +world cancel-import { + /// Host timer, async-typed: resolves after `ms` milliseconds. Its subtask + /// is the thing `start-poll-drop` and `start-race-drop` cancel. + import sleep: async func(ms: u64); + + /// Host timer, SYNC-typed, implemented with `suspending()`: calling it + /// parks the calling wasm frame mid-activation until the Promise settles. + import block: func(ms: u64); + + /// Start `sleep`, poll it once so the subtask is genuinely in flight, wait, + /// then DROP it from a detached task — a cancellation issued while the host + /// is idle. The minimal form of the reported shape. + export start-poll-drop: async func(hold-ms: u64, drop-after-ms: u64); + + /// The same cancellation via a timeout race: two `sleep`s, loser dropped. + export start-race-drop: async func(slow-ms: u64, fast-ms: u64); + + /// Control with no cancellation anywhere: a detached task that parks + /// mid-frame on `block` after the export call has already returned. + export start-block: async func(ms: u64); + + /// The reduced form: an ordinary export call that parks mid-frame. Two + /// concurrent export calls are enough on their own. + export block-for: async func(ms: u64); + + /// Cheap health poll: the export whose failure surfaces the wedge. + export ping: func() -> u32; +} diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 06e6841..df4f3d5 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -557,7 +557,9 @@ export async function driveStoreAsync( * have always produced concurrent loops, and the host-stream pump's stand-down * below is cooperative, so a *bounded overlap window* remains by construction * (an export call can start while the pump is parked mid-`await`; the pump - * only notices at its next `done()` evaluation). The invariant is: + * notices at its next `done()` evaluation, which the driver-arrival one-shot + * below now makes prompt — before issue #239 it was "whenever the host happens + * to answer", i.e. not bounded at all). The invariant is: * * **no activation is resumed twice for one settlement, and no activation is * resumed with a value from a settlement it has already consumed.** @@ -628,6 +630,53 @@ export function whenStoreDriverIdle(store: Store): Promise { return w.p; } +// --------------------------------------------------------------------------- +// Driver arrival: closing the overlap window (issue #239) +// --------------------------------------------------------------------------- +// +// The stand-down above ("the pumps are *fallback* drivers") is evaluated only +// at a driver's next `done()`, so the doc's "bounded overlap window" is really +// bounded by whatever the incumbent driver is parked on — and its longest park +// is `Promise.race([...parked tags, ...pendingHostCalls])`, i.e. HOST-CONTROLLED +// time. That is a stall in its own right, and it is fatal in combination with +// the SPECULATIVE resume entry the race holds: `Store.pendingResumptions` is a +// store-wide scheduling gate, so a second driver on the same store spins at +// `driveAsync`'s top and dies at the 10,000-hop internal-bug assert in ~311ms +// (issue #239 — the same-store half of the cross-store stall #210 fixed; see +// `tests/cross_store_driver_test.ts`, whose header describes this gate being +// "held for the entire duration of a guest's wait on a slow host import"). +// +// So drivers announce themselves: every `driveAsync` that finds itself the +// second (or later) loop on a store fires this one-shot, which every driver +// races alongside its parked tags. The incumbent wakes within a microtask, +// drops the speculative entry on its way out of the race, and re-evaluates +// `done()` — which is exactly the stand-down the pumps were always supposed to +// perform, now prompt instead of "whenever the host happens to answer". +const driverArrivals = new WeakMap; r: () => void }>(); + +/** A one-shot that resolves (to `null`, the race's "nothing settled" value) + * when another driver starts on `store`. */ +function armDriverArrival(store: Store): Promise { + let n = driverArrivals.get(store); + if (n === undefined) { + let r!: () => void; + const p = new Promise((res) => (r = () => res(null))); + n = { p, r }; + driverArrivals.set(store, n); + } + return n.p; +} + +function fireDriverArrival(store: Store): void { + const n = driverArrivals.get(store); + if (n === undefined) return; + // Deleted before resolving so the next `armDriverArrival` mints a fresh, + // unresolved one-shot: a driver that wakes on this and re-parks must not + // pick the settled promise back up and spin. + driverArrivals.delete(store); + n.r(); +} + // --------------------------------------------------------------------------- // The settlement pump: liveness between export calls // --------------------------------------------------------------------------- @@ -782,7 +831,13 @@ async function driveAsync( done: () => boolean, what: string, ): Promise { - driverDepth.set(store, storeDriverDepth(store) + 1); + const depth = storeDriverDepth(store) + 1; + driverDepth.set(store, depth); + // An incumbent driver may be parked in the awaiting-race holding the + // speculative resume entry — a store-wide gate this loop would otherwise + // spin on until the 10,000-hop assert (issue #239). Announce ourselves so it + // stands down within a microtask. + if (depth > 1) fireDriverArrival(store); try { let claimHops = 0; for (;;) { @@ -1026,9 +1081,14 @@ async function driveAsync( // Every awaiting thread's settle is deferred on a non-enterable // instance. The way out is the lock holder finishing, and the only // await-spanning host-entry lock is the async-dtor bracket, which - // registers in `pendingHostCalls` — so park on those. + // registers in `pendingHostCalls` — so park on those, plus the + // driver-arrival one-shot: every park in this loop races it, so the + // stand-down below is prompt wherever we happen to be waiting. if (store.pendingHostCalls.size > 0) { - await Promise.race([...store.pendingHostCalls]).catch(() => {}); + await Promise.race([ + ...store.pendingHostCalls, + armDriverArrival(store), + ]).catch(() => {}); continue; } // Per the issue #156 analysis this is unreachable (a spanning lock @@ -1061,12 +1121,46 @@ async function driveAsync( // await — which takes a fresh entry of its own — had that entry // clobbered early, re-opening the window it exists to close. With a set // we can name exactly what we added. - store.addPendingResumption(chosen); + // + // SOLE DRIVER ONLY, AND ONLY UNTIL ONE ARRIVES (issue #239). The entry + // is a claim over a window this loop cannot bound: the race settles when + // the HOST answers, which may be never. As a store-wide scheduling gate + // (`Store.tick` refuses; every driver yields at its top) that is a wedge + // the moment a second driver exists — it spins at the top of its own + // loop and dies at the 10,000-hop assert in ~311ms, an internal-bug + // detector firing on a perfectly ordinary suspended guest. Two concurrent + // export calls with one slow suspending import were enough; the reported + // shape was a detached guest task cancelling an in-flight import, which + // parks mid-frame with no export call outstanding and leaves the + // settlement pump holding this entry. + // + // What the entry protects — "the engine may run `chosen`'s wasm during + // this await" — it protects by refusing OTHER `Store.tick` callers, and + // this loop is not one of them while it awaits. The tick callers that + // can reach a store mid-race are another `driveAsync` loop and + // `HostActivity.pump`'s synchronous drain (exec/host_streams.ts) — the + // latter is not gated by driver depth, so scoping the entry to "sole + // driver" does hand it a window the entry used to close at depth >= 2. + // What holds regardless is the invariant the `driverDepth` note names: + // a genuine resumption is preceded by `SuspensionPoint.resume`'s OWN + // entry (jspi/bridge.ts, minted before the settle), and every + // resumption site here re-checks membership, promise identity and + // `dispatchableTail` synchronously — mechanisms (a) and (b), which is + // where that note already puts the weight. + const sole = storeDriverDepth(store) === 1; + if (sole) store.addPendingResumption(chosen); let winner: AwaitWinner | null; try { - winner = await Promise.race([chosenTag, ...others]); + // `armDriverArrival` rides the race for every driver, not just the one + // holding the entry: waking on a new arrival is also how a fallback + // pump reaches its next `done()` — i.e. its stand-down — promptly. + winner = await Promise.race([ + chosenTag, + ...others, + armDriverArrival(store), + ]); } finally { - store.removePendingResumption(chosen); + if (sole) store.removePendingResumption(chosen); } // Resume whichever thread actually settled -- not necessarily the one we // claimed. Resuming only the claimed thread would spin: its promise may @@ -1108,7 +1202,18 @@ async function driveAsync( // not ours — this is genuine, unavoidable nondeterminism at the boundary // (the reference has the same freedom in `Store.tick`). Everything // *inside* the component stays deterministic per scheduler.ts. - await Promise.race([...store.pendingHostCalls]).catch(() => {}); + // + // The driver-arrival one-shot rides here too. This is the routine park of + // a quiet guest with a real host call outstanding — no speculative entry + // is held, so there is no wedge to break, but a fallback pump parked here + // would otherwise not reach its `done()` (i.e. its stand-down) until the + // HOST answered, leaving two loops interleaving `serviceSettled`/`tick` + // for that whole window. That interleaving is what the `driverDepth` note + // above calls out as bad for throughput and blame. + await Promise.race([ + ...store.pendingHostCalls, + armDriverArrival(store), + ]).catch(() => {}); } } finally { const left = storeDriverDepth(store) - 1; diff --git a/runtime/tests/integration/e2e_cancel_import_test.ts b/runtime/tests/integration/e2e_cancel_import_test.ts new file mode 100644 index 0000000..82c9c9c --- /dev/null +++ b/runtime/tests/integration/e2e_cancel_import_test.ts @@ -0,0 +1,242 @@ +// Issue #239 end-to-end regression: `driveAsync` (runtime/src/exec/boundary.ts) +// used to hold a store-wide scheduling gate (`Store.pendingResumptions`) +// across an await bounded only by the HOST's answer, whenever two drivers +// were live on the same store and one was parked in its awaiting-race. The +// second driver spun at the top of its own loop and died in ~311ms with: +// +// driveAsync: a resumed-activation claim was never released (the +// activation neither parked, finished, nor trapped) +// +// `runtime/tests/same_store_driver_test.ts` pins the store-level unit shape +// of the fix. This file is the end-to-end proof against a real wit-bindgen +// guest (`examples/guests/cancel-import`), covering every shape the guest +// models: two concurrent export calls, a detached task parked mid-frame with +// no export call outstanding, and a detached task cancelling an in-flight +// async import (`subtask.cancel` via wit-bindgen's drop-to-cancel path). +// +// Requires build artifacts (both produced from source in this repo): +// - target/wasm32-unknown-unknown/release/translator_shim.wasm +// cargo build -p translator-shim --release --target wasm32-unknown-unknown +// - examples/guests/build/cancel-import.component.wasm +// ./examples/build.sh + +import { assertEq } from "../support/asserts.ts"; +import { Translator } from "../../src/shim/mod.ts"; +import { instantiateComponent } from "../../src/exec/mod.ts"; +import { suspending } from "@polyengine/protocol"; + +const root = new URL("../../../", import.meta.url); + +async function readArtifact(rel: string, hint: string): Promise { + try { + return await Deno.readFile(new URL(rel, root)); + } catch { + throw new Error(`missing build artifact ${rel} — run: ${hint}`); + } +} + +const shimWasm = await readArtifact( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + "cargo build -p translator-shim --release --target wasm32-unknown-unknown", +); +const guestWasm = await readArtifact( + "examples/guests/build/cancel-import.component.wasm", + "./examples/build.sh", +); + +const translator = await Translator.create(shimWasm); +const { plan, adapters } = translator.translate(guestWasm); + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function instantiate() { + const imports = { + // Plain async import: a Promise settles through the task core with no + // JSPI involved. + sleep: (ms: bigint) => delay(Number(ms)), + // Sync-typed import wrapped in `suspending()` (contracts/embedder-api.md + // §"Functions and async" amendment A1): calling it parks the guest's + // wasm frame mid-activation until the Promise settles — the #239 A1 + // park shape. + block: suspending((ms: bigint) => delay(Number(ms))), + }; + return await instantiateComponent({ + plan, + componentBytes: guestWasm, + adapters, + imports, + }); +} + +// deno-lint-ignore no-explicit-any +type Exports = any; + +const WEDGE_ASSERTION = + "driveAsync: a resumed-activation claim was never released " + + "(the activation neither parked, finished, nor trapped)"; + +async function assertPing(e: Exports, where: string): Promise { + let value: unknown; + try { + value = await e.ping(); + } catch (err) { + throw new Error( + `issue #239 regression at ${where}: ping() threw instead of ` + + `resolving to 42 — this is exactly the wedge described by the ` + + `assertion "${WEDGE_ASSERTION}". Original error: ${err}`, + ); + } + assertEq( + value, + 42, + `issue #239 regression at ${where}: ping() resolved to ${value}, not ` + + `42 — the store is wedged (see "${WEDGE_ASSERTION}")`, + ); +} + +// "Slow" host-import duration shared by every test below. Must comfortably +// outlast a single ping poll (started at +50ms) plus the ~311ms/10,000-hop +// spin the pre-fix wedge needs to die — 1000ms leaves ~650ms of margin over +// that spin once the first poll lands, which the negative control below +// confirms is still enough to trip the assertion. +const SLOW = 1000; + +Deno.test( + "cancel-import #239: two concurrent export calls — blockFor + ping polls", + async () => { + const component = await instantiate(); + const e = component.exports as Exports; + + // Start a slow, mid-frame-parking export call WITHOUT awaiting it: this + // is the reduced form of the wedge — a second driver (ping) must be able + // to make progress on the same store while the first driver (blockFor) + // is parked awaiting the host's answer. + // + // Timeline (relative to this call): block-for(SLOW) parks the guest + // frame on `block` from t=0 to t=SLOW=1000. `blockFor` is awaited below, + // so nothing outlives this test — no arithmetic needed beyond "await it". + const slow = e["block-for"](BigInt(SLOW)) as Promise; + + for (let i = 0; i < 5; i++) { + await delay(50); + await assertPing(e, `poll ${i} while blockFor(${SLOW}) is outstanding`); + } + + // Let the slow call finish so nothing leaks past the test. + await slow; + }, +); + +Deno.test( + "cancel-import #239: detached task parks mid-frame, no export call outstanding", + async () => { + const component = await instantiate(); + const e = component.exports as Exports; + + // `start-block` spawns a detached task and returns almost immediately + // (the spawn does not block the export's own completion) — call that + // t=0. Per src/lib.rs `start_block`: the detached task first + // `sleep(300).await`s, THEN calls `block(SLOW)`, parking the guest + // frame from t=300 to t=300+SLOW=1300. The detached task ends at t=1300. + // + // The polling window must straddle t=300 — polls that only run before + // the park (as an earlier revision of this test did, +50..+250ms) never + // observe the parked-with-no-export-call-outstanding shape at all. Poll + // every 50ms from +50ms out to +700ms (14 polls) so several land solidly + // inside the parked window (300..1300). + await e["start-block"](BigInt(SLOW)); + + for (let i = 0; i < 14; i++) { + await delay(50); + const elapsed = (i + 1) * 50; + await assertPing( + e, + `poll ${i} at t+${elapsed}ms after start-block(${SLOW}) ` + + `(parked window is 300..1300)`, + ); + } + + // 14 polls * 50ms = 700ms elapsed. Remaining guest time: 1300 - 700 = + // 600ms. Wait 750ms (150ms margin) so the detached task has provably + // ended before the test returns. + await delay(750); + }, +); + +Deno.test( + "cancel-import #239: detached task cancels an in-flight import (subtask.cancel)", + async () => { + const component = await instantiate(); + const e = component.exports as Exports; + + // `start-poll-drop(hold, dropAfter)` spawns a detached task and returns + // almost immediately — call that t=0. Per src/lib.rs `start_poll_drop`: + // t=0: sleep(hold) starts (S1), polled once so it is genuinely + // in flight. + // t=dropAfter: the task wakes and DROPS S1's future -> `subtask.cancel`. + // This runtime's host-import subtasks have a no-op + // `on_cancel` (runtime/src/exec/boundary.ts, search + // `subtask.onCancel = () => {}`), so the cancel does NOT + // return promptly — it parks the guest until S1 resolves + // NATURALLY, i.e. at t=hold. + // t=hold: the cancel returns; the task then runs its tail + // `sleep(hold)`. + // t=2*hold: the detached task finally ends. + // With hold=SLOW=1000, dropAfter=100: the detached task ends at t=2000. + const dropAfter = 100; + await e["start-poll-drop"](BigInt(SLOW), BigInt(dropAfter)); + + for (let i = 0; i < 5; i++) { + await delay(50); + await assertPing(e, `poll ${i} while start-poll-drop's subtask is live`); + } + + // `dropAfter` (100ms) has elapsed by ~250ms in; keep polling across the + // cancellation point. 10 polls total * 50ms = 500ms elapsed. + for (let i = 0; i < 5; i++) { + await delay(50); + await assertPing(e, `poll ${i} after start-poll-drop's cancel point`); + } + + // Remaining guest time: 2*SLOW - 500 = 1500ms. Wait 1650ms (150ms + // margin) so the detached task has provably ended before the test + // returns. + await delay(1650); + }, +); + +Deno.test( + "cancel-import #239: detached task races two imports, drops the loser", + async () => { + const component = await instantiate(); + const e = component.exports as Exports; + + // `start-race-drop(slow, fast)` spawns a detached task and returns + // almost immediately — call that t=0. Per src/lib.rs `start_race_drop`: + // t=0: both sleep(slow) and sleep(fast) start via + // `futures::future::select`. + // t=fast: the fast sleep wins the select; the still-in-flight slow + // future (the loser) is dropped at the end of its scope -> + // `subtask.cancel`. Same no-op-`on_cancel` defect as + // start-poll-drop above: the cancel parks the guest until + // the loser resolves NATURALLY, at t=slow. + // t=slow: the cancel returns; the task then runs its tail + // `sleep(slow)`. + // t=2*slow: the detached task finally ends. + // With slow=SLOW=1000, fast=100: the detached task ends at t=2000. + const fast = 100; + await e["start-race-drop"](BigInt(SLOW), BigInt(fast)); + + for (let i = 0; i < 5; i++) { + await delay(50); + await assertPing(e, `poll ${i} while start-race-drop is racing/dropping`); + } + + // 5 polls * 50ms = 250ms elapsed. Remaining guest time: 2*SLOW - 250 = + // 1750ms. Wait 1900ms (150ms margin) so the detached task has provably + // ended before the test returns. + await delay(1900); + }, +); diff --git a/runtime/tests/same_store_driver_test.ts b/runtime/tests/same_store_driver_test.ts new file mode 100644 index 0000000..cb711fc --- /dev/null +++ b/runtime/tests/same_store_driver_test.ts @@ -0,0 +1,315 @@ +// Concurrent drivers on the SAME store (issue #239). +// +// The same-store half of #210. #210 made the driver's speculative resume +// entry per-store (`Store.pendingResumptions`), which stopped an unrelated +// store's driver from spinning on it — see `tests/cross_store_driver_test.ts`, +// whose header describes the entry as "held for the entire duration of a +// guest's wait on a slow host import". It left the same wedge intact for a +// SECOND driver on the store actually doing the waiting, which is the ordinary +// shape (two overlapping export calls; a detached guest task cancelling an +// in-flight import while the settlement pump holds the entry). +// +// Mechanism: `driveAsync` (exec/boundary.ts) takes the speculative entry and +// holds it across `Promise.race([chosenTag, ...others])`, where `others` +// includes `store.pendingHostCalls` — a window bounded only by when the HOST +// answers, i.e. possibly never. `Store.pendingResumptions` is a store-wide +// scheduling gate: `Store.tick()` returns false while it is non-empty +// (task/scheduler.ts), and every `driveAsync` yields at its top while +// `store.hasPendingResumptions()` holds, under a 10,000-hop bound that fires +// +// assert_(claimHops < 10_000, "driveAsync: a resumed-activation claim was +// never released (the activation neither parked, finished, nor trapped)") +// +// So the second driver died in ~311ms — an internal-bug detector firing on a +// perfectly ordinary suspended guest. +// +// The fix (boundary.ts, "Driver arrival: closing the overlap window"): the +// entry is taken only by the SOLE driver (`storeDriverDepth(store) === 1`), +// and `armDriverArrival(store)` rides the race so an incumbent wakes within a +// microtask when another driver arrives, drops the entry, and re-evaluates +// `done()`. +// +// These tests pin all three halves: the second driver returns promptly (1), +// the gate still gates when nobody else wants the store (2), and the entry is +// actually dropped while a second driver is live (3). +// +// Scaffolding style: cross_store_driver_test.ts / settlement_pump_test.ts +// (fake inst/threads, real Store, real driveStoreAsync). + +import { driveStoreAsync } from "../src/exec/mod.ts"; +import { Store } from "../src/task/mod.ts"; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} + +function fakeInst() { + return { + mayEnterFrom: (_: unknown) => true, + enterFrom: (_: unknown) => {}, + leaveTo: (_: unknown) => {}, + }; +} + +/** A thread parked on an awaitValue promise, as a promising-wrapped guest + * activation suspended on a host import is. */ +function awaitingThread(store: Store, p: Promise) { + const t = { + awaiting: p as Promise | null, + task: { inst: fakeInst() }, + ready: () => false, + waiting: () => false, + resume: () => {}, + resumeWith(_v: unknown, _f?: { error: unknown }) { + t.awaiting = null; + store.awaiting.delete(t); + }, + }; + store.noteAwaiting(t, p); + return t; +} + +function hostImport(store: Store, settle: Promise): void { + const p: Promise = settle.then(() => { + store.pendingHostCalls.delete(p); + }); + store.pendingHostCalls.add(p); +} + +/** Poll `cond` until true or the deadline passes; returns whether it held. + * Macrotask hops, so the drivers under observation get to run. */ +async function waitFor(cond: () => boolean, ms: number): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 >= ms) return false; + await new Promise((r) => setTimeout(r, 1)); + } + return true; +} + +// The regression. Driver A dwells on a slow host import; driver B arrives on +// the SAME store with nothing to do. Pre-fix B spun at the top of its own loop +// against A's speculative entry and died at the hop bound in ~311ms. +Deno.test("a second driver on the same store is not wedged by the incumbent's speculative entry (#239)", async () => { + const store = new Store(); + let settleThread!: (v: unknown) => void; + let settleHost!: (v: unknown) => void; + const threadP = new Promise((r) => (settleThread = r)); + const hostP = new Promise((r) => (settleHost = r)); + let aDone = false; + let aDriver: Promise = Promise.resolve(); + try { + // A guest suspended on a host import that never answers. + awaitingThread(store, threadP); + hostImport(store, hostP); + aDriver = driveStoreAsync(store, () => aDone, "A: dweller").catch((e) => e); + + // Wait until A has reached its race holding the entry. A is the sole + // driver here, so `sole` is true and the entry IS taken — the predicate is + // as good a "A is parked in the race" signal as it is in the cross-store + // test (and it is the exact state the bug needs). + assert( + await waitFor(() => store.hasPendingResumptions(), 2000), + "A's driver never took its entry", + ); + + // Driver B: same store, immediately done. It must not consult A's entry. + const tB = Date.now(); + let threw: unknown = null; + try { + await driveStoreAsync(store, () => true, "B: arriving"); + } catch (e) { + threw = e; + } + const elapsed = Date.now() - tB; + // Name the bug in the failure report: pre-fix this is the claimHops + // AssertionError, not some generic rejection. + assert( + threw === null, + `B's driver must not throw; got: ${String(threw)}` + + (String(threw).includes("resumed-activation claim") + ? " -- this is the #239 wedge: the incumbent's speculative " + + "Store.pendingResumptions entry gated B's whole loop" + : ""), + ); + // Pre-fix: ~311ms (10,000 hops) ending in that assert. The bound is loose + // on purpose — the property is "promptly, not gated on A's host" — and it + // is the throw above that carries the regression; this only pins that B + // cannot instead be made to dwell for the host's own duration. + assert(elapsed < 2000, `B's driver returned in ${elapsed}ms, expected < 2s`); + } finally { + aDone = true; + settleThread(0); + settleHost(0); + await aDriver; + store.pendingResumptions.clear(); + } +}); + +// CONTROL, mirroring the tail of cross_store_driver_test.ts: the fix narrows +// WHO may hold the speculative entry, it does not delete it. With A the sole +// driver parked in its race, the gate is held and its own store refuses to +// schedule past it. +Deno.test("the speculative entry still gates when the incumbent is the sole driver (#239 control)", async () => { + const store = new Store(); + let settleThread!: (v: unknown) => void; + let settleHost!: (v: unknown) => void; + const threadP = new Promise((r) => (settleThread = r)); + const hostP = new Promise((r) => (settleHost = r)); + let aDone = false; + let aDriver: Promise = Promise.resolve(); + try { + awaitingThread(store, threadP); + hostImport(store, hostP); + aDriver = driveStoreAsync(store, () => aDone, "A: dweller").catch((e) => e); + + assert( + await waitFor(() => store.hasPendingResumptions(), 2000), + "A's driver never took its entry", + ); + assert(store.hasPendingResumptions(), "A's entry is still pending"); + assert(store.tick() === false, "the gate still refuses to schedule"); + } finally { + aDone = true; + settleThread(0); + settleHost(0); + await aDriver; + store.pendingResumptions.clear(); + } +}); + +// The mechanism of the fix, observed directly: while a second driver is live, +// nobody holds the store-wide gate. A wakes on `armDriverArrival`, drops the +// entry in the race's `finally`, and re-parks with `sole === false`. +// +// B is kept ALIVE across the observation (its `done` stays false until we have +// looked) deliberately: once B exits, A is sole again and legitimately retakes +// the entry, so a post-hoc read would be a race against A's next lap. +Deno.test("the speculative entry is dropped while a second driver is live on the store (#239)", async () => { + const store = new Store(); + let settleThread!: (v: unknown) => void; + let settleHost!: (v: unknown) => void; + const threadP = new Promise((r) => (settleThread = r)); + const hostP = new Promise((r) => (settleHost = r)); + let aDone = false; + let bDone = false; + let aDriver: Promise = Promise.resolve(); + let bDriver: Promise = Promise.resolve(); + try { + awaitingThread(store, threadP); + hostImport(store, hostP); + aDriver = driveStoreAsync(store, () => aDone, "A: dweller").catch((e) => e); + + assert( + await waitFor(() => store.hasPendingResumptions(), 2000), + "A's driver never took its entry", + ); + + bDriver = driveStoreAsync(store, () => bDone, "B: co-resident") + .catch((e) => e); + + // A must stand out of its race and release the entry. Bounded wait, not a + // bare read: the handoff is a few microtask hops, not synchronous. + assert( + await waitFor(() => !store.hasPendingResumptions(), 2000), + "the incumbent never released the store-wide gate for the arriving " + + "driver (#239: `armDriverArrival` did not reach A's race, or A " + + "retook the entry while a second driver was live)", + ); + } finally { + aDone = true; + bDone = true; + settleThread(0); + settleHost(0); + await aDriver; + await bDriver; + store.pendingResumptions.clear(); + } +}); + +// The release path removes ONLY the driver's own entry (issue #158's lesson, +// re-asserted under #239's new wake path). +// +// `Store.pendingResumptions` is a SET, not the single global slot it started +// as, precisely so the race site can "name exactly what we added": the +// `finally` used to blanket-clear, so an entry minted DURING the await by a +// guest-synchronous delivery (`SuspensionPoint.resume`, jspi/bridge.ts) was +// clobbered early, re-opening the mis-attribution window that entry exists to +// close. #239 gave the driver a brand-new reason to run that `finally` — it +// now stands down mid-await whenever another driver arrives — so the +// identity-scoped removal is worth pinning on that path specifically. Nothing +// else in this file distinguishes the two: with one entry in play, +// `removePendingResumption(chosen)` and `pendingResumptions.clear()` are the +// same function. +// +// The foreign entry is a bare sentinel object rather than a second +// `awaitingThread`. `pendingResumptions` is `Set` and the property +// under test is purely identity-scoped removal — the set's members are never +// dereferenced by the driver, only added/removed/counted — so a sentinel says +// exactly what is being tested and cannot accidentally participate in +// scheduling. A second parked thread would add awaiting-set membership the +// property does not involve. +// +// Teardown avoids the hop bound rather than catching it: while the foreign +// entry sits in the set the store-wide gate stays held, so BOTH drivers yield +// at their loop tops under the 10,000-hop `claimHops` assert (~311ms). The +// observation takes a few milliseconds, and teardown deletes the sentinel +// FIRST — restoring the state a real `SuspensionPoint.resume` would restore +// when its activation parks or finishes — so both drivers reach their `done()` +// and exit normally, with no AssertionError to swallow. +Deno.test("a driver releases only its own speculative entry, not the whole gate (#158 under #239's stand-down)", async () => { + const store = new Store(); + let settleThread!: (v: unknown) => void; + let settleHost!: (v: unknown) => void; + const threadP = new Promise((r) => (settleThread = r)); + const hostP = new Promise((r) => (settleHost = r)); + const foreign = { what: "an entry minted by SuspensionPoint.resume" }; + let aDone = false; + let bDone = false; + let aDriver: Promise = Promise.resolve(); + let bDriver: Promise = Promise.resolve(); + try { + const thread = awaitingThread(store, threadP); + hostImport(store, hostP); + aDriver = driveStoreAsync(store, () => aDone, "A: dweller").catch((e) => e); + + // A is sole, so the entry it takes in the race is `thread` itself. + assert( + await waitFor(() => store.pendingResumptions.has(thread), 2000), + "A's driver never took its entry", + ); + + // The delivery-in-flight entry, minted while A is parked in the race. + store.addPendingResumption(foreign); + + // B's arrival wakes A out of the race and runs its `finally`. B stays live + // across the observation for the same reason as the previous test. + bDriver = driveStoreAsync(store, () => bDone, "B: co-resident") + .catch((e) => e); + + assert( + await waitFor(() => !store.pendingResumptions.has(thread), 2000), + "A never released its own entry on the stand-down path", + ); + // THE PROPERTY: A named what it added. A blanket clear here would take the + // in-flight delivery's entry with it — the #158 regression, invisible to + // every other test in this file because they only ever have one entry. + assert( + store.pendingResumptions.has(foreign), + "A's release path cleared an entry it did not add (#158: the " + + "`finally` must remove only `chosen`, not clear the set — an entry " + + "minted mid-await by a guest-synchronous delivery must survive)", + ); + } finally { + // Sentinel first: it is the only thing holding the gate now, and both + // drivers are yielding against the hop bound until it goes. + store.pendingResumptions.delete(foreign); + aDone = true; + bDone = true; + settleThread(0); + settleHost(0); + await aDriver; + await bDriver; + store.pendingResumptions.clear(); + } +});