Skip to content

feat(wasm): run fibers with scopes and deferred values - #446

Open
isoshigi wants to merge 30 commits into
fix/wasm-memory-exhaustionfrom
feat/wasm-fibers
Open

isoshigi wants to merge 30 commits into
fix/wasm-memory-exhaustionfrom
feat/wasm-fibers

Conversation

@isoshigi

@isoshigi isoshigi commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

PR 5

概要

WASM target で Fiber / Scope / Deferred / interruption / release / scheduler を動かします。C backend の per-operation caller boundary を移植し、[dyn.suspend] の「caller は resume の呼び出しの内側で再開する」を満たします。

レビューで見つかった blocker の修正

  1. implementation 内の fairness yield で run が終了する問題 — pause wait を C の EM_JOB_GO と同じ形で job に載せ、end / impl を保持したまま再開する。implementation の継続が main として再開され、本当の main continuation が捨てられる問題を修正。
  2. queued wake で interruption が配送されない問題 — main strand の JK_RESUME で fb_interrupting を確認し、値ではなく interruption を届ける(置き換える値は参照なら解放)。scope body の return 後に child が走らない。
  3. 入れ子 implementation の unwind が 1 段だけの問題 — interrupt_fiber を fb_drop_wait の再帰に置き換え、内側からすべて unwind する。scope 終了後に内側の実装が動かない。

回帰テストを crates/emela-driver/tests/suspend_parity.rs に追加(C/JS/WASM パリティ)。修正前のソースで WASM だけが期待と異なることも確認済み。

レビューで見つかった major の修正

  • interruption object を counted にしたのに置換時に drop していなかった(defect.rs の raise / defect_resume)。C と同じく解放。
  • Run::Service のリクエストごとに G_DEFECT / G_FAILURE の run result を drop せず消していた。clear 前に解放。
  • show_failure の borrow 契約を明示し、所有している呼び出し側(fb_cause_string、settled_impl、test_result、finish、cause_to_string)で error を解放。
  • 死んだ fb_step、空の if を削除。scope_close_desc の slot 6/7 と idle の非終端 status の前提をコメント化。fb_drain の run-over 分岐の br(0) を br(1) に修正。

変更点

  • perform_end が implementation の captured frames を strand として保持し、caller を WK_CALLER wait で park する。resume が caller を wake し、同じ fiber ならその場で走らせる。
  • fb_drop_wait で abandoned caller の implementation を innermost-first に unwind する。
  • Fiber / Scope / Deferred / wait / job / frame / perform の参照カウントと drop を実装。re-suspension、scope close、child failure、interruption、defect、main exit の各経路で exactly once にする。
  • failure notes を「その failure の error」に紐づける(static error は copy する)。Fiber.attempt は自分の interruption を捕まえない。
  • fairness の later list、host / TestClock sleeper、run_mode / run_word の終了処理。
  • SystemClock.sleep の host wait(monotonic-clock.wait-for)と waitable-set-* / subtask-drop。
  • [target.wasm.unsupported] の「fibers は unsupported」を前提にした stale な skip を削除。

#442〜#444 / main との統合

テスト

  • EMELA_WASMTIME=... cargo test -p emela-codegen-wasm --no-fail-fast
    • fiber 16, suspend 13, clock 8, memory 13, run 32, component 5, hosts 19, bytes_stable 2, filesystem 11, layout 5, smoke 3, unicode 3, notes 3, protocol 2
  • cargo test -p emela-driver --test wasm_corpus --test suspend_parity --test wasm --test parity --test wasm_examples --test wasm_parity
    • 4 / 11 / 10 / 3 / 4 / 4
  • wasmtime 49.0.3 で確認
  • cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, scripts/spec.py check

2E hardening

  • trap しない: fiber / interruption / release の defect が defect: + exit 101 になり、wasm trap にならない。
  • RC / memory: 50×20 / 400×20 / suspended children / failed scope body / re-suspension / closer wait の bounded-memory test。
  • 決定性: 同じ program を繰り返し実行して出力が変わらない。
  • scheduler: 多数 fiber、nested scope、Deferred の複数 waiter、complete race、release 中の interrupt、resume 後、二重 resume、main 終了後、notes の順序。
  • C / JS / WASM parity: C の fiber_*.emel corpus と、今回の blocker 3 件の回帰。

既知の制約 / follow-up

  • host subtask cancellation: abandoned host sleep の subtask.cancel は、terminal event 待ちで trap するため保留。event は次の fb_idle が消費し、残りは instance teardown で回収される(bounded)。
  • reference-valued job word at run end: job record に repr slot がなく、run 終了時に reference の job word が bounded に残る。Service の per-request 分岐は今回 drop を追加した。
  • post-stall ordering: WASM は 14.5 に従って stall unwind 後に終了する。C は later / event loop を再確認する。仕様判断として記録。
  • TestClock の複数 sleeper の時刻(handler が State[Instant] を更新するタイミング)は spec の記述と実装の差があり、著者判断が必要。
  • spec の記述不整合: spec/targets/wasm.md はまだ SystemClock.sleep を「利用不可」とし、import 一覧と中断時 timer の記述も実装と一致しない。仕様変更は別 proposal / spec 変更で扱う(この PR では触っていない)。
  • リーク専用のメモリテストは追加していない(WASM に C の EMELA_LEAK_CHECK 相当がなく、Service の複数リクエスト harness も別途必要)。

@isoshigi
isoshigi marked this pull request as draft October 25, 2026 16:37
@isoshigi
isoshigi changed the base branch from feat/exported-functions-and-entries to fix/wasm-memory-exhaustion October 26, 2026 12:25
@isoshigi

Copy link
Copy Markdown
Collaborator Author

rebase 後の CI で、HTTP service の service 経路だけ root_run を呼んだままになっていることが分かったので直しました(7694971)。各 request を fb_root_begin / fb_root_run で囲み、main / test と同じく 1 run として扱います。http_service 10、CLI の run_starts_the_server_of_an_http_service が通り、CI も全 shard green です。

@isoshigi
isoshigi force-pushed the fix/wasm-memory-exhaustion branch from 404343c to a28a328 Compare October 26, 2026 14:01
@isoshigi
isoshigi marked this pull request as ready for review October 27, 2026 13:13
@isoshigi
isoshigi force-pushed the fix/wasm-memory-exhaustion branch from a28a328 to 7cd8457 Compare October 30, 2026 12:46
Port the fiber programs of the C target to the WASM test suite: scope
and join, nested scopes, Deferred waiters and complete races,
interruption with a slow release, child failure notes, use after scope
close, attempt and interruption, determinism, and defects that must not
trap. The tests fail until the scheduler runs fibers.
First cut of the fiber runtime: counted Fiber/Scope/Deferred/wait records,
a job queue, the root fiber, scope close, fork/join/await/interrupt and
the Deferred operations, with the perform records reworked to wake a
caller through a wait. Simple scopes run and the module validates, but
the scope close path after a fork hangs and the existing suspend tests
regress (caller waits are not woken), so this is not ready: see
handoff/2a-fibers.md.
Port the C runtime's per-operation boundary: perform_end attaches the
implementation's captured frames as its own strand and parks the caller on a
separate caller wait; a later resume wakes that wait, so the caller runs inside
the resume call and resume returns when it next suspends or ends. A defect or
failure before the resume goes to the caller; after it ends the run. Waiting
now resumes the same fiber inside the wake, the event loop queues it, the
every-16th fairness yield goes through the later list, and an abandoned caller
unwinds its implementation first. The run's result is kept while the strands
still finishing return, and the TestClock wakes the earliest sleeper.
The corpus now matches C/JS for fiber_failures, fiber_unwinding,
suspend_resume, fiber_fairness, fiber_deferred and fiber_main_fails, so their
skip groups are removed. The emitted-byte pins move with the runtime change.
The run's list, the queued begin job and the handle each count a forked
fiber.  `fb_finish` drops the run's list reference, but the begin path
handed the job's reference to it and never released it, so every child
fiber record (and the scope it referenced) stayed alive for the rest of
the run.  Release the job's reference after the strand ends, in both the
normal and the interrupted-before-start paths.

A wait's live flag is a value, not a reference: the waiter descriptor
marked it as one, and `wait_new` did not initialize it, so dropping a
wait that was never suspended could free whatever stale pointer the
reused block held.  Mark `W_ONDROP` and `W_IMPL` instead (the fields
that are references) and zero the live flag when the wait is created.

A 400 round, 20 children per round run now fits the same six pages as
the 50 round one.
`fb_idle` passed the address of the waitable-set cell to
`waitable-set.wait` and `waitable-join` instead of the handle stored
there, so wasmtime rejected the address as an unknown handle index as
soon as a host timer started a subtask.  Load the handle first.

Finding the event's sleeper branched to the loop instead of out of the
block enclosing it, so a returned event spun forever instead of waking
the sleeper.  Branch to the block.

`SystemClock.sleep` now waits on the host clock on wasmtime 49.0.3 and
50.0.0, and so does an implementation that resumes more than once on
the real clock.
…kips

`[target.wasm.unsupported]` lists no construct.  The driver tests still
asserted that a fiber program is rejected on the WASM target: turn the
diagnostic test into a run of the fiber program, and remove the fiber
entries from the example and parity skip lists so the fibers example is
compared with JS like the others.
`fb_finish_run` dropped every wait left in the run's list without taking
it out of its target's list or out of the host and TestClock sleeper
lists, so those heads and tails kept pointing at released walks.  A
later run's `fb_test_sleep` appended through a stale tail and its
`fb_wake_test` woke an abandoned implementation continuation from the
previous run.  Unlink each wait before dropping it, and reset the host,
TestClock and sequence cells when the next run starts.

A two-test regression leaves an implementation sleeping after it
resumed its caller (spec 14.5 does not wait for it) and then sleeps with
TestClock in the next run.
A child that suspends as its first act attaches its continuation to the
suspension and returns from `fb_begin`'s suspension branch without
dropping the begin job's reference to the fiber, so the fiber, its scope
and its value stayed alive.  Drop that reference after `fb_suspended`.

`fb_scope_finish` released the body's input only for a reference value
with no child failure.  For a failure or defect/interruption (modes 1
and 2) it emitted the wasm stack `drop`, which discards the value
without releasing it, so a failed scope kept its error alive.  Release
the input through the runtime `drop` for those modes too.

Regressions: a 400-round scope of twenty sleeping children stays in the
six-page limit, and 3000 failing scopes with a ten-field error do too.
Failure notes were keyed by the error pointer, so two scopes that fail
the same static error shared one note entry and the second display also
showed the first scope's note.  Copy a static error when it first gets a
note and store the copy in the cause's error slot, as the C runtime's
`em_notes_add` does.

Also release the note string that an append replaces, and release the
shown error and the intermediate string in `show_failure` (the appends
borrow their arguments).

A regression runs the same scope twice in one run and checks that each
cause carries exactly its own note.
…ases

The branch left two unused helpers (`dupv`, `ret_i32`), an unused
`unwinding_cell` and an unused status constant, and two locals named
like constants.  Remove them and name the node and word locals in snake
case so `cargo clippy --workspace --all-targets --locked -- -D warnings`
passes.
An implementation continuation that suspended again kept two references:
`fb_advance` handed the perform it owned to `fb_suspended`, which takes
its own reference, and never released the incoming one; and `wait_wake`
cleared the wait's `W_IMPL` before unlinking it, so `fb_unlink_wait`
could not clear the perform's back-pointer or release the wait
reference the perform held.  The wait, its frames and the perform stayed
alive per sleep.

Release the perform in both of `fb_advance`'s exit paths, and unlink the
wait while it still names its perform.  A 4000-round operation that
sleeps twice per call now fits the same six pages as the one-sleep test.
`fb_drop_wait` left the implementation wait it recursed into alive, and
`fb_wait_wake` returned without consuming a wait that had already been
woken or unwound even though every caller owns the reference it passes.
Release the recursive wait after queueing its orphan continuation, and
drop the dead wait on the early wake.
`fb_root_begin` reset the orphan cell for the next run without releasing
a defect that the previous run's result did not use (it only replaces an
interrupted result).  Drop it before the reset, like the notes of the
previous run.
The scope's `S_CLOSER` is a raw slot, but it was filled with `st_dup`,
adding a reference that the finalizer-less scope record never released;
every scope that closed with a sleeping child kept the closer wait, the
child fiber and their records.  Store the wait as the raw pointer it is:
the suspension owns the one reference and waking it consumes that.

Also release a reference wake value with the runtime `drop` (not the
wasm stack drop) in the dead-wait path, consume the wake in the
defensive `W_FIBER == 0` path, and note at the post-stall turn why the
backend ends when it does.

Regression: 3000 scopes whose body returns while a TestClock sleeper
child runs, under the six-page limit.
`show_failure` emitted the notes query whenever a fiber plan existed,
which is true for any suspending program, but the notes cell is 0 when
the fiber runtime is not used.  `fb_notes_of` then read address 0 (the
scratch area, where a host subtask handle can be left) as the notes
list.  Guard the query on a real notes cell, and make the notes reader
and cleaner emit no read at all when the cell is 0.

A display whose `show` raises a defect no longer appends notes to the
showing result: it returns null and leaves the defect as the outcome, so
the test protocol cannot report a note as an error message while a
defect is pending.

Regressions: a suspending non-fiber program that sleeps then fails
(its show path must not read address 0; the component scan checks the
call), and a `@test` whose error's `show` raises a defect while notes
exist (no note in the test message).
The service path kept calling `root_run` after the fiber scheduler replaced it
with `fb_root_run`, so building a service failed with "missing runtime function".
Each request is now a run of its own: `fb_root_begin` before the launch and
`fb_root_run` after it, as for `main` and the tests.
…ions

Resume a fairness yield from its wait like the C runtime's EM_JOB_GO: the
wait keeps the continuation's owner (the fiber's main continuation or an
operation implementation) and the operation call it settles. A pause inside
an implementation no longer resumes as main and ends the run in place of the
caller, and an interruption can still find and unwind the wait.

Deliver a pending interruption to a queued resume instead of the value it
carries, and release the replaced value. Interrupting a child now uses the
recursive wait drop, so every implementation it is nested in unwinds
innermost first and none resumes after the scope ends.

Release the interrupted value when a defect replaces it, and the run result
before a service request clears it. Make show_failure's borrowing contract
explicit and release its argument at every owning caller.

Remove the dead fb_step helper, document the scope close frame's shared word
and non-reference slot, and note the non-terminal waitable status.

Add C/JS/WASM parity regressions for the three blockers, and update the
pinned component bytes.
@isoshigi
isoshigi force-pushed the fix/wasm-memory-exhaustion branch from 7cd8457 to 8a5c8fa Compare November 4, 2026 15:20
@isoshigi
isoshigi requested a review from Allianaab2m November 4, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant