You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
let-go's runtime primitives sometimes behave differently across native and WASM in ways that aren't obvious from the source, and that asymmetry can quietly mislead callers who only tested on one target. The trap shape: a Clojure-idiomatic pattern that works on PTY/stdin compiles and runs on WASM but silently misbehaves because of a constraint that lives below the language layer (Go-WASM scheduler semantics, Atomics.wait blocking the worker, syscall/js round-trip cost, etc.).
Two recent examples that prompted this:
(go (term/read-key)) + alts! (nooga/xsofy#51) — works on PTY (the goroutine reading stdin parks on an OS thread; the scheduler runs siblings) but would freeze under WASM because Atomics.wait in syscall/js pins the single worker thread, blocking any sibling goroutine doing alts! over a timeout channel. The PR's test plan correctly limited scope to PTY; a reader of the merged code has no in-tree signal that the helpers misbehave on WASM.
term/read-key resize handling (this repo's feat(rt/term): wake read-key on SIGWINCH via BEL self-pipe #165) — the first design I tried was the same (go ...) + alts! shape with SIGWINCH delivered via a signal channel. Ruled out for the same WASM reason and routed through a self-pipe wake-byte design instead.
The substrate work in #120 makes core.async primitives richer and more inviting, which is great for native composability and also widens the surface area where this trap can land. As the runtime grows contracts that legitimately differ across targets, we'd benefit from a way to encode "this is what target X does, by design, and we promise to keep doing it that way."
Proposal
A :expect metadata annotation on deftest that declares per-target expected outcome:
;; Default — most tests, no syntax change required
(deftestpure-thing-works
(is (=2 (+11))))
;; Asserting-difference: behavior intentionally differs by target
(deftest ^{:expect {:native:pass:wasm:fail}}
sibling-goroutine-runs-while-read-key-blocked...)
;; Skip on a target where the primitive doesn't apply
(deftest ^{:expect {:wasm:skip}}
fionread-non-blocking-poll...)
The test runner reads *platform* at runtime, looks up the expectation, and enforces. Absence of :expect means :pass everywhere — the common case stays clean.
The asserting-difference shape is the load-bearing one. A test marked {:native :pass :wasm :fail} becomes the canonical documentation of a cross-target contract — and importantly, the runner enforces the failure too. If WASM ever starts passing the test (because Go's scheduler changes, a future runtime refactor closes the gap, or someone lands a clever wake-byte mechanism), CI tells us, and we get to consciously decide whether to migrate the contract or update the annotation.
Lift estimate
Six pieces, sequential except where noted:
*platform* runtime constant returning :native / :wasm based on build target, reuses the existing build-tag splits.
:expect annotation reader in the test namespace, backwards-compatible.
WASM test runner harness — build the suite via lg -w, run via Node.js + wasm_exec.js, parse pass/fail back out, the chunky part.
Outcome aggregation: compare actual vs :expect[platform], report mismatches loudly.
CI matrix job for the WASM target.
Three seed tests + a docs note explaining the convention.
An MVP that catches the #51-class trap end-to-end could be done in half a day by skipping SAB-mocking complexity in step 3 — the goroutine-scheduler test doesn't actually need stdin input, just future / sleep / deref-with-timeout (which works headless on Node-WASM).
Open questions worth discussion
Where should *platform* live? rt core seems natural but I don't want to assume. Names that come to mind: *platform*, *target*, (rt/target), (rt/wasm?).
WASM test runner: Node + wasm_exec.js (cheap, doesn't catch real-browser issues like canvas / timing) vs Playwright (real engine, slower, more setup). Probably both eventually, but which first?
Should :expect accept more nuanced statuses (e.g. :flaky, :slow) or just :pass / :fail / :skip?
Should asserting-difference tests fire a separate CI signal when they flip to passing on a target they expected to fail on? I'd argue yes — that's the contract-migration moment.
Happy to take the first cut if there's interest in the direction. The MVP path above is small enough that I'd file it as a single PR rather than staging.
let-go's runtime primitives sometimes behave differently across native and WASM in ways that aren't obvious from the source, and that asymmetry can quietly mislead callers who only tested on one target. The trap shape: a Clojure-idiomatic pattern that works on PTY/stdin compiles and runs on WASM but silently misbehaves because of a constraint that lives below the language layer (Go-WASM scheduler semantics,
Atomics.waitblocking the worker, syscall/js round-trip cost, etc.).Two recent examples that prompted this:
(go (term/read-key))+alts!(nooga/xsofy#51) — works on PTY (the goroutine reading stdin parks on an OS thread; the scheduler runs siblings) but would freeze under WASM becauseAtomics.waitinsyscall/jspins the single worker thread, blocking any sibling goroutine doingalts!over a timeout channel. The PR's test plan correctly limited scope to PTY; a reader of the merged code has no in-tree signal that the helpers misbehave on WASM.term/read-keyresize handling (this repo's feat(rt/term): wake read-key on SIGWINCH via BEL self-pipe #165) — the first design I tried was the same(go ...) + alts!shape with SIGWINCH delivered via a signal channel. Ruled out for the same WASM reason and routed through a self-pipe wake-byte design instead.The substrate work in #120 makes core.async primitives richer and more inviting, which is great for native composability and also widens the surface area where this trap can land. As the runtime grows contracts that legitimately differ across targets, we'd benefit from a way to encode "this is what target X does, by design, and we promise to keep doing it that way."
Proposal
A
:expectmetadata annotation ondeftestthat declares per-target expected outcome:The test runner reads
*platform*at runtime, looks up the expectation, and enforces. Absence of:expectmeans:passeverywhere — the common case stays clean.The asserting-difference shape is the load-bearing one. A test marked
{:native :pass :wasm :fail}becomes the canonical documentation of a cross-target contract — and importantly, the runner enforces the failure too. If WASM ever starts passing the test (because Go's scheduler changes, a future runtime refactor closes the gap, or someone lands a clever wake-byte mechanism), CI tells us, and we get to consciously decide whether to migrate the contract or update the annotation.Lift estimate
Six pieces, sequential except where noted:
*platform*runtime constant returning:native/:wasmbased on build target, reuses the existing build-tag splits.:expectannotation reader in thetestnamespace, backwards-compatible.lg -w, run via Node.js +wasm_exec.js, parse pass/fail back out, the chunky part.:expect[platform], report mismatches loudly.An MVP that catches the #51-class trap end-to-end could be done in half a day by skipping SAB-mocking complexity in step 3 — the goroutine-scheduler test doesn't actually need stdin input, just
future/sleep/deref-with-timeout (which works headless on Node-WASM).Open questions worth discussion
*platform*live?rtcore seems natural but I don't want to assume. Names that come to mind:*platform*,*target*,(rt/target),(rt/wasm?).wasm_exec.js(cheap, doesn't catch real-browser issues like canvas / timing) vs Playwright (real engine, slower, more setup). Probably both eventually, but which first?:expectaccept more nuanced statuses (e.g.:flaky,:slow) or just:pass/:fail/:skip?Happy to take the first cut if there's interest in the direction. The MVP path above is small enough that I'd file it as a single PR rather than staging.