diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 4ac7ca5709..db70339cd8 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -115,7 +115,7 @@ jobs:
# The workflow file itself is a build input (it carries the configure flags
# for web/build64 below), so hash it too — a change to the build steps must
# miss a stale cache rather than skip the rebuild and reuse old archives.
- key: wasm-${{ runner.os }}-${{ hashFiles('.github/workflows/pages.yml', 'CMakeLists.txt', 'web/CMakeLists.txt', 'web/stage_playground_imgui_samples.cmake', 'src/**', 'include/**', 'modules/**', 'daslib/**', 'utils/daspkg/**', 'examples/games/**', 'examples/graphics/**', 'examples/pathTracer/**', 'web/examples/ui/samples/**', 'tutorials/**', 'dastest/**') }}
+ key: wasm-${{ runner.os }}-${{ hashFiles('.github/workflows/pages.yml', 'CMakeLists.txt', 'web/CMakeLists.txt', 'web/stage_playground_imgui_samples.cmake', 'src/**', 'include/**', 'modules/**', 'daslib/**', 'utils/daslang/**', 'utils/daspkg/**', 'examples/games/**', 'examples/graphics/**', 'examples/pathTracer/**', 'web/examples/ui/samples/**', 'tutorials/**', 'dastest/**') }}
# Host daslang — one games-capable build (dasLLVM for cross-compile + dasGlfw
# + dasOpenGL shared modules for the games) that serves BOTH das2rst and the
diff --git a/.github/workflows/playground-e2e.yml b/.github/workflows/playground-e2e.yml
index 9b8d8d27bd..6ccdc7bacc 100644
--- a/.github/workflows/playground-e2e.yml
+++ b/.github/workflows/playground-e2e.yml
@@ -1,8 +1,9 @@
name: Playground e2e (no WASM)
# Fast PR gate: builds the site without WASM (skips the 5-10 min Emscripten
-# step) and runs the Playwright suite minus any specs tagged `@wasm`. The full
-# WASM suite is reserved for the master-only workflow (forthcoming).
+# step) and runs the Playwright suite minus any specs tagged `@wasm`. The
+# tagged specs run nightly against the deployed site (nightly_playground.yml,
+# job wasm_specs).
on:
pull_request:
diff --git a/include/daScript/misc/job_que.h b/include/daScript/misc/job_que.h
index 54e2915b15..67247736ec 100644
--- a/include/daScript/misc/job_que.h
+++ b/include/daScript/misc/job_que.h
@@ -46,6 +46,9 @@ namespace das {
void Clear(uint32_t count = 1);
int addRef( LineInfo * at = nullptr );
int releaseRef( LineInfo * at = nullptr );
+ // live refs; <=1 (the joiner's own) means no dispatched job/thread can
+ // ever notify — waitForJob's deadlock-vs-long-job discriminator
+ int refCount() const { return mRef.load(); }
int size() const;
int append(int size);
bool isValid() const { return mMagic==uint32_t(STATUS_MAGIC); }
diff --git a/plans/playground_wedge_followups.md b/plans/playground_wedge_followups.md
new file mode 100644
index 0000000000..6288387d26
--- /dev/null
+++ b/plans/playground_wedge_followups.md
@@ -0,0 +1,63 @@
+# playground wedge handling - follow-ups and standing caveats
+
+The wedge arc (deferred `Module::Shutdown` under a live browser loop + the refcount-gated
+main-thread join in `waitForJob`) fixed the nightly's interpreter wedges. What it
+deliberately did NOT settle is ledgered here.
+
+## The bounded join (`waitForJob`, module_builtin_jobque.cpp)
+
+Active only under `__EMSCRIPTEN__ && __EMSCRIPTEN_PTHREADS__` AND on the browser main
+thread: the threaded playground interpreter and the daspkg `release wasm` builds. Native,
+single-threaded wasm, worker-thread joins, `waitForJobWithTimeout`, and every
+Channel/LockBox/Stream wait are untouched.
+
+- **The throw fires only when nothing can ever notify**: a stall window with
+ `refCount() <= 1` - every dispatched job/thread holds a ref via the capture macros, so
+ the only throwable state is appended-but-never-dispatched (the wedge class). A long or
+ starved job holds a ref and the join waits forever, as on native; after the first stall
+ window it logs one stderr line (mirrored to the devtools console by the run frame).
+ This gate is what makes the throw safe: with no holders there is no `with_*` scope guard
+ left to terminate through and no later writer into the joined status's stack frame.
+- **Remaining stall subclass, by design**: a join held by genuinely stuck work (a job
+ parked on a channel the joiner was supposed to fill) still freezes the tab - visible in
+ the console via the stall line, not recoverable in-page. The structural fix is the
+ `PROXY_TO_PTHREAD` arc below.
+- **Channel/Stream blocking pops on the main thread are a sibling wedge class** -
+ `for_each_clone` over a channel nothing fills parks the tab with no bound at all. Not
+ covered by this arc.
+
+## The deferred Module::Shutdown and loop teardown (utils/daslang/main.cpp)
+
+Emscripten `daslang` binary only, browser-loop path only.
+
+- **Teardown order at the loop's natural end**: script `shutdown()` (exceptions now
+ printed, not swallowed) -> bounded global-que drain (3s) -> Context delete -> deferred
+ `Module::Shutdown`. On a drain timeout the loop's Context AND the modules are
+ deliberately leaked with a log line - freeing memory under running jobs is heap
+ corruption, and `~Module_JobQue`/`~JobQue` join workers unbounded. One program per frame
+ makes the leak inert in the playground; a long-lived multi-run embedder would accumulate.
+- **Unverified seam: a second `callMain` in one wasm instance while the first run's loop is
+ live.** The superseded path leaves modules alive on purpose, so run 2 reaches
+ `Module::Initialize` on already-initialized modules and `g_envTotal` drifts up by one
+ (suppressed leak dumps, atexit audit trip on exit). No current embedding can do it - the
+ run frame refuses second runs, `_interp.html` and the node test call `callMain` once.
+- **Thread affinity**: the deferred `Module::Shutdown` runs on the thread that services the
+ emscripten main loop, which today is the thread that ran `Module::Initialize`. The
+ `PROXY_TO_PTHREAD` arc moves daslang main onto a pthread - the tick's shutdown must move
+ with it or `daScriptEnvironment`'s thread-local bound env is null there.
+
+## The structural gap: user code can still freeze the page
+
+`while true {}` in pasted code wedges the tab: the interpreter runs on the run frame's main
+thread, same-origin frames share the tab's thread, and a frozen thread runs no parent-side
+watchdog. The refcount gate narrows the join case to stuck-work-only; compute loops and
+blocking pops remain.
+
+Structural fix: run daslang main on a pthread (`-sPROXY_TO_PTHREAD`, plus offscreen
+canvas/framebuffer for the GL path). The page thread stays live, the parent gets a real
+kill/restart switch (destroying the frame already terminates its workers), and every
+blocking join becomes legal on what is then a real thread. Own arc: GLFW event proxying,
+AudioWorklet interplay, and the pthread-pool budget all need the build-and-browser loop.
+The debug rig from the wedge arc applies (threaded `web/build_mt` staged into
+`site/playground`, COOP/COEP server over `site/`, Chromium trace + name-section
+symbolication - mind the bare `-s` in CMakeCommon's Release flags stripping wasm names).
diff --git a/site/LAWS.md b/site/LAWS.md
index 635c2db61d..0dae36613f 100644
--- a/site/LAWS.md
+++ b/site/LAWS.md
@@ -16,3 +16,9 @@ compacted, or cited as rules.
(one run's cmd covering several per-clip rows), Boris ruled the rendered cmd identifies
the RUN, not the row - "2. agree" to amending the first rule rather than storing per-clip
argvs.
+
+- **2026-08-31** (`REVIEW.md`): review-round flashlight items 2 and 4. Boris ruled ("yes")
+ the stated-suite-run rule is tip-pinned per PR with a restatement duty on later edits;
+ and ("yes") `site/README.md` is blessed as `site/`'s architecture doc (the skill takes a
+ carve-out rather than a doc split). The artifact-list and "page"-definition rewrites rode
+ along as auditor-identified defects.
diff --git a/site/README.md b/site/README.md
index d69baf863d..a5c9e62f3c 100644
--- a/site/README.md
+++ b/site/README.md
@@ -55,7 +55,7 @@ site/
| +-- samples/ # multi-file sample bundles (gitignored, mirrored from web/examples/ui/samples)
| +-- *.{js,css} # other vendored bits from web/examples/ui/src/ for local-dev (gitignored)
+-- tests/
-| +-- playground/ # Playwright e2e suite (28 specs, ~5 s no-WASM)
+| +-- playground/ # Playwright e2e suite (no-WASM lane per PR, @wasm nightly)
+-- doc/ # Sphinx HTML output (gitignored, deployed by CI)
```
@@ -318,8 +318,10 @@ The landing chart and `benchmarks.html` read them directly - no rebuild needed.
Specs cover: dropdowns, tab strip CRUD, multi-file persistence, share-URL
round-trip, splitter drag, hero up playground handoff, engine toggle, the
shared runtime module, and dead-page revival. Tests that need the daslang
-runtime carry `@wasm` in their title and only run when the WASM artifacts
-are staged; the per-PR lane runs the rest with `--grep-invert '@wasm'`.
+runtime carry `@wasm` in their title; the per-PR lane runs the rest with
+`--grep-invert '@wasm'`, and `nightly_playground.yml`'s `wasm_specs` job runs
+the tagged ones against the DEPLOYED site — an `@wasm` assertion that depends
+on an undeployed runtime change stays red until the artifact ships.
```bash
# Start the dev server from site/ (so paths resolve like prod).
@@ -335,8 +337,9 @@ npx playwright test # full suite, requires WASM at site/play
CI runs the no-WASM subset on every PR via
[`.github/workflows/playground-e2e.yml`](../.github/workflows/playground-e2e.yml).
-The `@wasm`-tagged specs are gated on the WASM build being present locally -
-no dedicated CI tier yet.
+The `@wasm`-tagged specs run nightly against the deployed site
+([`nightly_playground.yml`](../.github/workflows/nightly_playground.yml),
+`wasm_specs`), and locally against a staged WASM build.
## Common gotchas
diff --git a/site/REVIEW.md b/site/REVIEW.md
index 57511723dd..c0373355ad 100644
--- a/site/REVIEW.md
+++ b/site/REVIEW.md
@@ -2,8 +2,9 @@
**Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture doc:
`README.md`. **A Playwright spec (`*.spec.js`), wherever the diff puts it, answers to the
-`tests/playground/` checklist.** A page is an `.html` or `.md` file under this folder, together
-with what the scripts it loads render into it.
+`tests/playground/` checklist.** A page is an `.html` or `.md` file under this folder that a
+visitor navigates to, together with what the scripts it loads render into it - not a
+machine-only harness document (`playground/run-frame.html`), and not editor content.
**Never show on a page a hand-written shell command, flag, or output line invented for
illustration - show only a command the run actually executed.** A rendered `cmd` identifies
@@ -58,17 +59,18 @@ a defect - link every embedded sample.**
**A daslang sample embedded in a page not written in gen2 is a defect.**
-**A diff that changes `playground/` (this folder) or `examples/_interp.html` (this folder)
-states a run of the WASM-staged Playwright suite (`tests/playground/`, this folder) in its PR
-body or commit message, naming the passes and any failures, in the same change.** The no-WASM
-lane cannot see a broken runtime path, and every sample on the page runs through that path.
+**A PR whose diff changes `playground/` (this folder) or `examples/_interp.html` (this folder)
+states, in the PR body, a run of the WASM-staged Playwright suite (`tests/playground/`, this
+folder) against the branch tip, naming the passes and any failures; a later edit to those
+files restates the run.** The no-WASM lane cannot see a broken runtime path, and a run
+recorded mid-branch describes a tree that no longer ships.
**A stated Playwright run names every sample the diff changed: for each, the spec that loaded
it, or - when no spec loads it - that it was opened and run by hand in the playground.**
**A stated Playwright run names the runtime artifacts it used: built from this change when
-the diff touches `src/`, `include/`, `daslib/`, `modules/`, `dastest/`, or
-`web/CMakeLists.txt` (all repo root); the deployed ones otherwise.**
+the diff touches any source compiled into the WASM runtime (`daslang_static` - its `main()`
+lives in `utils/daslang/`) or the web build; the deployed ones otherwise.**
**A diff that puts a measurement number - a rate, a duration, a size, a score some run
produced, never a fixture or run parameter - on a page without rendering it from live data,
diff --git a/site/playground/forge-skin.css b/site/playground/forge-skin.css
index ba8fb51d53..09337ae2b8 100644
--- a/site/playground/forge-skin.css
+++ b/site/playground/forge-skin.css
@@ -121,6 +121,18 @@ body {
}
.button_header:hover { background: #f4b04a !important; }
.button_header:disabled { opacity: 0.5; cursor: default; }
+/* Run-button progress/busy states (paintRunButton in main.js; base rules in
+ main.css). Loading shows a dim base so the amber fill reads as progress;
+ busy keeps the amber base under the sliding stripe. */
+#run.pg-loading {
+ background: rgba(232, 161, 58, 0.18) !important;
+ color: var(--fg-dim) !important;
+ opacity: 1;
+}
+#run.pg-loading::before {
+ background: rgba(232, 161, 58, 0.55);
+}
+#run.pg-busy { opacity: 1; }
.button_header--ghost {
background: transparent !important;
color: var(--fg-dim) !important;
diff --git a/site/playground/index.html b/site/playground/index.html
index b39fe80d45..0e6bc87fa6 100644
--- a/site/playground/index.html
+++ b/site/playground/index.html
@@ -114,11 +114,11 @@
-
+
-
+
diff --git a/site/playground/playground-runner.js b/site/playground/playground-runner.js
index 044080e7c6..af4f8d4377 100644
--- a/site/playground/playground-runner.js
+++ b/site/playground/playground-runner.js
@@ -13,11 +13,12 @@
(function () {
"use strict";
- var FRAME_SRC = "run-frame.html?v=3";
+ var FRAME_SRC = "run-frame.html?v=5";
var host = null; // element the frames live in
var current = null; // frame serving the run in flight (or the idle one)
var spare = null; // pre-warmed frame, so a Run click pays ~0 startup
+ var runSeq = 0; // run identity for the busy-state token (see run())
var onOutput = null; // set by main.js: (text, color) => void
var onExit = null;
@@ -42,6 +43,27 @@
var WASM_URL = "daslang_static.wasm";
var wasmModulePromise = null;
+ // ── load progress ────────────────────────────────────────────────────────
+ // The runtime is ~40MB and the Run button is dead until it lands, so the
+ // button doubles as the progress bar (main.js paints it). Reported phases:
+ // download (0..1 of the runtime fetch — the long pole)
+ // compile (WebAssembly.compileStreaming, indeterminate)
+ // start (frame instantiation, indeterminate)
+ // ready (a frame is standing by)
+ // dead (the runner gave up — Run click revives)
+ // Phase changes and download ticks both go through here; main.js owns the
+ // presentation.
+ var loadPhase = "download";
+ var loadFraction = 0;
+
+ function reportProgress(phase, fraction) {
+ loadPhase = phase;
+ if (fraction !== undefined) loadFraction = fraction;
+ if (typeof window.pgRuntimeProgress === "function") {
+ window.pgRuntimeProgress(loadPhase, loadFraction);
+ }
+ }
+
// A download that stops flowing must reject rather than hang: once a frame
// has been acked, this promise is the only thing it is waiting on — nothing
// else times out, so a silent stall would wedge the page in the exact shape
@@ -53,6 +75,8 @@
return fetch(WASM_URL).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status + " fetching " + WASM_URL);
if (!r.body || typeof TransformStream === "undefined") return r;
+ var total = +r.headers.get("Content-Length") || 0;
+ var loaded = 0;
var timer = null;
function arm(controller) {
clearTimeout(timer);
@@ -62,8 +86,22 @@
}
var guard = new TransformStream({
start: function (c) { arm(c); },
- transform: function (chunk, c) { arm(c); c.enqueue(chunk); },
- flush: function () { clearTimeout(timer); },
+ transform: function (chunk, c) {
+ arm(c);
+ loaded += chunk.byteLength;
+ // No Content-Length (chunked encoding): report the phase with
+ // an UNKNOWN fraction (-1) rather than inventing a percentage —
+ // the button then shows an indeterminate "loading…" instead of
+ // sitting on a stuck number.
+ reportProgress("download", total > 0 ? Math.min(loaded / total, 1) : -1);
+ c.enqueue(chunk);
+ },
+ flush: function () {
+ clearTimeout(timer);
+ // Bytes are all in; what remains is the tail of the
+ // streaming compile, then frame instantiation.
+ reportProgress("compile", 1);
+ },
});
// Headers ride along so compileStreaming still sees the wasm MIME type.
return new Response(r.body.pipeThrough(guard), { headers: r.headers });
@@ -74,6 +112,7 @@
if (!wasmModulePromise) {
wasmModulePromise = fetchRuntimeWithStallGuard()
.then(function (r) { return WebAssembly.compileStreaming(r); })
+ .then(function (mod) { reportProgress("start", 1); return mod; })
.catch(function (e) {
// The ladder the emscripten glue always had: streaming compile
// hard-requires the application/wasm MIME type, and losing the
@@ -205,6 +244,17 @@
else if (spare && ev.source === spare.el.contentWindow) rec = spare;
if (!rec) return;
+ // First sign of life from the running program — output, a canvas, a drawn
+ // frame, an exit — ends the Run button's "running…" state (main.js). The
+ // token pins the signal to the run that set the state: an OUTGOING frame
+ // stays `current` (and alive, posting) until the next run() promotes the
+ // spare, and its messages must not clear a state it did not own. A bare
+ // fps tick is not life — the meter ticks from frame load; value > 0 is.
+ if (rec === current && msg.type !== "need-wasm-module" && msg.type !== "ready"
+ && (msg.type !== "fps" || msg.value > 0)) {
+ if (typeof window.pgProgramActivity === "function") window.pgProgramActivity(msg.type, rec.runToken);
+ }
+
switch (msg.type) {
case "need-wasm-module":
// Ack immediately: the compile can take seconds, and without an
@@ -224,6 +274,7 @@
case "ready":
rec.ready = true;
if (rec === spare) spareAborts = 0;
+ reportProgress("ready", 1);
if (typeof window.updateButtonStates === "function") window.updateButtonStates();
if (rec.pending) { var p = rec.pending; rec.pending = null; send(rec, p); }
break;
@@ -239,6 +290,7 @@
destroy(spare);
spare = null;
if (++spareAborts <= MAX_SPARE_ABORTS) ensureSpare();
+ else reportProgress("dead", 0);
if (typeof window.updateButtonStates === "function") window.updateButtonStates();
}
break;
@@ -307,6 +359,10 @@
if (now - lastReviveAt < REVIVE_COOLDOWN_MS) return false;
lastReviveAt = now;
spareAborts = 0;
+ // Back to an indeterminate download, not "start": a revive after a
+ // failed compile re-fetches the runtime, and the first real chunk
+ // tick takes over the percentage from here.
+ reportProgress("download", -1);
ensureSpare();
if (typeof window.updateButtonStates === "function") window.updateButtonStates();
return true;
@@ -338,20 +394,28 @@
renderBadge(null); // the run it described is gone
spareAborts = 0;
ensureSpare();
+ // The destroyed frame will never post again — tell main.js so a
+ // "running…" state it owned cannot outlive it (Clear during a run
+ // used to leave the buttons latched for the life of the page).
+ if (typeof window.pgProgramStopped === "function") window.pgProgramStopped();
if (typeof window.updateButtonStates === "function") window.updateButtonStates();
},
// files: { "main.das": "...", ... } args: argv for callMain
// assets: URLs fetched into MEMFS before the program starts
+ // Returns a token identifying this run; program-activity callbacks carry
+ // it so main.js can pin its busy state to the run that set it.
run: function (files, args, assets) {
this.reset();
current = spare;
spare = null;
current.used = true;
+ current.runToken = ++runSeq;
send(current, { type: "run", files: files, args: args, assets: assets || [] });
// Warm the next one while this program runs, so the following Run
// click does not pay startup either.
ensureSpare();
+ return current.runToken;
},
};
diff --git a/site/playground/run-frame.html b/site/playground/run-frame.html
index b226e3a0be..359cd6197a 100644
--- a/site/playground/run-frame.html
+++ b/site/playground/run-frame.html
@@ -236,10 +236,12 @@
postRun: [],
print: function (text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(" ");
+ console.log("[das]", String(text));
post({ type: "stdout", text: String(text) });
},
printErr: function (text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(" ");
+ console.error("[das:err]", String(text));
post({ type: "stderr", text: String(text) });
},
onRuntimeInitialized: function () { post({ type: "ready" }); },
diff --git a/site/tests/playground/LAWS.md b/site/tests/playground/LAWS.md
new file mode 100644
index 0000000000..5071dec369
--- /dev/null
+++ b/site/tests/playground/LAWS.md
@@ -0,0 +1,6 @@
+# LAWS - site/tests/playground (append-only intent provenance)
+
+- 2026-08-31 - `REVIEW.md`: Boris ruled ("yes", review-round flashlight item 1) to add the
+ new obligation: an `@wasm` test whose assertion depends on a same-diff runtime change must
+ state in the PR body that the nightly drives the deployed site and stays red until the
+ artifact ships; and to extend the tagging rule's WHY to name the nightly `wasm_specs` lane.
diff --git a/site/tests/playground/REVIEW.md b/site/tests/playground/REVIEW.md
index 07980e7c65..fa7588b2b3 100644
--- a/site/tests/playground/REVIEW.md
+++ b/site/tests/playground/REVIEW.md
@@ -6,4 +6,10 @@
**A diff that adds a test in this folder needing the daslang runtime, or that makes an existing
test here need it, puts `@wasm` in that test's title, in the same change.** The per-PR lane
stages the site without WASM artifacts and runs the suite with `--grep-invert '@wasm'`
-(`playground-e2e.yml`), so an untagged runtime-dependent test fails every PR.
+(`playground-e2e.yml`), so an untagged runtime-dependent test fails every PR; the tagged specs
+run only in `nightly_playground.yml`'s `wasm_specs` job, against the DEPLOYED site.
+
+**A diff that adds an `@wasm` test whose assertion depends on a runtime change in the same
+diff states in its PR body that the nightly drives the deployed site and the test stays red
+until the rebuilt artifact ships.** Without the statement, the first post-merge nightly reads
+as a mystery regression to whoever is on it.
diff --git a/site/tests/playground/jobque-join.spec.js b/site/tests/playground/jobque-join.spec.js
new file mode 100644
index 0000000000..2fff0d809e
--- /dev/null
+++ b/site/tests/playground/jobque-join.spec.js
@@ -0,0 +1,94 @@
+// Jobque on the browser main thread: a join whose jobs can never complete used
+// to park the interpreter's main thread in a condition-variable wait forever,
+// freezing the whole page (the nightly "wedge" class — the path tracer sample
+// hit it through the module-teardown-kills-the-persistent-que lifecycle bug).
+// waitForJob now discriminates by refcount: a status nothing holds (appended,
+// never dispatched) throws "join deadlock avoided"; a status live work holds
+// waits as long as the work needs. These two specs are the two halves of that
+// contract — lose the bound and the first wedges the tab; flatten the bound
+// into a naive deadline and the second throws on a healthy long job.
+//
+// Requires WASM (tagged @wasm so the no-WASM CI gate skips this file). The
+// nightly runs @wasm specs against the DEPLOYED site, so both stay red until a
+// runtime carrying the fix ships.
+//
+// Budgets follow runtime-revive.spec.js: against the live site the runtime is
+// a ~40MB download per fresh context, so test.slow()'s 90s is too tight.
+
+const { test, expect } = require('./fixtures.js');
+
+async function waitWasmReady(page) {
+ await page.waitForFunction(
+ () => !!(window.PlaygroundRunner && window.PlaygroundRunner.isReady()),
+ null,
+ { timeout: 60_000 }
+ );
+}
+
+async function setMainDas(page, lines) {
+ await page.waitForFunction(() => !!window.pgState, null, { timeout: 10_000 });
+ await page.evaluate((src) => {
+ window.pgSwitchFile('main.das');
+ window.code.getDoc().setValue(src);
+ }, lines.join('\n'));
+}
+
+test('a join that can never finish reports a deadlock instead of freezing the page @wasm', async ({ playground }) => {
+ test.setTimeout(240_000);
+ await waitWasmReady(playground);
+
+ await setMainDas(playground, [
+ 'options gen2',
+ 'require daslib/jobque_boost',
+ '',
+ '[export]',
+ 'def main {',
+ ' create_job_que()',
+ ' var status = job_status_create()',
+ ' status |> append(1) // a job that is never dispatched',
+ ' status |> join',
+ '}',
+ ]);
+
+ await playground.locator('#run').click();
+ await expect(playground.locator('.output_line_text', { hasText: 'join deadlock avoided' }))
+ .toBeVisible({ timeout: 60_000 });
+ // The page survived: a fresh frame stands by and Run is clickable again.
+ await expect.poll(() => playground.evaluate(() => window.PlaygroundRunner.isReady()),
+ { timeout: 60_000 }).toBe(true);
+ await expect(playground.locator('#run')).toBeEnabled();
+});
+
+test('a join held by a live long job completes instead of tripping the bound @wasm', async ({ playground }) => {
+ test.setTimeout(240_000);
+ await waitWasmReady(playground);
+
+ // The job sleeps past the 10s stall window on a worker thread; its captured
+ // status ref is what tells the join "live work, keep waiting". with_wait_group
+ // joins on the guarded stack shape — the arm a throw would terminate through.
+ await setMainDas(playground, [
+ 'options gen2',
+ 'require daslib/jobque_boost',
+ 'require daslib/fio',
+ '',
+ '[export]',
+ 'def main {',
+ ' create_job_que()',
+ ' with_wait_group(1) $(wg) {',
+ ' new_job() @() {',
+ ' sleep(12000u)',
+ ' wg |> done',
+ ' }',
+ ' }',
+ ' destroy_job_que()',
+ ' print("long join completed\\n")',
+ '}',
+ ]);
+
+ await playground.locator('#run').click();
+ await expect(playground.locator('.output_line_text', { hasText: 'long join completed' }))
+ .toBeVisible({ timeout: 90_000 });
+ const lines = await playground.evaluate(() =>
+ [...document.querySelectorAll('.output_line_text')].map((e) => e.innerText).join('\n'));
+ expect(lines).not.toContain('join deadlock avoided');
+});
diff --git a/site/tests/playground/run-button-state.spec.js b/site/tests/playground/run-button-state.spec.js
new file mode 100644
index 0000000000..2e56a106e0
--- /dev/null
+++ b/site/tests/playground/run-button-state.spec.js
@@ -0,0 +1,95 @@
+// The Run button's state machine. Two halves:
+// - the paint contract (pgRuntimeProgress → label/fill) needs no runtime and
+// runs in the per-PR no-WASM lane;
+// - the busy lifecycle around a real run (running… shows, first output clears
+// it, Clear during a run recovers the buttons) needs the runtime (@wasm).
+
+const { test, expect } = require('./fixtures.js');
+
+async function waitWasmReady(page) {
+ await page.waitForFunction(
+ () => !!(window.PlaygroundRunner && window.PlaygroundRunner.isReady()),
+ null,
+ { timeout: 60_000 }
+ );
+}
+
+test('pgRuntimeProgress paints percent, indeterminate, and phase labels', async ({ playground }) => {
+ // Pin the loading state: with a staged runtime the spare is ready, and with
+ // none (the no-WASM lane) the runner goes DEAD after its abort budget —
+ // either way the paint would show '▶ run' regardless of phase. Real download
+ // ticks race any two-step read, so each probe paints AND reads in one
+ // evaluate. Restored by page teardown.
+ await playground.evaluate(() => {
+ window.PlaygroundRunner.isReady = () => false;
+ window.PlaygroundRunner.isDead = () => false;
+ });
+ const paint = (phase, fraction) => playground.evaluate(
+ ([p, f]) => {
+ window.pgRuntimeProgress(p, f);
+ const b = document.getElementById('run');
+ return { label: b.textContent, fill: b.style.getPropertyValue('--pg-progress') };
+ },
+ [phase, fraction]);
+ expect(await paint('download', 0.42)).toEqual({ label: 'loading 42%', fill: '42%' });
+ // no Content-Length: an indeterminate download, never a stuck or NaN percent
+ expect(await paint('download', -1)).toEqual({ label: 'loading…', fill: '' });
+ expect((await paint('compile', 1)).label).toBe('compiling…');
+ expect((await paint('start', 1)).label).toBe('starting…');
+ // (the 'ready' → '▶ run' arm needs a live runtime; the @wasm specs below
+ // assert it at the end of a real run)
+});
+
+test('running… shows during a run, first output clears it, and Run stays the kill switch @wasm', async ({ playground }) => {
+ test.setTimeout(240_000);
+ await waitWasmReady(playground);
+ await playground.waitForFunction(() => !!window.pgState, null, { timeout: 10_000 });
+ await playground.evaluate(() => {
+ window.pgSwitchFile('main.das');
+ window.code.getDoc().setValue('options gen2\n[export]\ndef main {\n print("state probe done\\n")\n}\n');
+ });
+ await playground.locator('#run').click();
+ // the busy state is set before the frame compiles; it may clear fast, so
+ // sample the class rather than demand to catch it mid-flight
+ await expect(playground.locator('.output_line_text', { hasText: 'state probe done' }))
+ .toBeVisible({ timeout: 60_000 });
+ // first output cleared the busy state; the spare re-arms Run
+ await expect.poll(() => playground.evaluate(() =>
+ document.getElementById('run').classList.contains('pg-busy')), { timeout: 15_000 }).toBe(false);
+ await expect.poll(() => playground.evaluate(() =>
+ window.PlaygroundRunner.isReady()), { timeout: 60_000 }).toBe(true);
+ await expect(playground.locator('#run')).toBeEnabled();
+});
+
+test('Clear during a run recovers the buttons — no permanent latch @wasm', async ({ playground }) => {
+ test.setTimeout(240_000);
+ await waitWasmReady(playground);
+ await playground.waitForFunction(() => !!window.pgState, null, { timeout: 10_000 });
+ // a program slow enough that Clear can land while it is still starting
+ await playground.evaluate(() => {
+ window.pgSwitchFile('main.das');
+ window.code.getDoc().setValue([
+ 'options gen2',
+ 'require daslib/fio',
+ '[export]',
+ 'def main {',
+ ' sleep(3000u)',
+ ' print("slow done\\n")',
+ '}',
+ ].join('\n'));
+ });
+ await playground.locator('#run').click();
+ await playground.locator('#clear').click();
+ // whatever the interleaving (clear before/after the program started), the
+ // invariant holds: the page recovers, Run re-enables, and a next run works
+ await expect.poll(() => playground.evaluate(() =>
+ !document.getElementById('run').disabled
+ && !document.getElementById('run').classList.contains('pg-busy')), { timeout: 90_000 }).toBe(true);
+ await playground.evaluate(() => {
+ window.pgSwitchFile('main.das');
+ window.code.getDoc().setValue('options gen2\n[export]\ndef main {\n print("recovered\\n")\n}\n');
+ });
+ await playground.locator('#run').click();
+ await expect(playground.locator('.output_line_text', { hasText: 'recovered' }))
+ .toBeVisible({ timeout: 60_000 });
+});
diff --git a/skills/LAWS.md b/skills/LAWS.md
index 02bfdbd808..eabca6ccb9 100644
--- a/skills/LAWS.md
+++ b/skills/LAWS.md
@@ -9,3 +9,7 @@ compacted, or cited as rules.
| 2026-08-25 | comment_style_hygiene.md (guide self-review boundary) | "DONT PROPOSE MINOR CHANGES TO THIS ONE" - said while triaging comment findings on the vecmath backend PR; the guide invites findings against itself, and he wants that read narrowly |
| 2026-08-25 | comment_style_hygiene.md (vendored code) | "3rd party libraries go as is" - on the vendored include/vecmath copy: house comment rules do not reach code owned by an upstream project |
| 2026-08-26 | comment_style_hygiene.md (.das kept-set boundary), CLAUDE.md (repo root), install/CLAUDE.md (shipped twin), das_formatting.md | "lets fix. prorposed wording is good" - on the audit finding that the kept-set ban read as opt-out when `force_clean_comments` is opt-in; state the boundary by the mechanism, not by an example list, in every document that carried the old reading |
+
+- 2026-08-31 - `review_md.md`: review-round flashlight item 4. Boris ruled ("yes") to bless a
+ charter-carrying `README.md` as an architecture doc rather than splitting one out -
+ `site/README.md` is the precedent; both playground checklists keep their pointers.
diff --git a/skills/internal/preflight.md b/skills/internal/preflight.md
index 71e1808f16..0665b36b68 100644
--- a/skills/internal/preflight.md
+++ b/skills/internal/preflight.md
@@ -126,6 +126,7 @@ cmake -B build -DDAS_HV_DISABLED=OFF -DDAS_LLVM_DISABLED=OFF -DDAS_AUDIO_DISABLE
| CI step | Local mirror | Notes |
|---|---|---|
+| Markdown ASCII gate | preflight's `md-ascii` gate (fast tier, runs when the diff touches any `.md`); manual: `python3 ci/fix_md_ascii.py --check`, fix in place by dropping `--check` | em-dashes/arrows/ellipses in new markdown are the usual trip |
| dasgen freshness | ` utils/internal/dasgen/gen_bind.das` then `git diff --exit-code -- include/daScript/builtin/` | regen + commit if dirty; `skills/internal/visitor_gen_bind.md` |
| Run examples | `cmake --build build --config Release --target run_examples` | |
| Utils tests | `cmake --build build --config Release --target run_utils_tests` | |
diff --git a/skills/review_md.md b/skills/review_md.md
index 6949870e45..4cc0df38bf 100644
--- a/skills/review_md.md
+++ b/skills/review_md.md
@@ -29,8 +29,9 @@ deviates from this block - or a checklist that restates contract text instead of
a self-review finding, fixed like any other.
`` is the module's own design document - its `ARCHITECTURE.md` when it has one,
-otherwise its `CLAUDE.md`. Name it concretely; a module with no rationale home needs one
-before its rules can cite a reason.
+otherwise its `CLAUDE.md`; a `README.md` that carries the module's charter and mechanisms
+fills the slot too (`site/README.md` is the ruled precedent). Name it concretely; a module
+with no rationale home needs one before its rules can cite a reason.
## The executable half - REVIEW.das
diff --git a/src/builtin/REVIEW.md b/src/builtin/REVIEW.md
index 96598f1e91..6e33fb3ac5 100644
--- a/src/builtin/REVIEW.md
+++ b/src/builtin/REVIEW.md
@@ -3,7 +3,8 @@
**Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture doc:
`ARCHITECTURE.md`.
-- **A plain-value bind added or changed under this folder uses `addExternInline` or
+- **A diff that adds or changes an `addExtern...`/`addInterop` registration under this folder
+ uses, for a plain-value bind, `addExternInline` or
`addExternInlineEx` when its module is an Inline module, and an `addExtern...` entry point
whose name does not contain `Inline` in every other module.** A bind is a C++ function
registered into a module with an `addExtern...` or `addInterop` entry point; annotation,
diff --git a/src/builtin/module_builtin_jobque.cpp b/src/builtin/module_builtin_jobque.cpp
index 9e34706623..bc08a9cefe 100644
--- a/src/builtin/module_builtin_jobque.cpp
+++ b/src/builtin/module_builtin_jobque.cpp
@@ -8,6 +8,10 @@
#include "daScript/misc/job_que.h"
#include "module_builtin_rtti.h"
+#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__)
+#include // emscripten_is_main_browser_thread (waitForJob's bounded join)
+#endif
+
MAKE_TYPE_FACTORY(JobStatus, JobStatus)
MAKE_TYPE_FACTORY(Channel, Channel)
MAKE_TYPE_FACTORY(LockBox, LockBox)
@@ -1301,6 +1305,27 @@ namespace das {
if ( g_jobQue.use_count()==1 ) g_jobQue.reset(); // ~JobQue drains/joins the pool
}
+ // Bounded drain + teardown of the global que, for an embedder about to
+ // destroy a Context that in-flight jobs still reference (the browser loop).
+ // On timeout the que stays alive and the CALLER must leak the context —
+ // freeing it under running jobs is heap corruption, joining them a frozen tab.
+ bool shutdown_job_que_bounded ( int timeoutMs ) {
+ flushPendingForkJobs(); // batched closures reference the context too — publish them into the drain
+ g_batchForkJobs = false;
+ shared_ptr jq;
+ {
+ lock_guard guard(g_jobQueMutex);
+ jq = g_jobQue;
+ }
+ if ( !jq ) return true;
+ if ( !jq->drain(timeoutMs) ) return false;
+ jq.reset();
+ lock_guard guard(g_jobQueMutex);
+ g_persistentJobQue.reset();
+ if ( g_jobQue.use_count()==1 ) g_jobQue.reset(); // idle pool — ~JobQue joins promptly
+ return true;
+ }
+
void jobStatusAddRef ( JobStatus * status, Context * context, LineInfoArg * at ) {
if ( !status ) context->throw_error_at(at, "jobStatusAddRef: status is null");
status->addRef(at);
@@ -1338,6 +1363,42 @@ namespace das {
void waitForJob ( JobStatus * status, Context * context, LineInfoArg * at ) {
if ( !status ) context->throw_error_at(at, "waitForJob: status is null");
flushPendingForkJobs(); // batched dispatch publishes at the join point
+#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__)
+ // An unbounded join on the browser main thread is a frozen tab. Join in
+ // slices; on a stall, throw ONLY when nothing can ever notify: every
+ // dispatched job/thread holds a ref (capture macros add_ref), so
+ // refCount()<=1 = the wedge class. Refs held = live work — keep waiting
+ // (a throw there unwinds through noexcept with_* guard dtors =
+ // std::terminate, and a late notifier writes into a dead stack frame);
+ // log the stall to stderr instead.
+ if ( emscripten_is_main_browser_thread() ) {
+ const int sliceMs = 500, stallLimitMs = 10000;
+ int32_t last = status->size();
+ int stalledMs = 0;
+ bool warned = false;
+ while ( !status->WaitFor(sliceMs) ) {
+ int32_t now = status->size();
+ if ( now < last ) { // only completions are progress; an append is not
+ last = now;
+ stalledMs = 0;
+ continue;
+ }
+ last = now;
+ if ( (stalledMs += sliceMs) < stallLimitMs ) continue;
+ stalledMs = 0;
+ if ( status->refCount() <= 1 ) {
+ context->throw_error_at(at,
+ "join deadlock avoided: %d job(s) can never complete — appended but never dispatched (no live job or thread holds this status)",
+ int(now));
+ } else if ( !warned ) {
+ warned = true;
+ fprintf(stderr, "join stalled for %ds on the browser main thread: %d job(s) remaining, still held by live work — waiting\n",
+ stallLimitMs / 1000, int(now));
+ }
+ }
+ return;
+ }
+#endif
status->Wait();
}
diff --git a/utils/LAWS.md b/utils/LAWS.md
new file mode 100644
index 0000000000..e2be6614db
--- /dev/null
+++ b/utils/LAWS.md
@@ -0,0 +1,6 @@
+# LAWS - utils (append-only intent provenance)
+
+- 2026-08-31 - `REVIEW.md`: review-round flashlight item 3. Boris ruled ("agree") that a test
+ whose only executing CI row runs against an already-deployed artifact takes the same
+ recorded-local-run obligation as a compile-only row; the three test rules' triggers took
+ the diff-readable property form in the same edit.
diff --git a/utils/REVIEW.md b/utils/REVIEW.md
index ed5813a16d..96962616dc 100644
--- a/utils/REVIEW.md
+++ b/utils/REVIEW.md
@@ -3,14 +3,19 @@
**Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture
doc: `CLAUDE.md` (repo root).
+A tool is a directory that owns one program - its entry point and the files only that program
+uses - under `utils/`, or outside `utils/` when `CMakeLists.txt` (beside this file) builds or
+ships it.
+
**A file under `utils/` that belongs to a tool other than the one owning the directory it
sits in is reviewed with that tool's own `REVIEW.md`, where one exists, as well as with this
-checklist - not with the checklist of the directory it sits in. A file in a `utils/` library
-directory (`common/`) is reviewed with this checklist and with the checklist of every tool
-that requires it.** A tool is a directory that owns one program - its entry point and the files
-only that program uses - under `utils/`, or outside `utils/` when `CMakeLists.txt` (beside this
-file) builds or ships it; a tool's file outside `utils/` answers to the checklist of the folder
-that contains it as well as to this one.
+checklist - not with the checklist of the directory it sits in.**
+
+**A file in a `utils/` library directory (`common/`) is reviewed with this checklist and with
+the checklist of every tool that requires it.**
+
+**A tool's file outside `utils/` answers to the checklist of the folder that contains it as
+well as to this one.**
**A diff that changes the consent wording in `watchdog/watchdog.py` answers to
`modules/dasLLAMA/performance/REVIEW.md` (repo root) too.**
@@ -26,15 +31,21 @@ removed entry.
**A diff that deletes a tool outright records the decision beside `DAS_UTILS_SHIPPED_EXES` in
`CMakeLists.txt` (beside this file), in the same change.**
-**A new or changed test for a `utils/` tool whose load-bearing assertions a CI lane can run -
-the assertions that prove the change, not a skip-path assertion - ships with a CI row that
-executes those assertions, wherever the diff puts the test, added in the same change if no
-row already covers it.** A row that only compile-checks the test (`dastest --compile-only`)
-does not execute them. A test whose assertions no row executes never runs again.
+**A test the diff adds or changes alongside a change under `utils/`, whose load-bearing
+assertions a CI lane runs against the change - the assertions that prove it, not a skip-path
+assertion - ships with a CI row that executes those assertions, wherever the diff puts the
+test, added in the same change if no row already covers it.** A row that only compile-checks
+the test (`dastest --compile-only`) does not execute them. A test whose assertions no row
+executes never runs again.
+
+**A test whose only executing row runs against an already-deployed artifact (a nightly lane
+driving the live site) takes the same obligation as a compile-only row: the PR description
+records an executed local run of those assertions against the change.** A lane that tests
+production after merge proves nothing about the diff under review.
-**A new or changed test for a `utils/` tool whose load-bearing assertions no CI lane can run
-ships with a CI row that compile-checks it.**
+**A test the diff adds or changes alongside a change under `utils/`, whose load-bearing
+assertions no CI lane can run, ships with a CI row that compile-checks it.**
-**A new or changed test for a `utils/` tool that gets a compile-only row records its executed
+**A test the diff adds or changes alongside a change under `utils/` that gets a compile-only row records its executed
run in the PR description**: the machine the assertions ran on, what that machine had that CI
lacks, and the pass count.
diff --git a/utils/daslang/main.cpp b/utils/daslang/main.cpp
index 4d79f27355..674db5a60b 100644
--- a/utils/daslang/main.cpp
+++ b/utils/daslang/main.cpp
@@ -268,6 +268,12 @@ int das_aot_main ( int argc, char * argv[] ) {
#ifdef __EMSCRIPTEN__
#include
+namespace das {
+ // link-time resolved: src/builtin/module_builtin_jobque.cpp — bounded drain +
+ // teardown of the global job que; false = jobs did not drain, LEAK the context
+ bool shutdown_job_que_bounded ( int timeoutMs );
+}
+
// Browser 3-call lifecycle: a wasm page cannot block in main()'s while(true) — it
// must yield to the browser each frame. So a program that exposes `update` is run
// as a browser main-loop instead of single-shot main(): init() once, then update()
@@ -308,18 +314,34 @@ namespace {
// in stop_browser_loop respects the same flag as the end-of-callMain dump.
bool g_webloop_dump_leaks = true;
- // Stop the active loop: cancel its main loop, run its shutdown() (which
- // destroys the GLFW window + glfwTerminate — without this the next program's
- // glfwCreateWindow aborts "only supports one window at a time"), free the
- // Context. Idempotent; null-guarded; best-effort (teardown ignores exceptions).
- void stop_browser_loop () {
- if ( !g_activeWebLoop ) return;
+ // main() returns while the loop still runs the program — Module::Shutdown
+ // there destroys live state (~Module_JobQue killed init()'s persistent que:
+ // the playground wedge). main() sets this; the loop's natural end consumes
+ // it. The superseded path leaves modules alive — the next program needs them.
+ bool g_webloop_defer_module_shutdown = false;
+
+ // Stop the active loop: cancel it, run the script's shutdown() (destroys the
+ // GLFW window — the next glfwCreateWindow aborts otherwise), drain the jobs,
+ // free the Context. Idempotent; best-effort. False = que would not drain:
+ // the Context is deliberately LEAKED (jobs still reference its memory) and
+ // the caller must skip anything that joins the workers.
+ bool stop_browser_loop () {
+ if ( !g_activeWebLoop ) return true;
auto loop = g_activeWebLoop;
g_activeWebLoop = nullptr;
emscripten_cancel_main_loop();
if ( loop->shutdownFn ) {
loop->ctx->evalWithCatch(loop->shutdownFn, nullptr);
- loop->ctx->getException(); // swallow — teardown is best-effort
+ if ( auto ex = loop->ctx->getException() ) {
+ // best-effort continues, but not silently — the throw also skipped
+ // the rest of the program's shutdown()
+ tout << "EXCEPTION in shutdown(): " << ex << " at " << loop->ctx->exceptionAt.describe() << "\n";
+ }
+ }
+ // drain BEFORE the Context dies — fork contexts share its memory; bounded
+ if ( !das::shutdown_job_que_bounded(3000) ) {
+ tout << "job que did not drain in 3s — leaking the program's context rather than freeing memory under running jobs\n";
+ return false;
}
delete loop; // drops the Context shared_ptr -> Context + its objects freed
// Real leak check for browser-loop programs: now that the program has ended
@@ -333,6 +355,7 @@ namespace {
JobStatus::DumpJobQueLeaks();
}
}
+ return true;
}
void web_loop_tick ( void * arg ) {
@@ -352,7 +375,16 @@ namespace {
}
// void update(): runs until the page closes or the next run stops it.
if ( keepGoing ) loop->ctx->collectHeapIfMostlyFree();
- if ( !keepGoing ) stop_browser_loop();
+ if ( !keepGoing ) {
+ bool drained = stop_browser_loop();
+ // the deferred Module::Shutdown runs only on a drained que —
+ // ~Module_JobQue joins workers unbounded; undrained = leak modules
+ // alongside the already-leaked context (one program per frame)
+ if ( g_webloop_defer_module_shutdown ) {
+ g_webloop_defer_module_shutdown = false;
+ if ( drained ) Module::Shutdown(g_webloop_dump_leaks);
+ }
+ }
}
// True ⇒ the program was launched as a browser loop (Context persisted, main
@@ -1022,24 +1054,18 @@ int MAIN_FUNC_NAME ( int argc, char * argv[] ) {
// and done
if ( pauseAfterDone ) getchar();
#ifdef __EMSCRIPTEN__
- // A browser main-loop (update/init/shutdown program) keeps running after
- // callMain returns — its Context, JobStatus and smart_ptrs are legitimately
- // still alive (freed when the loop ends, via stop_browser_loop, which runs its
- // own leak check). Module::Shutdown still runs (its per-run cleanup is needed
- // for the next program to start cleanly), but with leak reporting off; then we
- // return before the end-of-run JobStatus/smart_ptr dump + exit(1), which assume
- // the program is finished and would flag every in-use object as "leaked".
- const bool browserLoopActive = ( g_activeWebLoop != nullptr );
-#else
- const bool browserLoopActive = false;
+ // A browser-loop program is still RUNNING here: no Module::Shutdown (its
+ // dtors destroy live state — see g_webloop_defer_module_shutdown), and no
+ // leak dump/exit(1) — every in-use object would read as leaked.
+ if ( g_activeWebLoop != nullptr ) {
+ g_webloop_defer_module_shutdown = true;
+ return exitCode;
+ }
#endif
// Handle-leak dump runs inside Module::Shutdown, between module
// destruction (drains job threads) and DLL unload (invalidates the
// dumpHandleLeaks function pointers registered from shared modules).
- Module::Shutdown(dumpLeaks && !browserLoopActive);
- if ( browserLoopActive ) {
- return exitCode;
- }
+ Module::Shutdown(dumpLeaks);
if ( dumpLeaks ) {
JobStatus::DumpJobQueLeaks();
}
diff --git a/utils/internal/dasweb-verify/browser/protocol.mjs b/utils/internal/dasweb-verify/browser/protocol.mjs
index 7082c01270..6da3c7fc3c 100644
--- a/utils/internal/dasweb-verify/browser/protocol.mjs
+++ b/utils/internal/dasweb-verify/browser/protocol.mjs
@@ -36,6 +36,16 @@ export const ERROR_MARKERS = [
];
// The output pane reports colours back as `rgb(r, g, b)`.
+// The run frame mirrors program stderr to the devtools console prefixed
+// "[das:err] " (site/playground/run-frame.html). Those lines are the PROGRAM
+// talking — the verdict already judges them through the output pane and the
+// per-sample ignore list — so the pageErrors channel, which exists for errors
+// the BROWSER generates, must not double-count them (and its ^-anchored ignore
+// patterns could never match the prefixed copy).
+export function isProgramStderrEcho(text) {
+ return String(text).startsWith('[das:err] ');
+}
+
export function normalizeColor(color) {
if (!color) return '';
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(color);
diff --git a/utils/internal/dasweb-verify/browser/protocol.test.mjs b/utils/internal/dasweb-verify/browser/protocol.test.mjs
index 1875d4c85f..841d668e5b 100644
--- a/utils/internal/dasweb-verify/browser/protocol.test.mjs
+++ b/utils/internal/dasweb-verify/browser/protocol.test.mjs
@@ -215,6 +215,13 @@ test('formatSummary escapes pipes and exitCodeFor gates on FAIL alone', () => {
assert.equal(P.exitCodeFor([...rows, { status: 'FAIL' }]), 1);
});
+test('program stderr echoed to the console is not a page error', () => {
+ assert.equal(P.isProgramStderrEcho('[das:err] EXCEPTION: join deadlock avoided'), true);
+ assert.equal(P.isProgramStderrEcho('[das:err] Blocking on the main thread is very dangerous'), true);
+ assert.equal(P.isProgramStderrEcho('WebGL: INVALID_ENUM: glBlitFramebuffer'), false);
+ assert.equal(P.isProgramStderrEcho('Blocking on the main thread is very dangerous'), false);
+});
+
test('every expectations row resolves, and the shipped manifest is fully covered', async () => {
const table = JSON.parse(await readFile(path.join(HERE, 'expectations.json'), 'utf8'));
for (const name of Object.keys(table.samples)) {
diff --git a/utils/internal/dasweb-verify/browser/runner.mjs b/utils/internal/dasweb-verify/browser/runner.mjs
index 745765cecd..c0f36156fa 100644
--- a/utils/internal/dasweb-verify/browser/runner.mjs
+++ b/utils/internal/dasweb-verify/browser/runner.mjs
@@ -143,7 +143,10 @@ class Playground {
// have one, so a 404 per run is the designed normal case. Assets
// that fail once a run frame does want them surface as an
// `asset : …` line in the output pane, which the verdict reads.
- if (msg.type() === 'error' && !/^Failed to load resource:/.test(msg.text())) {
+ // "[das:err] " console lines are the run frame's mirror of program
+ // stderr — already judged via the output pane, never a page error.
+ if (msg.type() === 'error' && !/^Failed to load resource:/.test(msg.text())
+ && !P.isProgramStderrEcho(msg.text())) {
this.pageErrors.push(msg.text());
}
});
@@ -314,7 +317,10 @@ class Artifacts {
// it — including the page. --console is how a failing sample gets
// diagnosed without reproducing it by hand.
if (this.cfg.console) process.stderr.write(` [${msg.type()}] ${msg.text()}\n`);
- if (msg.type() === 'error' && !/^Failed to load resource:/.test(msg.text())) {
+ // "[das:err] " console lines are the run frame's mirror of program
+ // stderr — already judged via the output pane, never a page error.
+ if (msg.type() === 'error' && !/^Failed to load resource:/.test(msg.text())
+ && !P.isProgramStderrEcho(msg.text())) {
this.pageErrors.push(msg.text());
}
});
diff --git a/utils/internal/preflight/main.das b/utils/internal/preflight/main.das
index aa019c8346..9726420744 100644
--- a/utils/internal/preflight/main.das
+++ b/utils/internal/preflight/main.das
@@ -450,6 +450,32 @@ def gate_review_md(ctx : PreflightCtx) : GateResult {
output = join(findings, "\n"))
}
+def gate_md_ascii(ctx : PreflightCtx) : GateResult {
+ let t0 = ref_time_ticks()
+ let changed = run_argv(["git", "diff", "--name-only", "{ctx.base}..HEAD"])
+ if (changed.rc != 0) {
+ return GateResult(name = "md-ascii", status = GateStatus.Skip, seconds = seconds_since(t0),
+ detail = "git diff {ctx.base}..HEAD failed - fetch origin first")
+ }
+ var touches_md = false
+ for (f in non_empty_lines(changed.out)) {
+ if (f |> ends_with(".md")) {
+ touches_md = true
+ }
+ }
+ if (!touches_md) {
+ return GateResult(name = "md-ascii", status = GateStatus.Skip, seconds = seconds_since(t0),
+ detail = "no .md changed vs {ctx.base}")
+ }
+ let r = run_argv(["python3", "ci/fix_md_ascii.py", "--check"])
+ if (r.rc == 0) {
+ return GateResult(name = "md-ascii", status = GateStatus.Pass, seconds = seconds_since(t0))
+ }
+ return GateResult(name = "md-ascii", status = GateStatus.Fail, seconds = seconds_since(t0),
+ detail = "non-ascii markdown (mirrors CI's Markdown ASCII gate) - `python3 ci/fix_md_ascii.py` fixes in place",
+ output = r.out)
+}
+
def gate_hash_refs() : GateResult {
let t0 = ref_time_ticks()
let shas = run_argv(["git", "log", "origin/master..HEAD", "--format=%H"])
@@ -1315,6 +1341,7 @@ def gate_table() : array {
GateInfo(name = "lint", full_only = false, doc = "lint changed .das on three rails (host, linux-mirror, -exe), zero warnings; --lint-skip-exe-rail drops the exe rail"),
GateInfo(name = "hash-refs", full_only = false, doc = "no bare #N in branch commit messages that GitHub would mislink - ledger cites spell out or backtick"),
GateInfo(name = "review-md", full_only = false, doc = "REVIEW.das gates of every folder the diff touches (utils/internal/review-md; mirrors CI's extended_checks step)"),
+ GateInfo(name = "md-ascii", full_only = false, doc = "ci/fix_md_ascii.py --check when the diff touches .md (mirrors CI's Markdown ASCII gate in extended_checks)"),
GateInfo(name = "ast-verify", full_only = false, doc = "daslang -dry-run --ast-verify-batch on changed .das (parallel, 300s/file; mirrors CI) - an AST verify line, a crash, or a timeout fails"),
GateInfo(name = "cpp-syntax", full_only = false, doc = "clang frontend pass on changed C++; header change → full src+tests-cpp sweep"),
GateInfo(name = "dasgen", full_only = true, doc = "gen_bind.das freshness vs include/daScript/builtin/"),
@@ -1478,6 +1505,8 @@ def main() : int { // nolint:STYLE037,STYLE038 — the gate loop + CLI surface;
r <- gate_hash_refs()
} elif (info.name == "review-md") {
r <- gate_review_md(ctx)
+ } elif (info.name == "md-ascii") {
+ r <- gate_md_ascii(ctx)
} elif (info.name == "ast-verify") {
r <- gate_ast_verify(ctx)
} elif (info.name == "cpp-syntax") {
diff --git a/web/.gitignore b/web/.gitignore
index c733287827..192a170cc2 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -1,9 +1,11 @@
-# Build output — wasm32 playground, then the wasm64 example cards
+# Build output — wasm32 playground (plain + threaded), then the wasm64 example cards
output/
+output_mt/
output64/
# CMake build trees (in-source or cmake_temp/)
build64/
+build_mt/
CMakeFiles/
CMakeCache.txt
CopyOfCMakeCache.txt
diff --git a/web/CMakeLists.txt b/web/CMakeLists.txt
index e1ee790837..b6b36b51de 100644
--- a/web/CMakeLists.txt
+++ b/web/CMakeLists.txt
@@ -207,13 +207,29 @@ set_target_properties(daslang_static PROPERTIES
EXCLUDE_FROM_ALL FALSE
RUNTIME_OUTPUT_DIRECTORY ${DAS_WEB_OUTPUT_DIR})
-# Pin the wasm32 runtime archive into web/output/lib so it doesn't collide
-# with the host build's lib/ (root CMakeLists.txt:317 resets
-# CMAKE_ARCHIVE_OUTPUT_DIRECTORY inside the subdirectory scope, so we override
-# per-target). dasLLVM (link_wasm) auto-locates the archives from this path.
+# Pin EVERY wasm archive into web/output/lib. The root CMakeLists resets
+# CMAKE_ARCHIVE_OUTPUT_DIRECTORY inside the subdirectory scope to the repo's
+# lib/ — shared with the native build, so each build silently overwrites the
+# other's archives and the loser's next link dies on foreign object format
+# ("archive member '/' not a mach-o file" natively; a wasm-ld reject here).
+# dasLLVM (link_wasm) auto-locates the runtime archive from this path.
+function(das_pin_wasm_archives dir)
+ get_property(_subdirs DIRECTORY ${dir} PROPERTY SUBDIRECTORIES)
+ foreach(_sub ${_subdirs})
+ das_pin_wasm_archives(${_sub})
+ endforeach()
+ get_property(_targets DIRECTORY ${dir} PROPERTY BUILDSYSTEM_TARGETS)
+ foreach(_t ${_targets})
+ get_target_property(_type ${_t} TYPE)
+ if(_type STREQUAL "STATIC_LIBRARY")
+ set_target_properties(${_t} PROPERTIES
+ ARCHIVE_OUTPUT_DIRECTORY ${DAS_WEB_OUTPUT_DIR}/lib)
+ endif()
+ endforeach()
+endfunction()
+das_pin_wasm_archives(${CMAKE_CURRENT_SOURCE_DIR}/..)
set_target_properties(libDaScript_runtime PROPERTIES
- EXCLUDE_FROM_ALL FALSE
- ARCHIVE_OUTPUT_DIRECTORY ${DAS_WEB_OUTPUT_DIR}/lib)
+ EXCLUDE_FROM_ALL FALSE)
# Copy web UI assets to output at build time. The OpenGL deferred-shading sample
# fetches mesh + PBR textures from tutorials/_assets/ at runtime (see its
diff --git a/web/examples/ui/LAWS.md b/web/examples/ui/LAWS.md
index fb49a83b1b..d5487c51a2 100644
--- a/web/examples/ui/LAWS.md
+++ b/web/examples/ui/LAWS.md
@@ -7,3 +7,4 @@ groomed, compacted, or cited as rules.
| Date | Document | The ask |
|---|---|---|
| 2026-08-29 | REVIEW.md (drop the blanket site import; own gen2/full-program rule; by-kind routing) | Port-convergence Q&A: approved the restructure ("yes"), batched into the convergence PR ("we ammend this one. its a bit more prose, not worth separate one"). The boulder-dash routing line took its property form in the same edit |
+| 2026-08-31 | REVIEW.md (tip-pinned suite-run rule; folder-local src/; verify-ban trailing fact removed) | Review-round flashlight item 2: "yes" to pinning the stated run to the branch tip with a restatement duty on later edits; disambiguation and the mood-test move rode along as auditor findings |
diff --git a/web/examples/ui/REVIEW.md b/web/examples/ui/REVIEW.md
index f1ddb51a7f..a9724f7c95 100644
--- a/web/examples/ui/REVIEW.md
+++ b/web/examples/ui/REVIEW.md
@@ -11,14 +11,14 @@ puts it.**
deploy copies these files into the site (`.github/workflows/pages.yml`, repo root), so
`site/playground/` never shows the change.
-**A diff that changes `src/` or `samples/` states a run of the WASM-staged Playwright suite
-(`site/tests/playground/`, repo root) in its PR body or commit message, naming the passes and
-any failures, in the same change.** The no-WASM lane cannot see a broken runtime path, and
-every sample on the page runs through that path.
+**A PR whose diff changes `src/` (this folder) or `samples/` states, in the PR body, a run of
+the WASM-staged Playwright suite (`site/tests/playground/`, repo root) against the branch tip,
+naming the passes and any failures; a later edit to those folders restates the run.** The
+no-WASM lane cannot see a broken runtime path, and a run recorded mid-branch describes a tree
+that no longer ships.
**A `.das` file under `samples/` not written in gen2, or not a full program that compiles and
runs with the current toolchain, is a defect.**
**Never write a `// verify:` line into a `.das` file under `samples/` - put the budget or the expected pattern
-in `utils/internal/dasweb-verify/browser/expectations.json` instead.** A sample-source change
-voids that sample's nightly build-cache entry.
+in `utils/internal/dasweb-verify/browser/expectations.json` instead.**
diff --git a/web/examples/ui/src/main.css b/web/examples/ui/src/main.css
index 74537d7a41..c64057351f 100644
--- a/web/examples/ui/src/main.css
+++ b/web/examples/ui/src/main.css
@@ -96,6 +96,43 @@ footer_p {
height: 35px;
}
+/* Run-button progress/busy states (painted by paintRunButton in main.js).
+ pg-loading: the runtime download — a translucent left-to-right fill whose
+ width is --pg-progress, under a "loading N%" label.
+ pg-busy: a program is compiling/starting — sliding diagonal stripes. The
+ stripe animates transform on a promoted layer, so the compositor keeps it
+ moving even while the wasm compile blocks the main thread. */
+#run { position: relative; overflow: hidden; }
+/* overflow:hidden clips the default focus ring; draw it inset so keyboard
+ focus stays visible. */
+#run:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; }
+#run.pg-loading::before,
+#run.pg-busy::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ pointer-events: none;
+}
+#run.pg-loading::before {
+ left: 0;
+ width: var(--pg-progress, 0%);
+ background: rgba(120, 200, 120, 0.35);
+ transition: width 0.15s linear;
+}
+#run.pg-busy::after {
+ left: -48px;
+ right: -48px;
+ background: repeating-linear-gradient(105deg,
+ rgba(255, 255, 255, 0.22) 0 10px, transparent 10px 24px);
+ animation: pg-busy-slide 0.7s linear infinite;
+ will-change: transform;
+}
+@keyframes pg-busy-slide {
+ to { transform: translateX(24px); }
+}
+#run.pg-loading:disabled, #run.pg-busy:disabled { opacity: 1; }
+
.select_header {
diff --git a/web/examples/ui/src/main.js b/web/examples/ui/src/main.js
index 5844d39ab7..59a4324f0f 100644
--- a/web/examples/ui/src/main.js
+++ b/web/examples/ui/src/main.js
@@ -503,6 +503,102 @@ function isWasmReady() {
return typeof PlaygroundRunner !== 'undefined' && PlaygroundRunner.isReady();
}
+// ── The Run button doubles as the status indicator ──────────────────────────
+// Two waits used to be invisible: the ~40MB runtime download on page load (the
+// button just sat dark) and the seconds between a Run click and the program's
+// first output (the interpreter compiles the script synchronously — nothing on
+// screen moves). The button itself now carries both: a left-to-right progress
+// fill with a percent label while the runtime loads, and an animated "running…"
+// stripe from the click until the program first shows life (any output, a
+// canvas, an fps tick, or exit — playground-runner.js reports it). The stripe
+// animates a compositor-driven transform, so it keeps moving even while the
+// frame's synchronous compile has this thread blocked.
+var runtimePhase = 'download'; // download → compile → start → ready (or dead)
+var runtimeFraction = -1; // 0..1, or -1 = unknown (no tick yet / no Content-Length)
+var programBusy = null; // null | 'starting' (interpreter) | 'building' (wasm)
+var activeRunToken = 0; // PlaygroundRunner.run() token the busy state belongs to
+var buildSeq = 0; // ditto for wasm builds
+var dispatchingRun = false; // inside PlaygroundRunner.run(): its reset() is not a stop
+var runEntryBusy = false; // reentrancy guard across runCode/runTests' await windows
+
+var RUN_LABEL = '▶ run';
+
+function paintRunButton() {
+ const runBtn = document.getElementById('run');
+ if (!runBtn) return;
+ const runnerDead = typeof PlaygroundRunner !== 'undefined'
+ && PlaygroundRunner.isDead && PlaygroundRunner.isDead();
+ const loading = selectedEngine() !== 'wasm' && !isWasmReady() && !runnerDead;
+ runBtn.classList.toggle('pg-busy', !!programBusy);
+ runBtn.classList.toggle('pg-loading', !programBusy && loading);
+ if (programBusy) {
+ runBtn.textContent = programBusy === 'building' ? 'building…' : 'running…';
+ } else if (loading) {
+ if (runtimePhase === 'download') {
+ if (runtimeFraction >= 0) {
+ const pct = Math.round(runtimeFraction * 100);
+ runBtn.textContent = 'loading ' + pct + '%';
+ runBtn.style.setProperty('--pg-progress', pct + '%');
+ } else {
+ // fraction unknown (no Content-Length): indeterminate, no made-up number
+ runBtn.textContent = 'loading…';
+ runBtn.style.removeProperty('--pg-progress');
+ }
+ } else if (runtimePhase === 'compile' || runtimePhase === 'start') {
+ // bytes are in; the compile/instantiation tail is short
+ runBtn.textContent = runtimePhase === 'compile' ? 'compiling…' : 'starting…';
+ runBtn.style.setProperty('--pg-progress', '100%');
+ } else {
+ // phase 'ready' with no ready frame = the post-run spare warming; no
+ // fake full bar for it
+ runBtn.textContent = 'starting…';
+ runBtn.style.removeProperty('--pg-progress');
+ }
+ } else {
+ runBtn.textContent = RUN_LABEL;
+ runBtn.style.removeProperty('--pg-progress');
+ }
+}
+
+// Called by playground-runner.js on every download tick and phase change.
+window.pgRuntimeProgress = function (phase, fraction) {
+ runtimePhase = phase;
+ runtimeFraction = (typeof fraction === 'number') ? fraction : -1;
+ if (phase === 'ready' || phase === 'dead') updateButtonStates();
+ else paintRunButton();
+};
+
+// First message from the running frame. The token pins it to the run that set
+// the state — the OUTGOING frame keeps posting until run() swaps it out, and a
+// wasm 'building' state must never be cleared by an interpreter frame.
+window.pgProgramActivity = function (type, token) {
+ if (programBusy !== 'starting' || token !== activeRunToken) return;
+ programBusy = null;
+ updateButtonStates();
+};
+
+// The current frame was destroyed (Clear/New, or a new run superseding): it
+// will never post again, so a 'running…' state it owned clears here. run()'s
+// own reset() is not a stop — the dispatch flag covers that window.
+window.pgProgramStopped = function () {
+ if (dispatchingRun || programBusy !== 'starting') return;
+ programBusy = null;
+ updateButtonStates();
+};
+
+// Flip into the busy state and give the browser one painted frame before the
+// heavy work starts (the frame's callMain compiles synchronously on this same
+// thread). The timeout arm keeps a hidden tab — where rAF never fires — from
+// deferring the dispatch itself.
+function markProgramBusy(kind) {
+ programBusy = kind;
+ updateButtonStates();
+ return new Promise(resolve => {
+ const t = setTimeout(resolve, 100);
+ requestAnimationFrame(() => setTimeout(() => { clearTimeout(t); resolve(); }, 0));
+ });
+}
+
// Toggle Run + Test buttons. Run requires WASM ready; Test additionally
// requires a [test] annotation in any open buffer. Called from autosave
// (every buffer/state mutation) and from Module.onRuntimeInitialized (WASM
@@ -534,11 +630,19 @@ function updateButtonStates() {
// button on a dead page would make that state permanent.
const runnerDead = typeof PlaygroundRunner !== 'undefined'
&& PlaygroundRunner.isDead && PlaygroundRunner.isDead();
- if (runBtn) runBtn.disabled = selectedEngine() === 'wasm' ? false : !(ready || runnerDead);
+ // The interpreter busy state is VISUAL only ('running…' + stripe): Run
+ // re-enables as soon as the next spare is ready, and a click during a run is
+ // the kill switch (run() destroys the old frame) — a misbehaving program
+ // must never hold down the one button that stops it. Only a wasm build
+ // disables Run: nothing to kill there, just a duplicate build to prevent.
+ if (runBtn) runBtn.disabled = selectedEngine() === 'wasm'
+ ? programBusy === 'building'
+ : !(ready || runnerDead);
// Test always runs interpreted, through the local runtime — and on a dead
// page it stays clickable for the same reason Run does: the click is the
// revive trigger (runTests routes through the same reportNotReady).
if (testBtn) testBtn.disabled = !(ready || runnerDead) || !hasTestAnnotation();
+ paintRunButton();
}
// Kept under the old name so playground-tabs.js's existing autosave hook
// still works without churn — it triggers a full refresh.
@@ -565,19 +669,35 @@ function reportNotReady() {
// (stdout flushing moved into run-frame.html, where FS now lives)
runCode = async function() {
- syncUrlToState();
- if (selectedEngine() === 'wasm') {
- await runWasm();
- return;
- }
- if (!isWasmReady()) {
- reportNotReady();
- return;
+ if (runEntryBusy) return; // a click mid-await must not queue a second run
+ runEntryBusy = true;
+ try {
+ syncUrlToState();
+ if (selectedEngine() === 'wasm') {
+ await runWasm();
+ return;
+ }
+ if (!isWasmReady()) {
+ reportNotReady();
+ return;
+ }
+ // Each run gets a fresh frame, so the previous program's canvas, GL
+ // context, module registry and MEMFS are gone before this one starts.
+ // No "compiling…" line in the output pane: the pane is the PROGRAM's
+ // output (tests and the sample verifier read it); the button carries
+ // the status.
+ showCanvas(false);
+ const assets = await collectAssetUrls();
+ await markProgramBusy('starting');
+ dispatchingRun = true;
+ try {
+ activeRunToken = PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets);
+ } finally {
+ dispatchingRun = false;
+ }
+ } finally {
+ runEntryBusy = false;
}
- // Each run gets a fresh frame, so the previous program's canvas, GL context,
- // module registry and MEMFS are gone before this one starts.
- showCanvas(false);
- PlaygroundRunner.run(collectProgramFiles(), ['main.das'], await collectAssetUrls());
}
// Invoke dastest against the current main.das. `[test]` functions in the file
@@ -587,16 +707,29 @@ runCode = async function() {
// wall-clock thread (suite.das wraps each file in new_thread when timeout>0),
// keeping the run single-threaded in the WASM build.
runTests = async function() {
- if (!isWasmReady()) {
- reportNotReady();
- return;
+ if (runEntryBusy) return;
+ runEntryBusy = true;
+ try {
+ if (!isWasmReady()) {
+ reportNotReady();
+ return;
+ }
+ syncUrlToState();
+ showCanvas(false);
+ const assets = await collectAssetUrls();
+ await markProgramBusy('starting');
+ dispatchingRun = true;
+ try {
+ activeRunToken = PlaygroundRunner.run(
+ collectProgramFiles(),
+ ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'],
+ assets);
+ } finally {
+ dispatchingRun = false;
+ }
+ } finally {
+ runEntryBusy = false;
}
- syncUrlToState();
- showCanvas(false);
- PlaygroundRunner.run(
- collectProgramFiles(),
- ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'],
- await collectAssetUrls());
}
// Minimal wasi_snapshot_preview1 shim — daslang STANDALONE_WASM output only
@@ -723,10 +856,11 @@ async function runWasm() {
printOutput('wasm engine unavailable: build client not loaded', '#ff9393');
return;
}
- // A build is not instant, so the button must not invite a second one.
- const runBtn = document.getElementById('run');
- const wasBusy = runBtn ? runBtn.disabled : false;
- if (runBtn) runBtn.disabled = true;
+ // A build is not instant, so the button must not invite a second one — the
+ // busy state disables it and shows the animated "building…" stripe. The
+ // token keeps this build's finally from clearing a state a later run owns.
+ const myBuild = ++buildSeq;
+ await markProgramBusy('building');
try {
const result = await window.pgWasmBuild.build(line => printOutput(line, '#9aa0a6'));
if (!result.ok) {
@@ -742,7 +876,10 @@ async function runWasm() {
} catch (e) {
printOutput('wasm build error: ' + (e && e.message ? e.message : e), '#ff2d2d');
} finally {
- if (runBtn) runBtn.disabled = wasBusy;
+ if (programBusy === 'building' && myBuild === buildSeq) {
+ programBusy = null;
+ updateButtonStates();
+ }
}
}