Repository navigation
embedder: cross-store stream/future transfer stalls consumers and loses producer faults #344
Description
Assessed scope: cross-store transfer loses progress and producer failures
This replaces the original missing-refusal framing. Accepting a direct reference is not inherently wrong: the intended semantics may be those of host-proxied operations between stores. Assessment at 5616bce6347ee22be3e12073aed4acbc2cf6be5c demonstrates that the current implementation does not satisfy that requirement. P1 / bug based on ordinary scalar liveness and failure-reporting defects.
The operator also explicitly permits blanket cross-store refusal as an interim resolution. Recommended mitigation: refuse such transfers before consuming the source handle or changing activity state, leaving the host free to construct an explicit proxy. This is an implementation limitation, not a claim that cross-store references are intrinsically invalid.
Confirmed failures
1. Delayed delivery does not wake/drive B — stream and future
Public-component probe:
- A creates a guest stream/future and returns its readable end to the host.
- Host passes that handle directly into independent B; B starts consuming.
- A later export writes a value. The canonical write confirms completion.
- B's original consuming call remains pending.
- An unrelated export invocation in B makes the same original call return the correct value.
Same-store controls and explicit host adapters complete after the write alone. Results repeat under default scheduling and seeds 1/4242. The evidence is not a timeout alone: an unrelated B entry releases the existing completed copy.
| Path | Before write | After write | After unrelated B entry |
|---|---|---|---|
| Same-store stream/future | pending | correct value | unnecessary |
| Cross-store direct stream/future | pending | pending | correct value |
| Cross-store explicit proxy | pending | correct value | unnecessary |
Mechanism: lowerStream/lowerFuture preserve the original boundStore; HostActivity.bind binds once, while lifted wrappers disarm on lowering. Copy readiness crosses the shared object, but arrival notification/driver handoff does not reach B. A shared object's one-store bookkeeping is insufficient for endpoints in two stores.
2. Accepted transfer cannot be passed back — stream and future
A → host → B succeeds, but B → host rejects in liftAsyncValue:
AssertionError: stream crossed into a second store; multi-store is unsupported- Corresponding future assertion.
Same-store round trips and explicit cross-store proxy controls succeed. The source wrapper correctly relinquishes read authority on successful transfer; the assertion is a later incompatible lift rule, not evidence of numeric handle aliasing.
3. Source stream failure looks like ordinary termination in B
A natural JS producer attached at A throws, either before transfer or after B starts reading.
- Direct: B's consuming call succeeds with final canonical status
DROPPED, count zero. A's next entry sees the originalStreamProducerError. - Explicit proxy and same-store controls: consuming call rejects with
StreamProducerErrorcarrying the source cause.
The scratch consumer uses synchronous canonical read and returns its final packed status, so this is not a discarded status or initial BLOCKED sentinel. The producer failure is recorded in A's original boundStore before dropping the writer; B sees only the drop. This establishes stream failure suppression; no corresponding general future-failure claim is made.
What worked / limits
- Host-fed immediate/delayed scalar values and some already-progressed guest producers transferred successfully. Simple successful transfer does not prove complete support.
- A B-side trap correctly rejected the producer with
PeerTrappedErrorwhile A remained usable. Actual B-side copy cancellation/drop behaved consistently with the distinction between cancelling a copy and terminating the end. No broad cancellation failure is claimed. - No wrong numeric-index lookup or sandbox escape was demonstrated in polyengine.
- Resource-bearing transfers require separate semantic conversion when origins differ; matching WIT/class is not nominal type identity. The resource probes do not establish successful proxy consumption or an automatic-cleanup defect. Explicit cleanup disposed once; no automatic cleanup or deterministic orphan is claimed.
- Deno/V8 assessment with public components and small scratch fixtures; no browser matrix. Independent review reran the probes and added same-store controls.
Resolution options
Recommended now: explicit cross-store refusal
- Reject a foreign-store Stream/Future before ownership-consuming
takeValueeffects, transfer hooks, or destination insertion; check raw lower seams as needed without relying on late lift assertions. - Use a host-facing error naming the unsupported transfer, not
AssertionError. - Keep first binding and same-store transfers/round trips working.
- On refusal, leave the original handle usable so the caller can deliberately adapt it via a source-store host read/await and a fresh destination endpoint (e.g.
.readable()/Promise.resolve(f)for tested scalar cases). - Verify both stream/future export arguments and host import returns; do not leave an alternate lowering route that admits the same broken shared state.
- Document refusal as the current support boundary, with explicit proxying available. The existing contract sentence can describe this interim implementation choice rather than imply that sharing is inherently invalid.
Later support: endpoint-aware forwarding
Coordinate readiness/drive and producer failures across each endpoint's store; support return transfers without changing origin identity or reviving consumed wrappers. Alternatively introduce a real internal proxy boundary with the appropriate value conversion. Simply deleting the lift assertion or assigning boundStore = B is insufficient: A can still own the producer and its scheduling state.
Evidence / reproduction artifacts
Local scripts and WAT fixtures retained under:
/tmp/opencode/assess-344-progress/:probe.ts,source.wat/.wasm, report and default/seed-1/seed-4242 logs./tmp/opencode/assess-344-lifecycle/:probe.ts,status-reader.wat/.wasm,trap-end.wat/.wasm,resource-types.wat/.wasm, report andoutput.json.
Run from this checkout using its import map:
deno run --config deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED /tmp/opencode/assess-344-progress/probe.ts
deno run --config deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED /tmp/opencode/assess-344-lifecycle/probe.tsProgress script asserts the decisive delayed-transfer matrix; pass-back/lifecycle results were inspected directly and independently reproduced. Paths inside the scratch scripts name the assessed checkout. No product files changed during assessment.
Related: #170's host-error vocabulary remains relevant. Pinned CM 7c676115 supplies canonical copy/ownership rules; cross-store embedding support, automatic producer error reporting, and per-instance poison behavior are local policies. Prior Wasmtime comparisons do not decide this support boundary.
Activity
lannbot commentedon Sep 12, 2026
Triage: P2 / bug — sixth in this batch
Confirmed on
mainat1a4f5e1by source review and a focused CABI probe: bothlowerStreamandlowerFutureaccept a shared value already bound to store A into store B's handle table, whileboundStoreremains A. This independently verifies the missing precondition; it does not rerun the report's component-level transfer.The embedder contract explicitly refuses cross-store reuse. Current evidence establishes a missing refusal, not a sandbox/isolation breach or corruption of valid same-store use. P2 is appropriate.
Acceptance: reject a mismatched existing store for streams and futures before
onLowered/destination handle insertion; preserve unbound first binding and same-store round trips. Use a host-facing error rather than introducing another internalAssertionError. Exercise public-handle transfer and cleanup on refusal, not just the low-level assignment.#170's multi-store/error-vocabulary work overlaps this code but is not a duplicate: it covers checks that already fire, whereas these checks are absent. These can share an implementation PR, provided each issue's acceptance conditions are met; neither needs to block the other.
Batch order: #342 → #345 → #343 → #346 → #347 → #344. Last reflects unsupported-input refusal rather than an observed failure in supported operation.
lannbot commentedon Sep 12, 2026
Component Model / Wasmtime verification
P2 unchanged; refusal is required by polyengine's contract, not established by the reference or by universal Wasmtime behavior.
At Component Model
7c676115,lower_stream/lower_futurecheck the value kind and absence of borrows, then insert an end. There is noboundStorecheck. This omission is not permission for arbitrary cross-store reuse; it means the specific host-input refusal is an embedding obligation rather than this ABI algorithm's rule.Native sanity check produced mixed behavior. A scratch public typed-call probe against pinned Wasmtime
4675ee16b703b33948073a5ff6b961367371e7a1passed same-store round trips for both reader types. Foreign values into unmatched destination allocation layouts failed withresource is of another type/resource not present. But after matching source/destination transmit allocation layouts, foreign stream and future IDs were accepted and aliased the destination entries.The source explains why:
TableId<T>contains a numeric index without store identity;lower_transmit_to_indexlooks it up in the destination concurrent table. Thus the initial errors are incidental lookup failures, not proof of an identity guard.Do not copy this behavior or cite Wasmtime as uniformly enforcing the precondition. Polyengine explicitly promises refusal and should implement it. This comparison does not demonstrate a guest sandbox escape; the probe deliberately misuses host-owned values across stores.
Executed:
CARGO_TARGET_DIR=/tmp/opencode/host-settlement-wasmtime/target cargo run --manifest-path /tmp/opencode/triage-native-spec/Cargo.toml. The scratch probe includes both allocation-layout controls. No issue was filed on the foreign Wasmtime repository.[-]embedder: stream and future lowering accepts handles bound to another store[/-][+]embedder: verify cross-store stream/future transfer preserves host-proxy semantics[/+]lannbot commentedon Sep 12, 2026
Requirement correction from the operator
The requirement is that store B must not treat A's handle as belonging to B. Passing a reference directly is acceptable if operations behave semantically as if the host were proxying between the two stores. Blanket refusal is not the intended requirement.
This supersedes my prior triage recommendation to add an unconditional mismatched-store rejection. The existing contract sentence “Cross-store reuse is refused” needs reconciliation with this clarified intent; it is not sufficient grounds to call mere acceptance a correctness bug.
The existing polyengine repro proves acceptance and data transfer, not misinterpretation of ownership: B creates a local readable-end entry pointing to the actual shared object, whose
boundStoreremains A. This is distinct from the numeric-index aliasing observed in the Wasmtime comparison.Reframe this issue around whether direct transfer preserves host-proxy semantics. Verify progress/wakeups in both stores, cancellation/drop and failure attribution, single ownership of each end, round trips, and resource-bearing payloads. Existing single-store assumptions warrant examination:
bindOnLowerdisarms a lifted wrapper's host activity on transfer, andliftAsyncValuestill asserts on a different store. Neither observation alone proves a particular end-to-end failure, but a successful one-value transfer does not establish proxy equivalence either.P2 retained for the semantic review; replacing
bugwithpolicy/testinguntil a concrete violation is demonstrated. Fix the contract to reflect the clarified requirement, then fix any demonstrated implementation gaps rather than adding blanket refusal. No implementation change is made by this triage update.[-]embedder: verify cross-store stream/future transfer preserves host-proxy semantics[/-][+]embedder: cross-store stream/future transfer stalls consumers and loses producer faults[/+]