Skip to content

playground: fix the interpreter jobque wedge; Run button carries load/run status - #3913

Merged
borisbat merged 11 commits into
masterfrom
bbatkin/playground-jobque-wedge
Aug 31, 2026
Merged

playground: fix the interpreter jobque wedge; Run button carries load/run status#3913
borisbat merged 11 commits into
masterfrom
bbatkin/playground-jobque-wedge

Conversation

@borisbat

@borisbat borisbat commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Behavior change (emscripten pthread builds only): a join on 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. callMain returns while the loop is live. daslang's main() then ran Module::Shutdown, and ~Module_JobQue destroyed the persistent job que the program's init() had just created. The first update() tick threw from new_job. The exception stopped the loop, and shutdown()'s join parked 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's shutdown() (exceptions printed, not swallowed), a bounded 3 s drain of the program's jobs, the Context delete, then the deferred Module::Shutdown — and a drain timeout deliberately leaks the context and modules with a log line rather than freeing memory under running jobs. waitForJob on the browser main thread joins in slices and throws only on refCount() <= 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 shared lib/), site/tests/playground/{jobque-join,run-button-state}.spec.js.

Validation, claims, ledger

Validation

  • WASM-staged Playwright suite at the branch tip (local threaded wasm32 build, COOP/COEP server): 70 passed, 0 failed at --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 @wasm specs run nightly against the DEPLOYED site, so they stay red until the rebuilt runtime ships (site/tests/playground/REVIEW.md now carries this disclosure duty).
  • Native tests/jobque: 262 passed, 1 skipped, at the tip. dasweb-verify node: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.
  • Full local review round ran (grounding, five surfacers, TDD + five checklist auditors, adversarial prover): 8 confirmed findings, all fixed in this batch; 3 demoted, 1 rejected. Three external review rounds: r1/r2's long-job P1 is resolved by the refcount design; r3's P1 (batched jobs unflushed before the bounded drain) verified and fixed (840b60c).
  • The deferred-shutdown branch's executed evidence is the manual Chromium lab verification above plus the nightly sample verifier cells post-deploy — the join specs' single-shot programs do not reach it (utils/REVIEW.md now names this evidence bucket).

Claims — stated, not tested

  • A second callMain in one wasm instance while the first run's loop is live reaches Module::Initialize on live modules (g_envTotal drifts). No current embedding can do it; ledgered.
  • The busy stripe animating through the synchronous compile rests on compositor-driven transforms; verified by eye, not by a spec.

Not done

  • A join held by genuinely stuck work still freezes the tab (console stall line only), as do compute loops and blocking Channel pops — the structural fix is the PROXY_TO_PTHREAD arc, plans/playground_wedge_followups.md.
  • Production picks up the C++ half when pages.yml rebuilds daslang_static (its cache key now covers utils/daslang/**); the UI half deploys with the site.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc

borisbat and others added 2 commits August 31, 2026 07:04
… 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
Copilot AI lite review requested due to automatic review settings August 31, 2026 14:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Shutdown for browser-loop programs until the loop naturally ends (Emscripten path).
  • Bound waitForJob on 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.

Comment thread site/playground/run-frame.html Outdated
Comment thread web/examples/ui/src/main.css
…ves the Run button's overflow clip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
Copilot AI review requested due to automatic review settings August 31, 2026 15:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • pgRuntimeProgress coerces an unknown/omitted fraction to 0 via fraction || 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();
};

Comment thread site/playground/playground-runner.js
Comment thread web/examples/ui/src/main.js Outdated
…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
Copilot AI review requested due to automatic review settings August 31, 2026 15:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 after collectAssetUrls(). Since collectAssetUrls() 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 after collectAssetUrls(), 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);

Comment thread site/playground/playground-runner.js
…tarting' bar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc
Copilot AI review requested due to automatic review settings August 31, 2026 15:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • runCode awaits collectAssetUrls() before setting programBusy. That yields the event loop with Run/Test still enabled, so a quick double-click can start multiple concurrent runs. Also, if PlaygroundRunner.run(...) throws synchronously, programBusy will 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

  • runTests awaits collectAssetUrls() before setting programBusy, leaving a window where a second click can start another run concurrently. In addition, if PlaygroundRunner.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(

Comment thread src/builtin/module_builtin_jobque.cpp
borisbat and others added 2 commits August 31, 2026 09:43
…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
Copilot AI review requested due to automatic review settings August 31, 2026 16:48
…sses the loading paint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dispatchingRun issue as runCode: if PlaygroundRunner.run(...) throws, dispatchingRun stays true and later frame resets won't clear the "running…" busy state via pgProgramStopped(). Wrap the run call in try/finally.

Comment thread web/CMakeLists.txt
Comment thread web/examples/ui/src/main.js Outdated
Copilot AI review requested due to automatic review settings August 31, 2026 16:52
…() must not suppress pgProgramStopped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6gNeDZkF14X81rLz3QLc

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 |

Copilot AI review requested due to automatic review settings August 31, 2026 16:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 31, 2026 17:39
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.

Comment thread utils/daslang/main.cpp
Copilot AI review requested due to automatic review settings August 31, 2026 17:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • isProgramStderrEcho currently requires a trailing space in the prefix ("[das:err] "), but the emitter in site/playground/run-frame.html logs as console.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] ');
}

@borisbat
borisbat merged commit ed4d2fe into master Aug 31, 2026
58 of 59 checks passed
@borisbat
borisbat deleted the bbatkin/playground-jobque-wedge branch August 31, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants