playground: fix the interpreter jobque wedge; Run button carries load/run status - #3913
Conversation
… learns to talk 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
…D follow-up Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
This PR addresses a wedged-playground failure mode specific to Emscripten pthread builds by deferring module shutdown while a browser main loop is still live, and by bounding main-thread join waits to avoid freezing the page. It also improves the playground UI by making Run reflect runtime load progress and early-run “starting” status, plus adds a regression test for the wedge.
Changes:
- Defer
Module::Shutdownfor browser-loop programs until the loop naturally ends (Emscripten path). - Bound
waitForJobon the browser main thread (pthread builds) to throw after sustained no-progress instead of blocking forever. - Add Run-button load/busy indicators, console mirroring for frame output, and a Playwright regression spec.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| web/examples/ui/src/main.js | Run button state machine: load-progress + “starting/building” busy indicator integration. |
| web/examples/ui/src/main.css | CSS for Run-button progress fill and busy stripe animation. |
| web/.gitignore | Ignore threaded wasm/playground build outputs (*_mt). |
| utils/daslang/main.cpp | Defer module shutdown while a web main loop is active; perform shutdown at loop end. |
| src/builtin/module_builtin_jobque.cpp | Bound main-thread join in waitForJob (Emscripten pthread builds) to avoid wedging. |
| site/tests/playground/jobque-join.spec.js | New @wasm regression: never-completing join must report deadlock rather than freeze. |
| site/playground/run-frame.html | Mirror stdout/stderr to the browser console for post-mortem visibility. |
| site/playground/playground-runner.js | Report runtime load phases/progress to host UI; detect first program activity. |
| site/playground/index.html | Bump cache-busting query params for updated assets. |
| site/playground/forge-skin.css | Theme styling for the new Run-button loading/busy states. |
| plans/playground_wedge_followups.md | Ledger of follow-ups/caveats for the wedge fixes and their blast radius. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ves the Run button's overflow clip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
web/examples/ui/src/main.js:554
pgRuntimeProgresscoerces an unknown/omitted fraction to 0 viafraction || 0. When the runner reports an indeterminate download (e.g. chunked encoding with no Content-Length), this forces the UI into a fake 0% state instead of allowing an indeterminate rendering.
window.pgRuntimeProgress = function (phase, fraction) {
runtimePhase = phase;
runtimeFraction = fraction || 0;
if (phase === 'ready' || phase === 'dead') updateButtonStates();
else paintRunButton();
};
…load - 'loading…', never a stuck or invented percentage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
web/examples/ui/src/main.js:662
markProgramBusy('starting')is awaited aftercollectAssetUrls(). SincecollectAssetUrls()performs a network fetch for the assets sidecar, a slow/stalled fetch can still leave the UI with no immediate “running…” feedback after the Run click. Start the busy state first, and overlap the assets fetch with the rAF paint hop so the button updates immediately.
const assets = await collectAssetUrls();
await markProgramBusy('starting');
PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets);
web/examples/ui/src/main.js:683
- Same as
runCode:markProgramBusy('starting')is awaited aftercollectAssetUrls(), so an assets sidecar fetch can still leave the UI without immediate “running…” feedback. Start the busy state first and overlap the fetch with the paint hop.
const assets = await collectAssetUrls();
await markProgramBusy('starting');
PlaygroundRunner.run(
collectProgramFiles(),
['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0'],
assets);
…tarting' bar Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
web/examples/ui/src/main.js:662
runCodeawaitscollectAssetUrls()before settingprogramBusy. That yields the event loop with Run/Test still enabled, so a quick double-click can start multiple concurrent runs. Also, ifPlaygroundRunner.run(...)throws synchronously,programBusywill remain set and keep the buttons disabled.
This issue also appears on line 676 of the same file.
showCanvas(false);
const assets = await collectAssetUrls();
await markProgramBusy('starting');
PlaygroundRunner.run(collectProgramFiles(), ['main.das'], assets);
web/examples/ui/src/main.js:680
runTestsawaitscollectAssetUrls()before settingprogramBusy, leaving a window where a second click can start another run concurrently. In addition, ifPlaygroundRunner.run(...)throws, the busy state is never cleared and the buttons can remain disabled.
syncUrlToState();
showCanvas(false);
const assets = await collectAssetUrls();
await markProgramBusy('starting');
PlaygroundRunner.run(
…drains before it frees; the busy latch keeps the kill switch 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
…ains — an unflushed batch would outlive the freed context Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
…sses the loading paint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
web/examples/ui/src/main.js:723
- Same
dispatchingRunissue asrunCode: ifPlaygroundRunner.run(...)throws,dispatchingRunstays true and later frame resets won't clear the "running…" busy state viapgProgramStopped(). Wrap the run call intry/finally.
…() must not suppress pgProgramStopped Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
web/examples/ui/LAWS.md:10
- Spelling: "ammend" should be "amend" in the quoted text (even if it's paraphrasing), so searches/quotations stay consistent.
| 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 |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
…he 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
utils/internal/dasweb-verify/browser/protocol.mjs:47
isProgramStderrEchocurrently requires a trailing space in the prefix ("[das:err] "), but the emitter in site/playground/run-frame.html logs asconsole.error("[das:err]", ...)(multiple console args). Whether a space appears becomes dependent on the browser/Playwright stringification of console arguments, which can cause mirrored program stderr to be misclassified as a page error. Make the prefix match robustly by checking only the stable tag prefix.
export function isProgramStderrEcho(text) {
return String(text).startsWith('[das:err] ');
}
Behavior change (emscripten pthread builds only): a
joinon the browser main thread throws "join deadlock avoided" when nothing can ever complete it — remaining jobs with no live holder. A join held by live work waits as long as the work needs, logging one stall line after 10 s. Native and single-threaded wasm are unchanged.The nightly playground wedges were one lifecycle bug. On the web a graphics program runs as a browser loop.
callMainreturns while the loop is live. daslang'smain()then ranModule::Shutdown, and~Module_JobQuedestroyed the persistent job que the program'sinit()had just created. The firstupdate()tick threw fromnew_job. The exception stopped the loop, andshutdown()'sjoinparked the browser main thread forever, waiting for jobs that were never pushed. Same-origin frames share the tab's thread, so the whole page froze, and the exception text sat in a postMessage queue nothing would ever drain.The fix is three-layered.
main()no longer tears down modules while a browser loop is live; the loop's natural end runs the script'sshutdown()(exceptions printed, not swallowed), a bounded 3 s drain of the program's jobs, the Context delete, then the deferredModule::Shutdown— and a drain timeout deliberately leaks the context and modules with a log line rather than freeing memory under running jobs.waitForJobon the browser main thread joins in slices and throws only onrefCount() <= 1: every dispatched job and thread holds a ref via the capture macros, so the only throwable state is appended-but-never-dispatched — the wedge class — where no scope guard can terminate and no late notifier can write into a dead frame. And the run frame mirrors program stderr to the devtools console, with the sample verifier taught that mirror is program output, not a page error.The playground UI grows the indicators it was missing. The Run button is the runtime-load progress bar ("loading N%", indeterminate "loading…" without a Content-Length), then an animated "running…" stripe until the program first shows life — visual only: Run re-enables as soon as the next frame is ready, so a click during a run remains the kill switch, and run tokens keep an outgoing frame or a superseded wasm build from clearing a state it does not own.
Where to look:
src/builtin/module_builtin_jobque.cpp(waitForJob,shutdown_job_que_bounded),utils/daslang/main.cpp(loop teardown order),site/playground/playground-runner.js+web/examples/ui/src/main.js(run tokens, busy states),web/CMakeLists.txt(every wasm archive now pinned out of the sharedlib/),site/tests/playground/{jobque-join,run-button-state}.spec.js.Validation, claims, ledger
Validation
--workers=4, including both halves of the join contract — the no-holder join throws, and a join held by a live 12 s job completes without tripping the bound (that spec fails against the pre-fix runtime, red-first). The per-PR CI lane is no-WASM; the@wasmspecs run nightly against the DEPLOYED site, so they stay red until the rebuilt runtime ships (site/tests/playground/REVIEW.mdnow carries this disclosure duty).tests/jobque: 262 passed, 1 skipped, at the tip. dasweb-verifynode:test: 21 passed. Verified in Chromium against the tip build: path tracer 120 fps interpreted with Run re-enabled mid-run, deliberate-deadlock probe reports the exception with the page alive.utils/REVIEW.mdnow names this evidence bucket).Claims — stated, not tested
callMainin one wasm instance while the first run's loop is live reachesModule::Initializeon live modules (g_envTotaldrifts). No current embedding can do it; ledgered.Not done
PROXY_TO_PTHREADarc,plans/playground_wedge_followups.md.daslang_static(its cache key now coversutils/daslang/**); the UI half deploys with the site.🤖 Generated with Claude Code
https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc