Repository navigation
Go components trap (and poison their instance) when the Go runtime reads the clock inside cabi_realloc #145
Description
Observed at a2f84a5, driving the wosh composed client (Rust wosh-client + Go ssh-core + iroh endpoint, wac-plugged) in headless Chromium. Downstream report and mitigations: lann/wosh#71
Symptom, downstream
Under sustained output flood, every host call into the composed instance begins failing with
cannot enter component instance 8 (reentrance forbidden)
permanently — the embedder-visible face of instance poisoning. The page-level result was a dead SSH session mid-seq.
The first trap, captured
Instrumented the setOnInstancePoisoned seam (task/scheduler.ts) in the bundled runtime and reran the flood. The poison's recorded cause, consistently across ~10 reproductions against two separately-composed builds:
Trap: cannot leave component instance 1 (may_leave violation)
at trap (dist/deltic.js:919)
at trapIf (dist/deltic.js:922)
...
at clock_time_get (wasm)
at runtime.clock_time_get (Go runtime)
at runtime.walltime1
at time.now
Instance 1 is the Go (componentize-go) member. Mechanism:
- A cross-instance call lowers a
list<u8>into the Go instance; the FACT adapter runs the callee'scabi_reallocwithmay_leave = false(reference semantics — fact/trampoline'sset_may_leave_false, definitions.py's copy-window rule). - Under allocation pressure, the Go runtime decides this allocation is the one that crosses a GC-pacing threshold and reads the wall clock —
time.now → runtime.walltime1 → clock_time_get— from insidecabi_realloc. - That is an import call during the copy window:
cannot leave component instance (may_leave violation). The trap escapes the task, and per exec/boundary.ts's poisoning rules the instance is permanently un-enterable. - Every later host entry then reports
cannot enter component instance N (reentrance forbidden)— with no trace of the original cause.
Load-dependence explains the flakiness: the trap needs an allocation to cross a GC threshold inside the window, so it only shows under flood, and not on every run. wasmtime presumably has the same exposure with a different blast radius (store poisoning), but we have only observed it under deltic — the native gates simply never generated the same allocation pressure.
deltic's enforcement looks reference-faithful here; the composition is what's illegal. Filed against deltic anyway because this is where the practical decisions live:
Asks, in descending order of confidence
-
Surface the poison cause on later entry refusals. The cause is already recorded (
notifyInstancePoisoned(inst, cause)); the subsequentcannot enter component instance N (reentrance forbidden)message could carry— instance poisoned by: <original trap>(or an embedder API to query it). This is pure diagnosability, no semantic change, and would have cut a day off the downstream investigation: the reentrance message sent us chasing caller-side call overlap when the instance was simply dead. -
Consider whether WASI clock reads deserve a copy-window exemption.
clock_time_getis a pure leaf: it re-enters nothing, observes no component state, and cannot re-enter the instance mid-copy. Exempting designated leaf imports frommay_leaveenforcement (or buffering the read) would make Go components viable under allocation pressure, at the cost of a documented divergence from the reference. Reasonable to refuse — but worth an explicit decision, because as it stands any composed component with a big-Go member is a time bomb: the Go runtime reserves the right to read the clock in any allocation. -
Track the componentize-go angle (their repo, not deltic's):
cabi_reallocthat never triggers GC pacing — pre-reserved arenas, or a GC hold during realloc — would fix this at the source for every runtime, wasmtime included.
Repro shape
Any composed component where host↔Go traffic crosses instance boundaries with list<u8> arguments at volume. With wosh: connect a session, seq 1 400000, and keep calling in (keystrokes suffice — xterm's automatic ESC[6n replies are enough). Poison typically lands within 60s. The downstream PR's browser-e2e leg L is a packaged reproduction.
Activity
lannbot commentedon Aug 15, 2026
Reviewed against the code and the spec authorities (
definitions.pyat the pinned submodule commit, CanonicalABI.md, wasmtime-environ 47). Verdict: the diagnosis is correct and well-evidenced, with one material sharpening below.Mechanism — verified end-to-end
LiftLowerContext.reallocate(definitions.py:670) setsmay_leave = Falsearound the entirecabi_realloccall;canon_lower(definitions.py:2244) traps when it is false. Any import call from inside realloc traps, per the reference.WebAssembly.GlobalbackingmayLeave(runtime/src/task/mod.ts:54) — and the lowered-import trampoline traps with exactly the reported message (runtime/src/exec/boundary.ts:1742).runtime/src/intrinsics/fact_calls.ts:674), entered set poisoned (boundary.ts:1296-1313); the cause flows only to the single-slot hook (runtime/src/task/scheduler.ts:144) — the instance lands in aWeakSet, the cause is not stored. Later entries refuse atboundary.ts:1161with no trace of it. Exactly as described.fact/trampoline.rs, and the CM corpus asserts the analogous trap for the post-return window (test/values/post-return.wast).Sharpening: the host-entry path does not enforce the window
The JS-side
LiftLowerContext.reallocate(runtime/src/cabi/context.ts:92) is a documented v1 simplification: it calls realloc directly, without the may_leave toggle. So host→Go lowering of alist<u8>runs Go's realloc withmay_leave = true, and a clock read there succeeds on deltic — while the reference traps. Consequences:Tracked separately so the decision is made deliberately rather than inherited from a TODO.
The asks
Ask 1 (surface the cause) — agreed, implementing.
poisonedInstancesbecomes aWeakMapkeyed by instance holding the cause; the entry-refusal sites append— instance poisoned by: <cause>when the target is poisoned (and only then — a transient reentrance refusal stays unchanged, which is precisely the disambiguation this ask wants). Conformance-safe: the harness matches trap messages by substring (harness/src/runner.ts:319). One more data point in favor: instrumentingsetOnInstancePoisoned— the only way to observe the cause today — replaces the stream-end retirement walk thattask/streams.ts:831registers on that single-slot seam (#66), so the downstream instrumentation disabled retirement while it ran. That seam is not a diagnostics API.Ask 2 (copy-window exemption for clock reads) — recommend refusing the exemption as stated.
reallocate(CanonicalABI.md, "Thus, in general,reallocmust be called reentrantly…") makes themay_leavetraps load-bearing: they are what license compiling realloc calls as plain synchronous calls instead of the specced fresh-thread semantics. This is not an incidental guard.The honest option set, given the host-path gap above: (1) close the gap — Go components get worse on deltic; (2) keep the current inconsistent state; (3) exempt on both paths as a documented opt-in, accepting ecosystem-fork risk. If leniency is wanted, the durable venue is an upstream spec conversation — a "pure leaf import" carve-out is at least coherent (a sync, non-reentrant import that never touches task/thread state cannot observe the difference between the fresh-thread and synchronous-call implementations), but that is the spec repo's call, not this runtime's.
Ask 3 (componentize-go) — agreed; it is the real fix. A
cabi_reallocthat can trigger GC pacing (and hence clock reads) is non-conforming under any Component Model host. Recorded inupstream-consumer-findings.md; filing upstream is the operator's call.Two wording notes on the report: "the composition is what's illegal" should be "the Go component's
cabi_reallocis non-conforming" — composition is merely where deltic happens to enforce today; and the wasmtime-exposure claim is appropriately hedged but unverified specifically for wasmtime's host-side lowering path.Merge pull request #148 from lann/poison-cause-145
task core: re-key poisoning refusal onto the poison marker (#173)
lannbot commentedon Sep 12, 2026
Triage: p2 → p1, prioritizing observed behavior failures. This has the strongest downstream evidence in the p2 queue: repeated allocation-pressure reproductions caused a permanently dead wosh SSH session. The cause-diagnostic improvement has landed; the remaining priority is resolution of Go realloc compatibility (upstream fix or an explicit policy decision), not reimplementing diagnostics. High priority does not authorize a copy-window exemption: the report establishes that the current trap is reference-faithful. Reproduce against current consumer/compiler pins before choosing the remedy.