Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions upstream-component-model-repo-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,14 @@ In `cancel_copy`, when the pending event is a stream `COMPLETED`, deliver
## CM-4: `sync-streams.wast:145` overfits wasmtime's scheduler — entry-status timing is not normative

**Status:** ADJUDICATED (operator, 2026-08-10) — upstream **test defect**,
not a reference-semantics issue. Candidate filing against the test suite;
distilled finding + filing kit:
[deltic#43](https://github.com/lann/deltic/issues/43) (filing itself
tracked by [deltic#15](https://github.com/lann/deltic/issues/15)).
not a reference-semantics issue. **Filing kit READY** (2026-08-11, closes
[deltic#43](https://github.com/lann/deltic/issues/43)):
`upstream-issue-sync-streams-schedule-overfit.md` (ready-to-file draft) +
`upstream-sync-streams-schedule-agnostic.patch` (applies at the spec repo
root, verified against 73b7ad5; both arms exercised green through the
deltic pipeline, FIFO + seeds — see the kit PR for the recipe). Filing
itself tracked by
[deltic#15](https://github.com/lann/deltic/issues/15).
Archived evidence tree (mechanism docs, both experiment patches, trace,
verify script): `4f3351f:exams/wasmtime-exclusivity/`.
**Found:** 2026-08-08 (JSPI flip, M2 exit). **Mechanism corrected:**
Expand Down
112 changes: 112 additions & 0 deletions upstream-issue-sync-streams-schedule-overfit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Upstream issue draft: sync-streams.wast:145 asserts scheduler policy, not semantics

Target repo: WebAssembly/component-model
Status: not yet filed (filing tracked by deltic#15; adjudication record in
deltic#43)
Companion artifact: `upstream-sync-streams-schedule-agnostic.patch` (applies
at the spec repo root, verified against 73b7ad5)

---

Title: **test/async/sync-streams.wast:145 pins one scheduler policy: STARTED vs STARTING for entry into a gated instance is not normative**

`test/async/sync-streams.wast:145` (at 73b7ad5) hard-asserts that an
async-lowered call to `$C.set` reports STARTED in its packed result:

```wat
(local.set $ret (call $set (local.get $rx)))
(if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf)))
(then unreachable))
```

At that instant `$C`'s exclusivity gate is held by the previous task:
`$C.get` has resolved (its `task.return` was delivered at line 32) but is
parked mid-frame in a synchronous `stream.write` (line 38) — and is ready to
resume, since `$D.run` already completed the first rendezvous. Whether the
new call reports STARTING or STARTED is then decided by *when* the host
evaluates the callee's admission, and the spec's two reference points
disagree:

- **canonical-abi/definitions.py decides eagerly, at the call instant**:
`canon_lower` invokes the callee synchronously (definitions.py:2281),
`canon_lift` creates and resumes the callee thread on the spot
(:2211–2212), the thread parks at the still-held gate (:484–486), and the
caller packs whatever state the subtask reached (:2306). Answer:
**STARTING** — deterministically, under every schedule the deterministic
profile can produce. The reference fails its own test suite's assertion.

- **wasmtime defers the decision**: `start_call` queues the callee and
suspends the caller until the first status event; ready work queued ahead
(the parked-but-ready `$C.get`) runs to invocation exit and releases the
gate before the new call's readiness is evaluated, so the callee is
admitted and the caller learns **STARTED** (deterministic under wasmtime's
FIFO; line refs and an execution trace against wasmtime main and v47.0.3
are available on request).

The gate *semantics* are not in question — definitions.py's
`exclusive_thread` (released only at the event-loop wait :2187–2188 and task
exit :506–508), wasmtime's `do_not_enter` bracketing, and the
CanonicalABI.md:3740–3746 prose all agree the gate spans the whole core
invocation, mid-frame parks included. What differs is scheduler policy on
top of agreed semantics, and both policies are conforming: the spec
deliberately leaves task scheduling nondeterministic. A hard STARTED
assertion therefore pins the co-developed runner's policy (the wast corpus
documents wasmtime as its runner), while the reference interpreter itself
answers STARTING — and the contradiction has no detector today because CI
runs `run_tests.py` but never executes the wast corpus against
definitions.py.

## Proposed fix

Make the region schedule-agnostic; the test's real content — the sibling
call is eventually admitted once the gate-holder exits, and the stream
rendezvous completes correctly — is preserved under both policies:

1. accept STARTING **or** STARTED from the lower;
2. on STARTING, `waitable-set.wait` for the subtask's admission (STARTED)
before touching the new stream — RETURNED cannot arrive first, because
`$C.set` reads from a stream nothing has written to yet;
3. leave every other assertion in the file unchanged.

Patch attached (also happy to open it as a PR). Under a deferred-entry host
the test takes the STARTED arm, whose assertions are byte-identical to
today's — no coverage is lost on the current runner. Under an eager-entry
host (the reference's policy) the STARTING arm finally gives this scenario a
green path. We verified both arms on a Component Model runtime that
implements hold-lifetime gates with a drain-to-quiescence entry decision:
the patched test passes as written (STARTED arm), and a reordered variant
that makes the gate-holder unready at the call site — forcing the STARTING
answer — passes the STARTING arm's waits and asserts, including under
seeded-shuffle scheduling.

## Secondary, structural

Reference↔corpus contradictions of this class stay invisible until an
independent implementation trips over them. Running the wast corpus against
definitions.py in CI (or, short of that, flagging assertions known to encode
runner policy) would give them a detector.

---

Filing notes (not part of the issue body):

- Adjudication record: deltic#43 (final operator comment, 2026-08-10);
tracker entry CM-4 in `upstream-component-model-repo-findings.md`. Same
class as NOTE-1 (tests assuming the deterministic profile), sharper
instance.
- Archived evidence tree (mechanism analysis with wasmtime line refs for
both vintages, `trace-sync-streams-wasmtime-dev.log`, `verify-cm4.sh`
whose legs 0–1 reproduce the reference-side STARTING answer against
pristine definitions.py): `4f3351f:exams/wasmtime-exclusivity/`.
- Patch verification (2026-08-11, this repo at the 73b7ad5 submodule pin):
pristine/patched/STARTING-variant legs all 2/2 through testgen +
RuntimeExecutor, FIFO and DELTIC_SCHED_SEED=1/4242; the variant
hard-asserts STARTING so its pass proves the new arm executed. Recipe: see
the PR that added this file.
- wasmtime-side check of the patched test needs a **dev** CLI (`wasmtime
wast -W component-model-async=y -W component-model-more-async-builtins=y
test/async/sync-streams.wast`); 47-era release CLIs cannot parse the
post-#655 suite syntax. By construction the patch only adds an arm
wasmtime never takes, but running it pre-filing is a reasonable courtesy.
- Keep the deltic-specific framing out of the filed text: the issue body
above names no deltic internals beyond "a Component Model runtime".
53 changes: 53 additions & 0 deletions upstream-sync-streams-schedule-agnostic.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
diff --git a/test/async/sync-streams.wast b/test/async/sync-streams.wast
index 7d31a10..83c7c6b 100644
--- a/test/async/sync-streams.wast
+++ b/test/async/sync-streams.wast
@@ -137,14 +137,36 @@
(call $stream.drop-readable (local.get $rx))

;; ($rx, $tx) = stream.new
- ;; $C.set($rx) blocks on stream.read, so the async call returns STARTED
+ ;; $C.set($rx) blocks on stream.read. $C's exclusivity gate is still
+ ;; held by the resolved-but-parked $C.get task, so whether this call
+ ;; reports STARTING or STARTED is host scheduler policy, not
+ ;; semantics: a host may decide entry eagerly at the call instant
+ ;; (STARTING) or defer the decision until runnable work queued ahead
+ ;; of the call has drained (STARTED). Accept both; on STARTING, wait
+ ;; for the subtask to be admitted (STARTED) before touching the
+ ;; stream.
(local.set $ret64 (call $stream.new))
(local.set $rx (i32.wrap_i64 (local.get $ret64)))
(local.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32))))
(local.set $ret (call $set (local.get $rx)))
- (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)))
+ (local.set $ws (call $waitable-set.new))
+ (call $waitable.join (local.get $subtask) (local.get $ws))
+ (if (i32.eq (i32.const 0 (; STARTING ;)) (i32.and (local.get $ret) (i32.const 0xf)))
+ (then
+ ;; admitted only after the parked $C.get task exits: wait for
+ ;; STARTED. RETURNED cannot arrive first: $C.set reads from a
+ ;; stream nothing has written to yet.
+ (local.set $ret (call $waitable-set.wait (local.get $ws) (i32.const 0)))
+ (if (i32.ne (i32.const 1 (; SUBTASK ;)) (local.get $ret))
+ (then unreachable))
+ (if (i32.ne (local.get $subtask) (i32.load (i32.const 0)))
+ (then unreachable))
+ (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.load (i32.const 4)))
+ (then unreachable)))
+ (else
+ (if (i32.ne (i32.const 1 (; STARTED ;)) (i32.and (local.get $ret) (i32.const 0xf)))
+ (then unreachable))))

;; (stream.write $tx $bufp 4) will succeed without blocking
(local.set $bufp (i32.const 16))
@@ -156,8 +178,6 @@
(call $stream.drop-writable (local.get $tx))

;; wait for $C.set to finish, which implies all its checks passed
- (local.set $ws (call $waitable-set.new))
- (call $waitable.join (local.get $subtask) (local.get $ws))
(local.set $ret (call $waitable-set.wait (local.get $ws) (i32.const 0)))
(if (i32.ne (i32.const 1 (; SUBTASK ;)) (local.get $ret))
(then unreachable))
Loading