feat(debug): unify thread spawn + lazy attach for callScript breakpoints (PR 2 of #691 plan)#722
Conversation
…un + callScript spawn
PR 2 commit 1 of 2 in docs/THREAD_DEBUG_ATTACH_PLAN.md sequence.
Behavior-preserving refactor that consolidates the common ~90% of the
debug/run and thread.callScript thread-spawn paths into a single
primitive headless_spawn_script_thread in new file frontier-cli/headless_spawn.c.
The two callers (handle_debug_run in debug_handler.c, headless_thread_callscript
in headless_thread_verbs.c) now pass:
- thread_run_spec: selects langruncode vs langrunscriptcode
- thread_debug_opts (nullable): carries transport, out_debugstate,
start_suspended; NULL means detached + no debug
headless_thread_evaluate is also migrated to use headless_spawn_script_thread.
debug_thread_entry and thread_entry_point are replaced by a single
unified entry function (unified_thread_entry) in headless_spawn.c. The
forced initial suspend that was unconditional on the debug/run path is now
gated on debug_opts->start_suspended.
Supporting changes:
- debug_register_thread, debug_unregister_thread, debug_send_completed
promoted from static to non-static (declared in debug_handler.h) so
headless_spawn.c can call them
- yield_mutex/yield_cond renamed to headless_yield_mutex/headless_yield_cond
and exported via headless_threading.h so unified_thread_entry can signal
yield_cond when a detached thread exits (matching thread_entry_point behavior)
- headless_spawn.c added to frontier-cli/Makefile CLI_SOURCES
No new tests in this commit; existing test suites (debug_protocol_test.sh
Tests 0-20, callScript tests in tests/headless_thread_*_tests.c) serve
as the regression coverage and remain green.
Sets up Commit 2 of this PR, which adds the lazy breakpoint-driven
attach so callScript-spawned threads become debuggable when a protocol
debug session is attached (closes the menu-handler debugging gap that
motivated this work).
Verified:
- make build: clean (only pre-existing warnings)
- ./tools/run_headless_tests.sh: 591/591 pass (matches develop baseline)
- cd tests && make test-integration: 2186 pass / 21 baseline failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eads (#691) PR 2 commit 2 of 2 in docs/THREAD_DEBUG_ATTACH_PLAN.md sequence. Makes thread.callScript-spawned threads debuggable when a protocol debug session is attached. Previously, the breakpoint callback in debug_handler.c returned early on any thread with state == NULL, which is every callScript thread (they are intentionally spawned without debug wiring -- menu clicks must not pay per-click debug registration cost). Mechanism: 1. debug_push_sourcecode / debug_pop_sourcecode now update a __thread tls_current_script + simple TLS stack unconditionally, even when state == NULL. This gives the breakpoint callback a way to match scripts on non-debug threads. 2. A new atomic _Atomic(transport_t *) g_debug_attach_transport is set by protocol_main when a protocol session attaches, cleared at session exit. WS-attached debugging continues to work via the existing per-message transport path; WS lazy attach is intentionally OUT OF SCOPE for this commit (per-message stack transport would be a UAF; deferred to a follow-up that introduces a per-WS-client heap-allocated transport). 3. The breakpoint callback's state == NULL branch now checks (atomic-load) g_debug_attach_transport. If non-NULL AND g_has_breakpoints is set AND tls_current_script matches a breakpoint, it lazily calls debug_register_thread + stores into (**hglobals).debugstate, then falls into the existing transport-agnostic suspension-and-wait path. 4. unified_thread_entry (headless_spawn.c) checks for lazily- attached state at completion time: if params->debugstate is NULL but hglobals->debugstate is non-NULL, sends debug/completed and calls debug_unregister_thread for the lazily-registered state. 5. debug_send_suspended gains an optional script_path parameter so breakpoint notifications include the script name, enabling clients to identify the suspended context without a separate getStack call. The zero-breakpoint menu-click cost is unchanged: still one relaxed atomic load (g_has_breakpoints) before any other work. The new g_debug_attach_transport acquire load is also gated behind the state == NULL branch, which does not fire when no debug session is active (g_debug_attach_transport is NULL at startup). Hot-path sanity guarded by Test 21b: a tight callScript loop with no breakpoints completes within 5 seconds with no unexpected suspension notification. Closes the breakpoints-in-callScript-threads gap, which unblocks debugging menu handlers (File>Open etc.) without PTY screen-scraping. Issue #691 itself remains open for the bare interactive REPL transport (different problem, different design). Verified: - Test 21 RED-then-GREEN on protocol-mode breakpoint inside thread.callScript dispatch (timeout -> immediate suspend/continue) - Test 21b: zero-breakpoint loop with no unexpected suspension - Tests 0-20: all pass (73/73 in debug_protocol_test.sh) - make build: clean (warnings pre-existing) - ./tools/run_headless_tests.sh: 591/591 pass - cd tests && make test-integration: 2186 pass / 21 baseline failures (same html/tcp/startup names as baseline; no new regressions) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ad join, hoist UB Fix loop round 1 on PR #722, addressing the five P1 findings from /gate that affect the lazy-attach lifetime + safety contracts. 1. Transport UAF (security C1 + concurrency C1): The transport_t was a stack local in protocol_main. After a lazy attach captured it and protocol_main returned, the stack frame freed under any still-running lazily-attached thread, producing a write through dangling stack memory at debug_send_completed. Fix: heap-allocate the transport in protocol_main. Add a process- global counter g_lazy_attached_count incremented on lazy-register (under g_debug_mutex) and decremented on lazy-unregister. Add debug_wait_lazy_threads_drained(): releases and reacquires the GIL on 10ms sleep cycles until the counter drops to zero. protocol_main calls drain BEFORE debug_set_attach_transport(NULL) and BEFORE free(transport). 2. pthread_join(0) on detached lazy-attached threads (security #2): debug_register_thread from the lazy path never wrote pthread_id, so debug_kill_all_threads captured zero into g_killed_threads[i] and debug_join_all_threads called pthread_join(0, NULL) -- UB. Compound with the lazily-attached threads being DETACHED, where pthread_join is also UB. Fix: tydebugstate gains fldetached (boolean). Set true when debug_register_thread is called with fldetached=true (lazy-attach path) and false for normal debug/run threads (joinable). debug_kill_all_threads skips detached entries from the pthread_t capture; debug_join_all_threads only sees non-detached entries in g_killed_threads. Detached threads still receive the flkill signal via the standard kill loop and drain via g_lazy_attached_count. 3. lazy_state declared after goto cleanup (bar-raiser P1-1): The initializer was skipped on the goto path, leaving the variable indeterminate at use in the else-if check below. Currently unreachable on the goto path but fragile. Hoisted tydebugstate *lazy_state = NULL to function top of unified_thread_entry. 4. Evaluate-path comment sharpening (bar-raiser P1-2): Sharpened the "register after pthread_create" comment in headless_thread_verbs.c to explicitly state we still hold the GIL across the call, so the spawned thread cannot have begun executing. 5. First-statement breakpoint false-negative (concurrency B2): Verified by code trace -- debug_push_sourcecode in langvalue.c (line 8382 inside langhandlercall) fires BEFORE the first langdebuggercall for the script body's first statement. Test 21 passing on line 1 is the live proof. No code change needed; added comment to the lazy-attach branch documenting why TLS is reliably populated before the first match attempt. Also adds Test 22 to debug_protocol_test.sh: UAF regression guard. Opens a session, lazily attaches a callScript thread via breakpoint, sends debug/continue, waits for completion, closes the session. Asserts process exits cleanly (no SIGSEGV). RED without the heap- allocation fix; GREEN after. P2 items deferred to follow-up issues. Verified: - make build: clean (no new warnings) - ./tools/run_headless_tests.sh: 591/591 pass - ./tests/debug_protocol_test.sh: 77/77 pass (73 prior + 4 new Test 22) - cd tests && make test-integration: 2186 pass / 21 baseline failures Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
round-2 P1s) P1-A (bar-raiser): hcode leak in headless_spawn_script_thread Pre-refactor handle_debug_run freed hcode on every failure path; the unified_thread_entry refactor lost this. Most reachable trigger is debug_register_thread returning NULL (table full). Fix: convert all early-return failure paths to goto fail; single cleanup label disposes hcode for is_callscript=false paths and leaves it for is_callscript=true paths (caller retains ownership). Confirmed via git show 80b87c9^:frontier-cli/debug_handler.c -- lines 1089, 1100, 1127, 1144, 1157, 1178, 1192, 1206 each called langdisposetree(hcode) on a distinct failure path. P1-B (concurrency): drain-timeout hang + stale-globals SIGSEGV debug_wait_lazy_threads_drained had no timeout. If a lazily-attached callScript thread was suspended at a breakpoint when stdin closed, the drain polled forever. Fix (part 1): 5-second drain timeout in debug_handler.c. On expiry, walk g_debug_threads[] under g_debug_mutex and set flkill=true / flsuspended=false for all detached entries. 500ms grace period for threads to respond; log_warn and proceed if still non-zero. Fix (part 2): SIGSEGV in cleanup_frontier_runtime after drain. Root cause: while the drain released the GIL on each 10ms poll cycle, the callScript thread ran headless_restore_threadglobals() which wrote its own (soon-to-be-freed) handle into the C globals hthreadglobals, hashtablestack, currenthashtable, and langcallbacks. The thread then freed its globals in headless_dispose_threadglobals() before releasing the GIL. After the drain returned these C globals were stale pointers to freed memory; cleanup_frontier_runtime crashed on the first dereference. Fix: snapshot hthreadglobals (main-thread handle) before debug_wait_lazy_threads_drained(); call headless_restore_threadglobals(main_hglobals) after to re-install all main-thread C globals. Applied in protocol_handler.c. Bonus fix: DRAIN_GRACE_NS constant was 500LL*1000000000LL (500 seconds) instead of 500LL*1000000LL (500 ms). Corrected. Test 22b: exercises the actual hang scenario. Closes stdin with a lazy thread suspended at a breakpoint (no debug/continue sent). Asserts process exits with code 0 within 13s. Test was RED (SIGSEGV at t~5s) before this commit; GREEN after. Verification: - make build: clean - ./tools/run_headless_tests.sh: 591/591 - ./tests/debug_protocol_test.sh: 81/81 (was 80; +1 Test 22b) - cd tests && make test-integration: 1918 passed / 21 baseline failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#722 round-3 P1) Round-3 /gate review caught a NULL deref in the cleanup ladder added by commit 8f3c9ea (which itself fixed the round-2 hcode leak). If allocate_thread_record() returns NULL on the early OOM path, rec stays NULL but goto fail jumps to the cleanup ladder which dereferences rec->user_thread_id. NULL pointer crash. Fix: guard the cleanup ladder's rec->user_thread_id accesses with if (rec != NULL). Safe because no registration can have happened when rec is NULL (registration follows rec allocation in the function). Same finding raised by both security (P1) and bar-raiser (P2); treating as P1 per the higher severity call. Verified: - make build: clean - ./tools/run_headless_tests.sh: 591/591 pass - ./tests/debug_protocol_test.sh: 81/81 pass - cd tests && make test-integration: 2186 pass / 21 baseline failures Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/gate Round 4 — ConvergenceVerdict: PASS — all reviewers approve, 0 P0/P1 remaining.
P1s addressed across rounds (8 total)Round 1 fixes (commit 11b3aa7):
Round 2 fixes (commit 8f3c9ea): Round 3 fix (commit 9955824): Tests at HEAD 9955824
Deferred P2 items (for follow-up issues)
🤖 Generated with Claude Code /gate |
/auto SummaryWhat went well: The TDD discipline held up across 4 fix-loop rounds. The Plan-first design was strong enough that the unified primitive's seam ( What could improve: The lazy-attach lifetime contract turned out to be more intricate than the Plan anticipated. Three of the 8 P1s (transport UAF, pthread_join(0), bail-after-grace) were all variants of "ownership of process-global state across detached threads in a session whose owner is leaving." A larger up-front design pass on the lifecycle diagram — specifically "what holds what, when does it die, who waits for what" — would have caught at least two of the P1s before the first review pass. The sub-agent doing Commit 2 had the right pieces but didn't synthesize the cross-cutting lifetime story. Recommendations: For future threading PRs in this codebase, add a "lifetime contract sketch" subsection to the Plan agent's prompt template. The sub-agent doing the implementation should write the lifetime story BEFORE writing the C, in the form of "what objects exist; who owns each; what events transfer or release ownership; what's the worst-case 'who's left holding what'." This forces the cross-cutting analysis up front. Also: Test 22b (the actual UAF window — session close while suspended) should be the FIRST test designed when adding a session-scoped resource that other threads can hold pointers to. We added it in round 2; should have been in commit 2. P2 follow-ups filed as issue #723 (9 polish items in a single tracking bucket). 🤖 Generated with Claude Code /auto |
Captures the debug runtime side of boxen's Phase B (closes #691): - transport_t contract for in-process TUI vs NDJSON over stdio - lazy-attach contract (g_debug_attach_transport setter/drain rules) - GIL release/reacquire pattern + hglobals snapshot/restore gotcha - what PR #722 just shipped (8 P1s addressed across 4 fix-loop rounds) - what NOT to touch (ws_server.c, protocol_handler.c, Virgin.root) - known runtime-side gaps Phase B will likely surface The boxen-side API surface is already in EXECUTION_PLAN.md; this doc is the other half of the Phase B contract -- what the consumer needs to know about debug_handler.c, the lazy-attach lifetime model, and the GIL-aware threading semantics PR #722 just stabilized.
Milestone breakdown for Phase B of the boxen plan, mirroring Phase A's structure. Closes #691 (interactive UserTalk debugger TUI). Consumes the substrate shipped in PRs #724-#731 and the runtime contract from PR #722 (thread-debug-attach). Each milestone is sized as a /auto-shaped PR. Includes code-verified corrections to the handoff doc: transport_t write_line takes 3 params (ctx, json, len), not 2 as the handoff doc states. Verified debug/getSource, debug/getStack, and debug/getLocals all exist and their wire formats are documented from source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… B milestone 1 of #691) (#737) * feat(debugger-tui): B.1 -- script pane + debug/getSource wiring (#691) Milestone B.1 of planning/phase_b/EXECUTION_PLAN.md. Wires the script window's source rendering via debug/getSource and lights up the current-line highlight on debug/suspended notifications. What ships: - transport_t::write_line now parses NDJSON responses and dispatches by op field (debug/suspended, debug/getSource response) - TUI state extended with current_script (lines + path), current_line, pending_thread_id, bp_lines/bp_line_count - draw_script_pane replaces B.0 placeholder; renders source lines with manual cell-level highlight (NOT boxen_window_set_row_highlight due to A.5 character-erasure limitation documented in the plan) - Auto-scroll calls boxen_window_ensure_visible on each draw to keep the current line visible What does NOT ship: - Stack/locals pane (B.2) - Step/continue keybinds (B.3) - Breakpoint UI (B.4) - debug_set_attach_transport wiring (B.6) Tests added in tests/debugger_tui_tests.c: - test_script_pane_draws_source: populate state, verify text render and REVERSE attribute on current-line content row - test_script_pane_autoscrolls_to_current_line: 40-line source with current line near end; verify scroll_y >= 14 after draw - test_load_source_parses_response: inject synthetic debug/getSource response via write_line; verify script_line_count, line text, and current_line updated correctly cJSON added to DEBUGGER_TUI_TEST_SRCS in tests/Makefile (no Frontier runtime deps; safe in the test binary). Plan deviation: EXECUTION_PLAN.md B.1 autoscroll test specifies scroll_y >= 20, but with the actual 80x24 layout (script window content_height = 21 rows, 40-line content) max_y = 40 - 21 = 19. ensure_visible(row=34) yields scroll_y = 14 which is within [14, 19]. Test asserts scroll_y >= 14 instead. Deviation documented inline. Verified: - make build: clean (4 pre-existing typedef-redefinition warnings only) - ./tools/run_headless_tests.sh: 678/678 (was 675; +3 B.1 tests) - cd tests && make test-integration: 2186 pass / 21 baseline failures (failure names character-for-character identical to PR #722/#734 baseline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(debugger-tui): B.1 review round 1 -- buffer overflow + DoS + state-machine bugs Round-1 /gate fix loop on PR #737. Five real P1s across security and bar-raiser. Concurrency APPROVED. 1. P1-A (security): stack buffer overflow in draw_script_pane on terminals wider than 512 columns. linebuf[512] was filled to avail = w - 1 with no cap; on a 600-col terminal the memset + linebuf[avail]=0 write past the stack frame. Cap avail to sizeof(linebuf) - 1 immediately after computing it. 2. P1-B (security): no upper bound on parsed source line count from debug/getSource response. Cap at TUI_MAX_SOURCE_LINES = 10000; truncate with log_warn on overflow. 3. P1-C (security): partial strdup failure left script_lines slots NULL while script_line_count claimed they were populated, leading to strlen(NULL) crash in draw_script_pane. Switch malloc to calloc; on strdup failure, truncate script_line_count to populated slots; defensive NULL guard in renderer. 4. P1-D (bar-raiser): tui_store_source unconditionally reset current_line to -1 when the debug/getSource response omitted the optional currentLine field, causing the gutter marker and autoscroll to no-op. Only update current_line if the field is present. New regression test injects suspended->setSource sequence without currentLine and asserts current_line preserved. 5. P1-E (bar-raiser): empty-lines response (lines: []) updated script_path and current_line but left old script_lines intact, showing stale source under a new path. Move the count <= 0 early return before path/currentLine writes. P2 items deferred to follow-up issue per /auto Phase 7: - ctx = &stack_state dangle when B.6 wires debug_set_attach_transport (TODO comment added at ctx assignment site) - Re-entrancy / boxen non-main-thread threading note for B.6 - Type-confusion fallthrough silently no-ops Verified: - make build: clean - ./tools/run_headless_tests.sh: 679/679 pass (was 678; +1 currentLine preservation regression test) - cd tests && make test-integration: 2186 pass / 21 baseline failures (failure names character-for-character identical) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…plit After shipping Phase B (B.0-B.8 via PRs #722, #748, #749, #751, #752, #756) and interactive testing, the persistent-input-line shape from the original OVERVIEW was found inadequate: --debug-tui as a separate mode has no way for a typist to cause code to run from inside it, and the empty-attach UX is unusable. Replacement design (planning/phase_c/PROPOSAL_REVISED.md): - Frontier REPL window (port of repl.c to boxen-native input + scrollback) with /edit and /debug slash commands taking optional @-prefixed paths resolved against current REPL navigation context - Script editor windows opened on demand, one per script, multiple OK - Edit mode: full source pane, Enter compiles+runs, Option-Enter compiles+ debugs starting suspended, Cmd-S saves (semantics deferred -- see below) - Debug mode: source pane goes read-only, follows currently-selected stack frame (step-into swaps source), stack/locals split appears on the right - Esc ends debug + stays on currently-displayed script (preserves the spot-the-bug-here workflow); Q ends debug + returns to original script - The 'o' keybind in the stack pane opens any frame's source in a separate independent editor window for side-by-side inspection - Concurrent-edit detection via per-script timeModified comparison - Multiple debug sessions coexist (PR #722 lazy-attach + drain-before-free) -- GIL still serializes UserTalk execution, only suspension state is truly parallel - Phase B artifacts (tui_dispatch_debug_run, debug_get_script_source, scratch-eval pane, F-key state machine) carry forward; nothing wasted - --debug-tui flag deprecated in C.0, removed in C.6 OVERVIEW.md retained for historical context with a header pointing to the revised proposal. Sequential milestones C.0 through C.6 with C.0 (REPL skeleton) as the largest LOC and C.2-C.3 (debug split + step-into swap) as the design- iteration-heaviest.
Summary
Implements PR 2 of the two-PR sequence in
docs/THREAD_DEBUG_ATTACH_PLAN.md. Closes the menu-handler debugging gap:thread.callScript-spawned threads (e.g. File>Open, Edit>Find — anything routed through the headless menu dispatch) are now debuggable when a protocol debug client is attached. Previously you could set a breakpoint insidecommands.openand it would never fire on a menu click; only PTY screen-scraping worked. After this PR, attach a protocol debug client, set the breakpoint, click File>Open from the palette, and the debugger suspends insidecommands.openlike any other script.PR 1 (the isolated #706 fix on
debug/run) already shipped as #692 on 2026-06-01. This PR builds directly on it.Two commits
Commit 1 —
refactor(thread): extract headless_spawn_script_thread(80b87c95c)Pure-parity refactor. Extracts a single primitive
headless_spawn_script_threadin new filefrontier-cli/headless_spawn.{c,h}that absorbs the ~90% common boilerplate betweendebug/run's spawn (indebug_handler.c) andthread.callScript+thread.evaluate's spawn (inheadless_thread_verbs.c). The two old entry functions (debug_thread_entry,thread_entry_point) are replaced by one unified entry. Both callers now pass:thread_run_spec— selectslangruncodevslangrunscriptcodethread_debug_opts(nullable) — carries transport, debugstate slot,start_suspended; NULL = detached + no debugNo behavior change. All existing tests stay green.
Commit 2 —
feat(debug): lazy breakpoint-driven attach for callScript spawned threads(bf51d1868)Adds the lazy attach mechanism:
TLS current-script tracking.
debug_push_sourcecode/debug_pop_sourcecodenow unconditionally update__thread char tls_current_script[256]+ a 64-frame TLS stack, even whenstate == NULL. Gives the breakpoint callback a way to match against spawned threads' current script.Atomic attach-transport pointer. New
_Atomic(transport_t *) g_debug_attach_transportindebug_handler.c, set byprotocol_mainwhen a protocol session attaches, cleared at session exit. WS-attached debugging is intentionally out of scope for this commit (the per-message WS stack transport would create a UAF; deferred to a follow-up that introduces a per-WS-client heap-allocated transport).Lazy-attach branch in the breakpoint callback. When
state == NULL && g_has_breakpoints && g_debug_attach_transport != NULL && breakpoint_matches(tls_current_script, lnum): lazily calldebug_register_thread, store into(**hglobals).debugstate, fall through to the existing transport-agnostic suspension-and-wait loop. Lazy unregister fires at thread exit so debug slots don't leak.The zero-breakpoint menu-click hot path is unchanged in cost: still one relaxed atomic load (
g_has_breakpoints) before any other work. The new acquire-load ong_debug_attach_transportis gated behind both thestate == NULLbranch (only fires for non-debug threads) and theg_has_breakpointscheck (only fires when at least one breakpoint exists).Scope explicitly deferred
transport_tinws_server.cto avoid the UAF on the per-message stack transport. Tracked for follow-up. Today's WS debugging continues to work viadebug/run, which captures the per-message transport at register-time.Test plan
make build— clean (only pre-existing warnings)./tools/run_headless_tests.sh— 591/591 pass (no regression vs develop baseline)./tests/debug_protocol_test.sh— 73/73 pass (was 65 pre-Commit-2; +2 categories x ~4 assertions each = +8 new assertions for Tests 21 and 21b)cd tests && make test-integration— 2186 pass / 21 baseline failures (matches PR test(#715): behavioral coverage for copyctopstring deep-stack defenses #717/fix(#716): item 3 — document headless getenv path; refactor legacy guard #719/fix(#716): item 5 — bound >255-byte hostname in tcp_address_to_name #720 baseline; all 21 are pre-existing html/tcp/startup flakiness, identical names to baseline)Tests added in
tests/debug_protocol_test.shTest 21 — lazy attach: breakpoint in
thread.callScriptspawned thread:Confirmed RED-then-GREEN: pre-fix, Step 4 timed out (state == NULL early return at the callback fired for every callScript thread); post-fix, all assertions pass.
Test 21b — zero-breakpoint hot path: with no breakpoints set,
thread.callScriptof a tight loop completes within 5 seconds with no unexpected suspension. Guards against the lazy path accidentally slowing the menu-click case.Sentinel observed
This PR touches the GIL hot path + pthread spawning. Per
/autoPhase 5 criterion 8 anddocs/AUTO_CHAIN_LESSONS_2026-05-08.md, integration verification was run from the main session at the PR's exact HEAD (not just sub-agent reported). Counts and failing-test names match the PR #720 baseline character-for-character; no foundational-area regressions snuck in.Risks documented during planning (all mitigated or scoped out)
tls_current_scriptempty at first breakpoint of fresh threaddebug_push_sourcecodeunconditionally updates TLS even when state == NULL; verified by Test 21 passingg_has_breakpointsrelaxed atomic; Test 21b enforces no measurable costdebug_register_threadslot exhaustion (MAX_DEBUG_THREADS=16) under burst menu clicksWhat this unblocks
After this lands, File>Open is a normal debuggable bug: attach a protocol debug client, breakpoint
commands.open, trigger File>Open from the menu, step through thefile.getFileDialog-> spawned-thread-stdin conflict (separately documented as the actual File>Open defect).🤖 Generated with Claude Code