Component: hwjs-cc / HwjsCoroutinize pass — e8-registry phase (populateE8Registry in CoroutinizePass.cpp).
Found: 2026-07-09, cross-building the clang-22 fork (7ca43933) as a WASM-HWJS target for the self-hosted build image (BRINTOS-22). Plugin pin 893bf82 (ds_region_abi=2).
Symptom
The final clang-22 LTO link merges clang/LLVM into one ~252 MB module and runs the coroutinize pass over it. The pass never completes — it pins one core at 100% with no output. Under a bounded budget it is a clean never-silent refuse, attributed to a single function:
# HWJS_CORO_BUDGET_SECONDS=300
BUDGET REFUSE phase=e8-registry fn=clang::interp::Compiler<ByteCodeEmitter>::visitExpr
exceeded=wall-clock elapsed_ms=300004 work_units=153878528
# HWJS_CORO_BUDGET_SECONDS=3600
BUDGET REFUSE phase=e8-registry fn=clang::interp::Compiler<ByteCodeEmitter>::visitExpr
exceeded=wall-clock work_units=2130362368
Work-units grow linearly with the budget (590 k/s) and do not converge (2.13 B at 1 h, still climbing). For scale, the most expensive function that does complete is clang::FieldDecl::printName at 6.79 M — visitExpr is >300× that and unbounded.
Root cause
populateE8Registry runs a ≤64-round fixpoint that, per round, calls e8PerTargetNocapture(F) for every address-taken target — an interprocedural escape walk (classifyEscape). Issue #16 fixed exactly this walk's exponential blowup by memoizing it (callee-fate memo, d51b4e6), which this plugin includes. But that memo is gated … && !e8Building(), and the whole e8 fixpoint runs with e8Building() == true — so inside this phase the walk runs unmemoized, and clang's deeply-recursive constexpr-bytecode-interpreter functions reproduce the #16 exponential path-enumeration the memo no longer guards here. The code documents the dependency itself (CoroutinizePass.cpp:581): depth-10 is "Affordable AFTER the #16 memo (depth is IN the calleeFateMemo key → DP, not path enumeration)." With the memo off, it is path enumeration again.
Not #16, not #25: this plugin post-dates #16's fix (d51b4e6 is an ancestor of 893bf82) and #25's escape-lifts (db11f81). Clang is simply the first module large/recursive enough to exercise the unmemoized e8 fixpoint. No ops knob works around it — the e8 walk uses the hardcoded kEscapeScanDepth=10 (comment :575 "E8-mask queries stay at kEscapeScanDepth"), so HWJS_CORO_NO_DEEPWEB_DEPTH / HWJS_DBG_DEPTH don't touch it (a full 1 h run under HWJS_CORO_NO_DEEPWEB_DEPTH=1 refused identically); and populateE8Registry is on the mandatory lowering path.
Proposed fix
Two independent, opt-in changes; both validated by a full clang link (see below). Off by default so stock behavior is unchanged.
HWJS_E8_ROUND_MEMO— enable the callee-fate memo within an e8 fixpoint round, clearing it at each round boundary. The memo was excluded across the fixpoint because per-round estimates change (e8Mask()only descends, at the per-round MEET) — but within one round the estimates are constant, so a round-scoped memo is sound and restores the #16 DP. This is the real fix; you may prefer to make it unconditional rather than knob-gated.HWJS_E8_TARGET_WORK_CAP=<N>— a per-target ceiling on escape-walk steps; a target exceeding it bails to a conservativeOpaque(over-approximates escape, never a miscompile). Empirically the memo alone gives a clean ~18× on every function it covers (getNameForDiagnostic19.2 s→1.0 s,printName22 s→1.2 s) but does not boundvisitExpr— its blowup is in the use-graph worklist, not callee re-descent — so the cap is the backstop that bounds that one pathological target. The only cost of the conservative bail is efficiency, not correctness: the capped target's wasm-signature class gets pessimistic nocapture bits, so its pointer args at indirect-call sites are covered by the coroutine frame descriptor even when they need not be — slightly larger frames + marginally more save/restore/relocate at suspend points. Negligible here (visitExpris compile-time code that rarely suspends), and the validity census below confirms no correctness impact.
Validation: with both enabled (HWJS_E8_ROUND_MEMO=1 HWJS_E8_TARGET_WORK_CAP=20000000) the pass runs the whole clang module to completion:
SUMMARY coroutinized=104299 refused=0 skipped=0 defined=142009 coroutinized_fraction=73.45%
OUTPUT-VALIDITY-CENSUS transformed=104299 invalid_functions=0 module_broken=0
BUDGET-OK (not REFUSE)
Fix branch (brintos/hwjs-cc, on top of current main)
Branch: fix/e8-round-memo Commit: d7ad555
29 insertions / 2 deletions in pass/coroutinize/CoroutinizePass.cpp: relax the two
!e8Building() memo gates to (!e8Building() || e8RoundMemoEnabled()); clear
calleeFateMemo()/recFateBank() at the top of each populateE8Registry fixpoint round;
add the per-target-step cap in the classifyEscape worklist (conservative Opaque on
exceed); reset the per-target counter in e8PerTargetNocapture; plus two small env-reading
helpers. Both knobs default OFF, so stock behavior is unchanged.
Repro
Cross-build the clang-22 fork (7ca43933) for wasm32-hwjs against a ported libc++ and LTO-link bin/clang-22; set HWJS_CORO_BUDGET_SECONDS=300 for the fast attributed refuse. (Any module whose address-taken set includes a large, deeply-recursive C++ function should reproduce it; happy to reduce a standalone fixture if useful.)
Impact
Blocks the on-target clang toolchain (BRINTOS-22). Everything upstream (all 2245 TUs, libc++) is built and cached, so the fix unblocks an immediate relink.
Proposed fix
Proposed fix targets brintos/hwjs-cc only — no distro submodule pins moved.
All distros share one lineage, so pin bumps are coordinated on merge, not
per-engineer.