From 89212f5cd2fe5e742f77a18e80180f25d5555ca2 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 07:04:48 -0700 Subject: [PATCH 01/11] playground: the interpreter wedge dies twice over, and the Run button learns to talk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly's three interpreter "wedges" (path tracer, physarum, fourier family) were one lifecycle bug. On the web a graphics program runs as a browser loop: callMain returns while the loop is live, and daslang's main() then ran Module::Shutdown — whose ~Module_JobQue destroys the persistent job que the program's init() had just created. The first update() tick's new_job threw "call create_job_que() first", the exception stopped the loop, and shutdown()'s `g_threads_status |> join` parked the browser main thread in a condvar wait for jobs that were never pushed. Same-origin frames share the tab's thread, so the whole page froze — with the EXCEPTION line stuck in the never-drained postMessage queue. (Profiled in Chromium: 89% of main-thread samples inside __wrap_emscripten_futex_wait under das::waitForJob under stop_browser_loop.) Two fixes, each sufficient for this wedge, both kept: - main() no longer runs Module::Shutdown while a browser loop is live — web_loop_tick runs it after the loop's natural end, when the program has actually finished. Module teardown of live runtime state was the root cause, and any module-owned global (audio included) was exposed to it. - waitForJob on the browser main thread joins in slices and tracks progress: a join whose remaining-count sits still for 10s throws "join deadlock avoided" instead of freezing the tab. A busy join that keeps completing jobs waits as long as it needs. This converts the whole user-reachable deadlock class into a visible, recoverable das exception — the page stays live and the next Run works. The playground UI gets the two indicators it was missing: - The Run button IS the runtime-load progress bar: "loading N%" with a left-to-right fill while the ~40MB interpreter streams in (Content-Length ticks from the stall-guard stream), then "compiling…"/"starting…" for the tail. No more dead dark button with nothing to look at. - After a click it turns into an animated "running…" stripe until the program first shows life (output, a canvas, a drawn frame, exit). The stripe animates a compositor-driven transform, so it keeps moving even while the frame's synchronous script compile has the shared thread blocked. A bare fps tick does not count as life — the meter ticks from frame load, only value > 0 does. run-frame.html also mirrors program stdout/stderr to console.log: when a run does freeze the thread, the browser console now holds the evidence the output pane can't show (this is how the wedge was diagnosed). New e2e spec jobque-join.spec.js is the wedge regression: a join that can never finish must report "join deadlock avoided" and hand the page back. WASM-staged Playwright suite (site/tests/playground, local threaded wasm32 build + COOP/COEP server): 66 passed, 0 failed at --workers=4; three specs (audio strudel, tabs rename/confirm) flaked once under unbounded parallel load and pass in isolation. Native tests/jobque suite: 262 passed, 1 skipped. Verified in Chromium: path tracer lab runs at 120fps interpreted (CPU-threads mode over the persistent que), physarum + fourier run, and the deliberate-deadlock probe reports the exception with the page alive. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- site/playground/forge-skin.css | 12 +++ site/playground/index.html | 4 +- site/playground/playground-runner.js | 54 ++++++++++++- site/playground/run-frame.html | 2 + site/tests/playground/jobque-join.spec.js | 47 +++++++++++ src/builtin/module_builtin_jobque.cpp | 30 +++++++ utils/daslang/main.cpp | 43 +++++++--- web/.gitignore | 4 +- web/examples/ui/src/main.css | 34 ++++++++ web/examples/ui/src/main.js | 96 ++++++++++++++++++++--- 10 files changed, 299 insertions(+), 27 deletions(-) create mode 100644 site/tests/playground/jobque-join.spec.js 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..5381290aa9 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..c3a984cc48 100644 --- a/site/playground/playground-runner.js +++ b/site/playground/playground-runner.js @@ -13,7 +13,7 @@ (function () { "use strict"; - var FRAME_SRC = "run-frame.html?v=3"; + var FRAME_SRC = "run-frame.html?v=4"; var host = null; // element the frames live in var current = null; // frame serving the run in flight (or the idle one) @@ -42,6 +42,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 +74,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 +85,20 @@ } 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): stay indeterminate + // rather than inventing a percentage. + if (total > 0) reportProgress("download", Math.min(loaded / total, 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 +109,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 +241,15 @@ 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 "starting…" state (main.js). + // The fps meter ticks from the moment the frame loads, so a bare fps + // message is NOT life; only one reporting actual draws (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); + } + switch (msg.type) { case "need-wasm-module": // Ack immediately: the compile can take seconds, and without an @@ -224,6 +269,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 +285,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 +354,7 @@ if (now - lastReviveAt < REVIVE_COOLDOWN_MS) return false; lastReviveAt = now; spareAborts = 0; + reportProgress("start", 0); ensureSpare(); if (typeof window.updateButtonStates === "function") window.updateButtonStates(); return true; diff --git a/site/playground/run-frame.html b/site/playground/run-frame.html index b226e3a0be..0dc23109ef 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.log("[das:err]", String(text)); post({ type: "stderr", text: String(text) }); }, onRuntimeInitialized: function () { post({ type: "ready" }); }, diff --git a/site/tests/playground/jobque-join.spec.js b/site/tests/playground/jobque-join.spec.js new file mode 100644 index 0000000000..d59f7185c8 --- /dev/null +++ b/site/tests/playground/jobque-join.spec.js @@ -0,0 +1,47 @@ +// 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 bounds a no-progress join and throws a das exception the +// output pane can show. This spec IS the wedge regression: if the bound is +// lost, the run never produces the exception line and the test times out +// instead of the visitor's tab. +// +// Requires WASM (tagged @wasm so the no-WASM CI gate skips this file). + +const { test, expect } = require('./fixtures.js'); + +async function waitWasmReady(page) { + await page.waitForFunction( + () => !!(window.PlaygroundRunner && window.PlaygroundRunner.isReady()), + null, + { timeout: 30_000 } + ); +} + +// The join gives up after 10s without progress; compile + slack on top. +test('a join that can never finish reports a deadlock instead of freezing the page @wasm', async ({ playground }) => { + test.setTimeout(60_000); + await waitWasmReady(playground); + + await playground.evaluate(() => { + window.code.getDoc().setValue([ + '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', + '}', + ].join('\n')); + }); + + await playground.locator('#run').click(); + await expect(playground.locator('.output_line_text', { hasText: 'join deadlock avoided' })) + .toBeVisible({ timeout: 40_000 }); + // The page survived: the editor still answers and Run comes back. + await expect(playground.locator('#run')).toHaveText('▶ run', { timeout: 15_000 }); +}); diff --git a/src/builtin/module_builtin_jobque.cpp b/src/builtin/module_builtin_jobque.cpp index 9e34706623..54d0431baf 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) @@ -1338,6 +1342,32 @@ 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__) + // On the browser main thread an unbounded join IS the page: the thread that + // would repaint, deliver postMessage (the output pane) and service input is + // the one parked here, so a join whose jobs can never complete freezes the + // whole tab with no diagnostic. Join in slices and track progress — a busy + // join that keeps completing jobs waits as long as it needs to, while one + // that makes NO progress for the whole window becomes a das exception the + // page can report instead of a wedge. + if ( emscripten_is_main_browser_thread() ) { + const int sliceMs = 500, stallLimitMs = 10000; + int32_t last = status->size(); + int stalledMs = 0; + while ( !status->WaitFor(sliceMs) ) { + int32_t now = status->size(); + if ( now != last ) { + last = now; + stalledMs = 0; + } else if ( (stalledMs += sliceMs) >= stallLimitMs ) { + context->throw_error_at(at, + "join deadlock avoided: %d job(s) made no progress for %ds on the browser main thread", + int(now), stallLimitMs / 1000); + } + } + return; + } +#endif status->Wait(); } diff --git a/utils/daslang/main.cpp b/utils/daslang/main.cpp index 4d79f27355..9891fc93a7 100644 --- a/utils/daslang/main.cpp +++ b/utils/daslang/main.cpp @@ -308,6 +308,17 @@ namespace { // in stop_browser_loop respects the same flag as the end-of-callMain dump. bool g_webloop_dump_leaks = true; + // main() returns while the browser loop still runs the program, so it must NOT + // tear down the module registry there: module destructors destroy live runtime + // state — ~Module_JobQue frees the persistent job que create_job_que() built in + // init(), so the first update()'s new_job threw "call create_job_que() first", + // and the shutdown() join then parked the browser main thread forever (the + // playground wedge). main() sets this instead, and the loop's NATURAL end + // (web_loop_tick) runs the deferred Module::Shutdown after the program's own + // shutdown(). The superseded path (next run's compile_and_run stops the loop) + // leaves modules alive on purpose — the next program is about to compile. + bool g_webloop_defer_module_shutdown = false; + // 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 @@ -352,7 +363,15 @@ 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 ) { + stop_browser_loop(); + // The program has truly ended — run the Module::Shutdown that main() + // deferred while the loop was live (see g_webloop_defer_module_shutdown). + if ( g_webloop_defer_module_shutdown ) { + g_webloop_defer_module_shutdown = false; + Module::Shutdown(g_webloop_dump_leaks); + } + } } // True ⇒ the program was launched as a browser loop (Context persisted, main @@ -1025,21 +1044,21 @@ int MAIN_FUNC_NAME ( int argc, char * argv[] ) { // 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; + // own leak check). Module::Shutdown must NOT run here: module destructors tear + // down live runtime state (~Module_JobQue destroys the persistent job que the + // program's init() created), so it is deferred to the loop's natural end in + // web_loop_tick. We also 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". + 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/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/examples/ui/src/main.css b/web/examples/ui/src/main.css index 74537d7a41..ec06642549 100644 --- a/web/examples/ui/src/main.css +++ b/web/examples/ui/src/main.css @@ -96,6 +96,40 @@ 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; } +#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..ffb5e20ac5 100644 --- a/web/examples/ui/src/main.js +++ b/web/examples/ui/src/main.js @@ -503,6 +503,72 @@ 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 = 0; +var programBusy = null; // null | 'starting' (interpreter) | 'building' (wasm) + +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') { + const pct = Math.round(runtimeFraction * 100); + runBtn.textContent = 'loading ' + pct + '%'; + runBtn.style.setProperty('--pg-progress', pct + '%'); + } else { + // compile / start: bytes are in, the tail is indeterminate but short + runBtn.textContent = runtimePhase === 'compile' ? 'compiling…' : 'starting…'; + runBtn.style.setProperty('--pg-progress', '100%'); + } + } 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 = fraction || 0; + if (phase === 'ready' || phase === 'dead') updateButtonStates(); + else paintRunButton(); +}; + +// Called by playground-runner.js on the running program's first message. +window.pgProgramActivity = function () { + if (!programBusy) 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, so without the rAF+timeout hop the button would never show it. +function markProgramBusy(kind) { + programBusy = kind; + updateButtonStates(); + return new Promise(resolve => requestAnimationFrame(() => setTimeout(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 +600,16 @@ 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); + // While a program is starting/building, both buttons hold: a second click + // could not be serviced anyway (the compile owns this thread), and the busy + // stripe is the feedback. + if (runBtn) runBtn.disabled = !!programBusy + || (selectedEngine() === 'wasm' ? false : !(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(); + if (testBtn) testBtn.disabled = !!programBusy || !(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. @@ -576,8 +647,13 @@ runCode = async function() { } // 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), and the busy Run button already + // carries the status. showCanvas(false); - PlaygroundRunner.run(collectProgramFiles(), ['main.das'], await collectAssetUrls()); + const assets = await collectAssetUrls(); + await markProgramBusy('starting'); + PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets); } // Invoke dastest against the current main.das. `[test]` functions in the file @@ -593,10 +669,12 @@ runTests = async function() { } syncUrlToState(); showCanvas(false); + const assets = await collectAssetUrls(); + await markProgramBusy('starting'); PlaygroundRunner.run( collectProgramFiles(), ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'], - await collectAssetUrls()); + assets); } // Minimal wasi_snapshot_preview1 shim — daslang STANDALONE_WASM output only @@ -723,10 +801,9 @@ 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. + await markProgramBusy('building'); try { const result = await window.pgWasmBuild.build(line => printOutput(line, '#9aa0a6')); if (!result.ok) { @@ -742,7 +819,8 @@ async function runWasm() { } catch (e) { printOutput('wasm build error: ' + (e && e.message ? e.message : e), '#ff2d2d'); } finally { - if (runBtn) runBtn.disabled = wasBusy; + programBusy = null; + updateButtonStates(); } } From cb2380197a7dc98241f61d3b095d0a683218fab0 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 07:16:48 -0700 Subject: [PATCH 02/11] plans: ledger the wedge-handling blast radius and the PROXY_TO_PTHREAD follow-up Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- plans/playground_wedge_followups.md | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 plans/playground_wedge_followups.md diff --git a/plans/playground_wedge_followups.md b/plans/playground_wedge_followups.md new file mode 100644 index 0000000000..bda17cef26 --- /dev/null +++ b/plans/playground_wedge_followups.md @@ -0,0 +1,54 @@ +# playground wedge handling - follow-ups and standing caveats + +The wedge arc (deferred `Module::Shutdown` under a live browser loop + the bounded +main-thread join in `waitForJob`) fixed the nightly's interpreter wedges. What it +deliberately did NOT settle is ledgered here. + +## Blast radius of 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 (those +always link `-pthread`). Native, single-threaded wasm, worker-thread joins, +`waitForJobWithTimeout`, and every Channel/LockBox/Stream wait are untouched. + +- **False-positive class: a single legitimately long job.** Progress is the remaining-count + moving, so ONE job that runs longer than the 10s window - or jobs starved behind another + dispatch's long jobs on the 4-worker wasm pool - trips "join deadlock avoided" on a join + that would have finished. Interpreted wasm is 10-50x slower than native, so a >10s single + job is reachable from user code. No shipped sample joins mid-run (the labs poll `isReady` + and only join at shutdown behind the epoch bail). If a legitimate trip ever surfaces: + soften to log-and-keep-waiting on the first window and throw on the second, or widen the + window - the constant is local to `waitForJob`. +- **Aftermath of a trip is bounded by refcounts.** Late-completing jobs land on a still-valid + status; `job_status_remove` refuses while refs are held. Worst case is a leaked JobStatus + plus its leak-report line - no use-after-free. + +## Blast radius of the deferred Module::Shutdown (utils/daslang/main.cpp) + +Emscripten `daslang` binary only, browser-loop path only. Non-loop wasm programs and the +compiled wasm64 cards (own entry point) are unchanged. + +- **Unverified seam: a second `callMain` in one wasm instance while the first run's loop is + live.** The superseded-loop path leaves modules alive on purpose (the next program is + about to compile), so run 2 calls `Module::Initialize` on already-initialized modules. + Believed idempotent; not exercised - the playground runs one program per frame, and the + frame refuses second runs. Verify before any embedder resurrects multi-run + daslang-in-wasm. +- The handle-leak dump for browser-loop programs moved from end-of-main (reporting off) to + loop end (reporting on) - leak lines now appear when the program actually finishes. + +## The structural gap: user code can still freeze the page + +`while true {}` in pasted code wedges the tab today: 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 - there is nothing to click. The bounded join covers the jobque +deadlock class only. + +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). From 35ce83492706ddbc402e7a73130360663b330114 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 08:13:59 -0700 Subject: [PATCH 03/11] review round: stderr mirrors at console.error; inset focus ring survives the Run button's overflow clip Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- site/playground/index.html | 2 +- site/playground/playground-runner.js | 2 +- site/playground/run-frame.html | 2 +- web/examples/ui/src/main.css | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/site/playground/index.html b/site/playground/index.html index 5381290aa9..247a113c85 100644 --- a/site/playground/index.html +++ b/site/playground/index.html @@ -114,7 +114,7 @@ - + diff --git a/site/playground/playground-runner.js b/site/playground/playground-runner.js index c3a984cc48..e363f073c4 100644 --- a/site/playground/playground-runner.js +++ b/site/playground/playground-runner.js @@ -13,7 +13,7 @@ (function () { "use strict"; - var FRAME_SRC = "run-frame.html?v=4"; + 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) diff --git a/site/playground/run-frame.html b/site/playground/run-frame.html index 0dc23109ef..359cd6197a 100644 --- a/site/playground/run-frame.html +++ b/site/playground/run-frame.html @@ -241,7 +241,7 @@ }, printErr: function (text) { if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(" "); - console.log("[das:err]", String(text)); + console.error("[das:err]", String(text)); post({ type: "stderr", text: String(text) }); }, onRuntimeInitialized: function () { post({ type: "ready" }); }, diff --git a/web/examples/ui/src/main.css b/web/examples/ui/src/main.css index ec06642549..c64057351f 100644 --- a/web/examples/ui/src/main.css +++ b/web/examples/ui/src/main.css @@ -103,6 +103,9 @@ footer_p { 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: ''; From 8418173dfbf10cad31855dbf641845e7f5af5abc Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 08:19:51 -0700 Subject: [PATCH 04/11] =?UTF-8?q?review=20round:=20an=20unknown=20Content-?= =?UTF-8?q?Length=20reports=20an=20indeterminate=20download=20-=20'loading?= =?UTF-8?q?=E2=80=A6',=20never=20a=20stuck=20or=20invented=20percentage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- site/playground/index.html | 4 ++-- site/playground/playground-runner.js | 8 +++++--- web/examples/ui/src/main.js | 16 +++++++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/site/playground/index.html b/site/playground/index.html index 247a113c85..4165a20ba0 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 e363f073c4..ca1dc6b458 100644 --- a/site/playground/playground-runner.js +++ b/site/playground/playground-runner.js @@ -88,9 +88,11 @@ transform: function (chunk, c) { arm(c); loaded += chunk.byteLength; - // No Content-Length (chunked encoding): stay indeterminate - // rather than inventing a percentage. - if (total > 0) reportProgress("download", Math.min(loaded / total, 1)); + // 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 () { diff --git a/web/examples/ui/src/main.js b/web/examples/ui/src/main.js index ffb5e20ac5..781023d807 100644 --- a/web/examples/ui/src/main.js +++ b/web/examples/ui/src/main.js @@ -514,7 +514,7 @@ function isWasmReady() { // 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 = 0; +var runtimeFraction = -1; // 0..1, or -1 = unknown (no tick yet / no Content-Length) var programBusy = null; // null | 'starting' (interpreter) | 'building' (wasm) var RUN_LABEL = '▶ run'; @@ -531,9 +531,15 @@ function paintRunButton() { runBtn.textContent = programBusy === 'building' ? 'building…' : 'running…'; } else if (loading) { if (runtimePhase === 'download') { - const pct = Math.round(runtimeFraction * 100); - runBtn.textContent = 'loading ' + pct + '%'; - runBtn.style.setProperty('--pg-progress', pct + '%'); + 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 { // compile / start: bytes are in, the tail is indeterminate but short runBtn.textContent = runtimePhase === 'compile' ? 'compiling…' : 'starting…'; @@ -548,7 +554,7 @@ function paintRunButton() { // Called by playground-runner.js on every download tick and phase change. window.pgRuntimeProgress = function (phase, fraction) { runtimePhase = phase; - runtimeFraction = fraction || 0; + runtimeFraction = (typeof fraction === 'number') ? fraction : -1; if (phase === 'ready' || phase === 'dead') updateButtonStates(); else paintRunButton(); }; From 66646c45aade7140e94743c58f617485c6da3bad Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 08:25:46 -0700 Subject: [PATCH 05/11] review round: revive reports an indeterminate download, not a full 'starting' bar Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- site/playground/index.html | 2 +- site/playground/playground-runner.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/site/playground/index.html b/site/playground/index.html index 4165a20ba0..da18d218db 100644 --- a/site/playground/index.html +++ b/site/playground/index.html @@ -114,7 +114,7 @@ - + diff --git a/site/playground/playground-runner.js b/site/playground/playground-runner.js index ca1dc6b458..d63b001afe 100644 --- a/site/playground/playground-runner.js +++ b/site/playground/playground-runner.js @@ -356,7 +356,10 @@ if (now - lastReviveAt < REVIVE_COOLDOWN_MS) return false; lastReviveAt = now; spareAborts = 0; - reportProgress("start", 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; From d32f453f3a08c768811931ed5602f860b70b2785 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 09:43:22 -0700 Subject: [PATCH 06/11] review round: the join throws only when nothing can notify; teardown drains before it frees; the busy latch keeps the kill switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local round's confirmed findings, one batch. Join (F1, woodpecker P1, Copilot r4): the 10s flat deadline could throw through with_wait_group's noexcept scope guards (std::terminate) and mis-kill long or starved jobs. Now the throw fires only on refCount()<=1 — appended-but-never-dispatched, the wedge class, where no guard and no later writer exist. Live work logs one stderr stall line and waits, as on native. New JobStatus::refCount(); a second @wasm spec pins the live-job half (a 12s job's join completes, no throw). Lifecycle (F9/F7/F2): loop teardown now runs shutdown() (exception printed, not swallowed) -> bounded 3s global-que drain -> Context delete -> deferred Module::Shutdown; a drain timeout deliberately leaks context and modules with a log line rather than freeing memory under running jobs or joining workers unbounded. New das::shutdown_job_que_bounded(). Verifier (F13): "[das:err] " console lines are the run frame's stderr mirror — recognized as program output, never page errors (isProgramStderrEcho + node:test). UI latch (F3/F4/F5/F11/F12): interpreter busy is visual-only — Run re-enables on the next spare and a click during a run is the kill switch master had; run tokens pin activity and the wasm build's clear to their own run; reset() notifies main.js so Clear always recovers; reentrancy guard on runCode/runTests; the rAF paint hop races a 100ms timeout so a hidden tab still dispatches. New run-button-state specs (paint contract runs in the per-PR no-WASM lane). Build system: the web superbuild wrote most archives into the repo's lib/, shared with the native build — each silently clobbered the other ("archive member '/' not a mach-o file"). Every wasm archive is now pinned into the web output dir. pages.yml's wasm cache key gains utils/daslang/** (the runtime's main() was uncovered). Docs per the flashlight rulings: the @wasm disclosure duty (site/tests/playground), tip-pinned suite-run rules with restatement (site, web/examples/ui), the deployed-only-lane evidence bucket (utils), README.md blessed as site's arch doc (skills/review_md.md carve-out); stale lane claims fixed, the spec-count deleted rather than gated, the ledger rewritten for the refcount design. LAWS.md entries carry the rulings. WASM-staged Playwright suite at this tip (local threaded wasm32 build + COOP/COEP server): 70 passed, 0 failed at --workers=4. Native tests/jobque: 262 passed, 1 skipped. dasweb-verify node:test: 21 passed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- .github/workflows/pages.yml | 2 +- .github/workflows/playground-e2e.yml | 5 +- include/daScript/misc/job_que.h | 3 + plans/playground_wedge_followups.md | 77 +++++----- site/LAWS.md | 6 + site/README.md | 13 +- site/REVIEW.md | 18 ++- site/playground/index.html | 4 +- site/playground/playground-runner.js | 19 ++- site/tests/playground/LAWS.md | 6 + site/tests/playground/REVIEW.md | 8 +- site/tests/playground/jobque-join.spec.js | 97 +++++++++---- .../tests/playground/run-button-state.spec.js | 91 ++++++++++++ skills/LAWS.md | 4 + skills/review_md.md | 5 +- src/builtin/REVIEW.md | 3 +- src/builtin/module_builtin_jobque.cpp | 51 +++++-- utils/LAWS.md | 6 + utils/REVIEW.md | 39 +++-- utils/daslang/main.cpp | 65 +++++---- .../dasweb-verify/browser/protocol.mjs | 10 ++ .../dasweb-verify/browser/protocol.test.mjs | 7 + .../internal/dasweb-verify/browser/runner.mjs | 10 +- web/CMakeLists.txt | 28 +++- web/examples/ui/LAWS.md | 1 + web/examples/ui/REVIEW.md | 12 +- web/examples/ui/src/main.js | 137 ++++++++++++------ 27 files changed, 529 insertions(+), 198 deletions(-) create mode 100644 site/tests/playground/LAWS.md create mode 100644 site/tests/playground/run-button-state.spec.js create mode 100644 utils/LAWS.md 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 index bda17cef26..3adf829e1f 100644 --- a/plans/playground_wedge_followups.md +++ b/plans/playground_wedge_followups.md @@ -1,48 +1,57 @@ # playground wedge handling - follow-ups and standing caveats -The wedge arc (deferred `Module::Shutdown` under a live browser loop + the bounded +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. -## Blast radius of the bounded join (`waitForJob`, module_builtin_jobque.cpp) +## 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 (those -always link `-pthread`). Native, single-threaded wasm, worker-thread joins, -`waitForJobWithTimeout`, and every Channel/LockBox/Stream wait are untouched. - -- **False-positive class: a single legitimately long job.** Progress is the remaining-count - moving, so ONE job that runs longer than the 10s window - or jobs starved behind another - dispatch's long jobs on the 4-worker wasm pool - trips "join deadlock avoided" on a join - that would have finished. Interpreted wasm is 10-50x slower than native, so a >10s single - job is reachable from user code. No shipped sample joins mid-run (the labs poll `isReady` - and only join at shutdown behind the epoch bail). If a legitimate trip ever surfaces: - soften to log-and-keep-waiting on the first window and throw on the second, or widen the - window - the constant is local to `waitForJob`. -- **Aftermath of a trip is bounded by refcounts.** Late-completing jobs land on a still-valid - status; `job_status_remove` refuses while refs are held. Worst case is a leaked JobStatus - plus its leak-report line - no use-after-free. - -## Blast radius of the deferred Module::Shutdown (utils/daslang/main.cpp) - -Emscripten `daslang` binary only, browser-loop path only. Non-loop wasm programs and the -compiled wasm64 cards (own entry point) are unchanged. - +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-loop path leaves modules alive on purpose (the next program is - about to compile), so run 2 calls `Module::Initialize` on already-initialized modules. - Believed idempotent; not exercised - the playground runs one program per frame, and the - frame refuses second runs. Verify before any embedder resurrects multi-run - daslang-in-wasm. -- The handle-leak dump for browser-loop programs moved from end-of-main (reporting off) to - loop end (reporting on) - leak lines now appear when the program actually finishes. + 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 today: 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 - there is nothing to click. The bounded join covers the jobque -deadlock class only. +`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 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..e6f6e2088c 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/index.html b/site/playground/index.html index da18d218db..edee7832f9 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 d63b001afe..af4f8d4377 100644 --- a/site/playground/playground-runner.js +++ b/site/playground/playground-runner.js @@ -18,6 +18,7 @@ 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; @@ -244,12 +245,14 @@ if (!rec) return; // First sign of life from the running program — output, a canvas, a drawn - // frame, an exit — ends the Run button's "starting…" state (main.js). - // The fps meter ticks from the moment the frame loads, so a bare fps - // message is NOT life; only one reporting actual draws (value > 0) is. + // 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); + if (typeof window.pgProgramActivity === "function") window.pgProgramActivity(msg.type, rec.runToken); } switch (msg.type) { @@ -391,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/tests/playground/LAWS.md b/site/tests/playground/LAWS.md new file mode 100644 index 0000000000..d6ba644801 --- /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 index d59f7185c8..2fff0d809e 100644 --- a/site/tests/playground/jobque-join.spec.js +++ b/site/tests/playground/jobque-join.spec.js @@ -2,12 +2,18 @@ // 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 bounds a no-progress join and throws a das exception the -// output pane can show. This spec IS the wedge regression: if the bound is -// lost, the run never produces the exception line and the test times out -// instead of the visitor's tab. +// 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). +// 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'); @@ -15,33 +21,74 @@ async function waitWasmReady(page) { await page.waitForFunction( () => !!(window.PlaygroundRunner && window.PlaygroundRunner.isReady()), null, - { timeout: 30_000 } + { timeout: 60_000 } ); } -// The join gives up after 10s without progress; compile + slack on top. +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(60_000); + test.setTimeout(240_000); await waitWasmReady(playground); - await playground.evaluate(() => { - window.code.getDoc().setValue([ - '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', - '}', - ].join('\n')); - }); + 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: 40_000 }); - // The page survived: the editor still answers and Run comes back. - await expect(playground.locator('#run')).toHaveText('▶ run', { timeout: 15_000 }); + .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..3d3078c3c7 --- /dev/null +++ b/site/tests/playground/run-button-state.spec.js @@ -0,0 +1,91 @@ +// 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 the + // paint would show '▶ run' regardless of phase. Real download ticks from the + // warming spare 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; }); + 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..c76c8fd62c 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/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 54d0431baf..858ee89289 100644 --- a/src/builtin/module_builtin_jobque.cpp +++ b/src/builtin/module_builtin_jobque.cpp @@ -1305,6 +1305,25 @@ 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 ) { + 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); @@ -1343,26 +1362,36 @@ namespace das { 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__) - // On the browser main thread an unbounded join IS the page: the thread that - // would repaint, deliver postMessage (the output pane) and service input is - // the one parked here, so a join whose jobs can never complete freezes the - // whole tab with no diagnostic. Join in slices and track progress — a busy - // join that keeps completing jobs waits as long as it needs to, while one - // that makes NO progress for the whole window becomes a das exception the - // page can report instead of a wedge. + // 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 ) { + if ( now < last ) { // only completions are progress; an append is not last = now; stalledMs = 0; - } else if ( (stalledMs += sliceMs) >= stallLimitMs ) { + 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) made no progress for %ds on the browser main thread", - int(now), stallLimitMs / 1000); + "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; diff --git a/utils/LAWS.md b/utils/LAWS.md new file mode 100644 index 0000000000..c169520913 --- /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 9891fc93a7..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,29 +314,34 @@ namespace { // in stop_browser_loop respects the same flag as the end-of-callMain dump. bool g_webloop_dump_leaks = true; - // main() returns while the browser loop still runs the program, so it must NOT - // tear down the module registry there: module destructors destroy live runtime - // state — ~Module_JobQue frees the persistent job que create_job_que() built in - // init(), so the first update()'s new_job threw "call create_job_que() first", - // and the shutdown() join then parked the browser main thread forever (the - // playground wedge). main() sets this instead, and the loop's NATURAL end - // (web_loop_tick) runs the deferred Module::Shutdown after the program's own - // shutdown(). The superseded path (next run's compile_and_run stops the loop) - // leaves modules alive on purpose — the next program is about to compile. + // 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 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; + // 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 @@ -344,6 +355,7 @@ namespace { JobStatus::DumpJobQueLeaks(); } } + return true; } void web_loop_tick ( void * arg ) { @@ -364,12 +376,13 @@ namespace { // void update(): runs until the page closes or the next run stops it. if ( keepGoing ) loop->ctx->collectHeapIfMostlyFree(); if ( !keepGoing ) { - stop_browser_loop(); - // The program has truly ended — run the Module::Shutdown that main() - // deferred while the loop was live (see g_webloop_defer_module_shutdown). + 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; - Module::Shutdown(g_webloop_dump_leaks); + if ( drained ) Module::Shutdown(g_webloop_dump_leaks); } } } @@ -1041,15 +1054,9 @@ 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 must NOT run here: module destructors tear - // down live runtime state (~Module_JobQue destroys the persistent job que the - // program's init() created), so it is deferred to the loop's natural end in - // web_loop_tick. We also 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". + // 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; 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/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.js b/web/examples/ui/src/main.js index 781023d807..4b2952dcbb 100644 --- a/web/examples/ui/src/main.js +++ b/web/examples/ui/src/main.js @@ -516,6 +516,10 @@ function isWasmReady() { 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'; @@ -540,10 +544,15 @@ function paintRunButton() { runBtn.textContent = 'loading…'; runBtn.style.removeProperty('--pg-progress'); } - } else { - // compile / start: bytes are in, the tail is indeterminate but short + } 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; @@ -559,20 +568,35 @@ window.pgRuntimeProgress = function (phase, fraction) { else paintRunButton(); }; -// Called by playground-runner.js on the running program's first message. -window.pgProgramActivity = function () { - if (!programBusy) return; +// 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, so without the rAF+timeout hop the button would never show it. +// 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 => requestAnimationFrame(() => setTimeout(resolve, 0))); + 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 @@ -606,15 +630,18 @@ function updateButtonStates() { // button on a dead page would make that state permanent. const runnerDead = typeof PlaygroundRunner !== 'undefined' && PlaygroundRunner.isDead && PlaygroundRunner.isDead(); - // While a program is starting/building, both buttons hold: a second click - // could not be serviced anyway (the compile owns this thread), and the busy - // stripe is the feedback. - if (runBtn) runBtn.disabled = !!programBusy - || (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 = !!programBusy || !(ready || runnerDead) || !hasTestAnnotation(); + if (testBtn) testBtn.disabled = !(ready || runnerDead) || !hasTestAnnotation(); paintRunButton(); } // Kept under the old name so playground-tabs.js's existing autosave hook @@ -642,24 +669,32 @@ 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; + activeRunToken = PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets); + 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. - // No "compiling…" line in the output pane: the pane is the PROGRAM's output - // (tests and the sample verifier read it), and the busy Run button already - // carries the status. - showCanvas(false); - const assets = await collectAssetUrls(); - await markProgramBusy('starting'); - PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets); } // Invoke dastest against the current main.das. `[test]` functions in the file @@ -669,18 +704,26 @@ 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; + activeRunToken = PlaygroundRunner.run( + collectProgramFiles(), + ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'], + assets); + dispatchingRun = false; + } finally { + runEntryBusy = false; } - syncUrlToState(); - showCanvas(false); - const assets = await collectAssetUrls(); - await markProgramBusy('starting'); - PlaygroundRunner.run( - collectProgramFiles(), - ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'], - assets); } // Minimal wasi_snapshot_preview1 shim — daslang STANDALONE_WASM output only @@ -808,7 +851,9 @@ async function runWasm() { return; } // 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. + // 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')); @@ -825,8 +870,10 @@ async function runWasm() { } catch (e) { printOutput('wasm build error: ' + (e && e.message ? e.message : e), '#ff2d2d'); } finally { - programBusy = null; - updateButtonStates(); + if (programBusy === 'building' && myBuild === buildSeq) { + programBusy = null; + updateButtonStates(); + } } } From 840b60ca7bb6cdb997a08313cc95a317ed3dea85 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 09:48:13 -0700 Subject: [PATCH 07/11] =?UTF-8?q?woodpecker=20r3:=20the=20bounded=20teardo?= =?UTF-8?q?wn=20flushes=20batched=20jobs=20before=20it=20drains=20?= =?UTF-8?q?=E2=80=94=20an=20unflushed=20batch=20would=20outlive=20the=20fr?= =?UTF-8?q?eed=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- src/builtin/module_builtin_jobque.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/builtin/module_builtin_jobque.cpp b/src/builtin/module_builtin_jobque.cpp index 858ee89289..bc08a9cefe 100644 --- a/src/builtin/module_builtin_jobque.cpp +++ b/src/builtin/module_builtin_jobque.cpp @@ -1310,6 +1310,8 @@ namespace das { // 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); From 9c2cebe5ffffbe6441aecbcc28f3c6d40f06369f Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 09:50:59 -0700 Subject: [PATCH 08/11] =?UTF-8?q?paint=20spec:=20pin=20isDead=20too=20?= =?UTF-8?q?=E2=80=94=20the=20no-WASM=20lane's=20dead=20runner=20also=20byp?= =?UTF-8?q?asses=20the=20loading=20paint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- site/tests/playground/run-button-state.spec.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/site/tests/playground/run-button-state.spec.js b/site/tests/playground/run-button-state.spec.js index 3d3078c3c7..2e56a106e0 100644 --- a/site/tests/playground/run-button-state.spec.js +++ b/site/tests/playground/run-button-state.spec.js @@ -15,11 +15,15 @@ async function waitWasmReady(page) { } test('pgRuntimeProgress paints percent, indeterminate, and phase labels', async ({ playground }) => { - // Pin the loading state: with a staged runtime the spare is ready and the - // paint would show '▶ run' regardless of phase. Real download ticks from the - // warming spare 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; }); + // 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); From f1646bd59669fe6a51b80e618c745d16f230f4eb Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 09:55:07 -0700 Subject: [PATCH 09/11] =?UTF-8?q?copilot=20round:=20the=20dispatch=20flag?= =?UTF-8?q?=20clears=20in=20a=20finally=20=E2=80=94=20a=20throwing=20run()?= =?UTF-8?q?=20must=20not=20suppress=20pgProgramStopped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- site/playground/index.html | 2 +- web/examples/ui/src/main.js | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/site/playground/index.html b/site/playground/index.html index edee7832f9..0e6bc87fa6 100644 --- a/site/playground/index.html +++ b/site/playground/index.html @@ -118,7 +118,7 @@ - + diff --git a/web/examples/ui/src/main.js b/web/examples/ui/src/main.js index 4b2952dcbb..59a4324f0f 100644 --- a/web/examples/ui/src/main.js +++ b/web/examples/ui/src/main.js @@ -690,8 +690,11 @@ runCode = async function() { const assets = await collectAssetUrls(); await markProgramBusy('starting'); dispatchingRun = true; - activeRunToken = PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets); - dispatchingRun = false; + try { + activeRunToken = PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets); + } finally { + dispatchingRun = false; + } } finally { runEntryBusy = false; } @@ -716,11 +719,14 @@ runTests = async function() { const assets = await collectAssetUrls(); await markProgramBusy('starting'); dispatchingRun = true; - activeRunToken = PlaygroundRunner.run( - collectProgramFiles(), - ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'], - assets); - dispatchingRun = false; + try { + activeRunToken = PlaygroundRunner.run( + collectProgramFiles(), + ['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'], + assets); + } finally { + dispatchingRun = false; + } } finally { runEntryBusy = false; } From f3b236f43e6603d9a929f09d29dfe43bf4e28582 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 10:39:33 -0700 Subject: [PATCH 10/11] md ascii gate: ci/fix_md_ascii.py over the arc's new markdown Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- plans/playground_wedge_followups.md | 14 +++++++------- site/REVIEW.md | 4 ++-- site/tests/playground/LAWS.md | 4 ++-- skills/LAWS.md | 4 ++-- utils/LAWS.md | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/plans/playground_wedge_followups.md b/plans/playground_wedge_followups.md index 3adf829e1f..6288387d26 100644 --- a/plans/playground_wedge_followups.md +++ b/plans/playground_wedge_followups.md @@ -12,17 +12,17 @@ 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 + `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 + 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** — +- **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. @@ -31,19 +31,19 @@ Channel/LockBox/Stream wait are untouched. 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 + 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 + 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 + (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 + `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 diff --git a/site/REVIEW.md b/site/REVIEW.md index e6f6e2088c..c0373355ad 100644 --- a/site/REVIEW.md +++ b/site/REVIEW.md @@ -3,7 +3,7 @@ **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 that a -visitor navigates to, together with what the scripts it loads render into it — not 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 @@ -69,7 +69,7 @@ recorded mid-branch describes a tree that no longer ships. 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 any source compiled into the WASM runtime (`daslang_static` — its `main()` +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 diff --git a/site/tests/playground/LAWS.md b/site/tests/playground/LAWS.md index d6ba644801..5071dec369 100644 --- a/site/tests/playground/LAWS.md +++ b/site/tests/playground/LAWS.md @@ -1,6 +1,6 @@ -# LAWS — site/tests/playground (append-only intent provenance) +# LAWS - site/tests/playground (append-only intent provenance) -- 2026-08-31 — `REVIEW.md`: Boris ruled ("yes", review-round flashlight item 1) to add the +- 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/skills/LAWS.md b/skills/LAWS.md index c76c8fd62c..eabca6ccb9 100644 --- a/skills/LAWS.md +++ b/skills/LAWS.md @@ -10,6 +10,6 @@ compacted, or cited as rules. | 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 — +- 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/utils/LAWS.md b/utils/LAWS.md index c169520913..e2be6614db 100644 --- a/utils/LAWS.md +++ b/utils/LAWS.md @@ -1,6 +1,6 @@ -# LAWS — utils (append-only intent provenance) +# LAWS - utils (append-only intent provenance) -- 2026-08-31 — `REVIEW.md`: review-round flashlight item 3. Boris ruled ("agree") that a test +- 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. From 4eae6d132a985b3eeda76d0a64343b658ca2132c Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 31 Aug 2026 10:43:48 -0700 Subject: [PATCH 11/11] preflight grows an md-ascii fast gate - the Markdown ASCII lane was the one extended_checks step with no local mirror Runs ci/fix_md_ascii.py --check when the diff touches any .md; this PR's CI red is the incident that proved the gap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc --- skills/internal/preflight.md | 1 + utils/internal/preflight/main.das | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) 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/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") {