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