Research only, no engine change. This is the pre-proposal groundwork for giving the Endor/Ironhorse debugger protocol a real distinction between "break on caught exceptions" and "break on uncaught exceptions", per the maintainer directive of 2026-07-28: it should be obvious from the stack whether there is a catch above the throw.
Every claim below is grounded in code read at a named location. Two claims are backed by an executed probe (a scratch crate outside the repo that links ironhorse-compile and ironhorse-vm by path; no engine file was modified). Claims I could not establish are listed at the end rather than guessed.
0. Precondition: the debugger row is not currently on the branch
The brief for this investigation places the endor-debug crate at rust/engine/endor-debug on the head of #600. It is not there now.
- PR 600 head is
33c68104b3067d0dae205f9c9f74905ad746d7ee (gh pr view 600), which is the commit checked out for this investigation.
rust/engine at that commit contains ironhorse-262, ironhorse-compile, ironhorse-fuzz, ironhorse-regexp, ironhorse-snapshot, ironhorse-vm, and xs-oracle. There is no debug crate, and DebugHook does not appear anywhere under rust/engine.
- The three debugger slice commits still exist as unreachable objects in a clone that fetched the older branch state:
2b6a8d70701c4d7a0cdc77042ed2715b1398f961 (slice 1, protocol core), 6bac90c2219b13af8b10c24daf0b49a2ad677c48 (slice 2, VM seam), 8024ee3f55c8aa2cee21185ac61740973fb93661 (slice 3, lifecycle tests). git branch -a --contains reports no ref for any of them, and their merge base with the current head is 00a04f5b4 (2026-07-18).
So the whole stage 8 and stage 9 line, debugger included, left the branch. This investigation reads the slice code from those unreachable commits, which is legitimate for research, but nothing proposed here can land until that work is back on a ref. Recovering the debugger row is the first follow-up.
1. Current behavior, precisely
C-XS at the pinned reference
Pin: c/moddable gitlink 23b4d6b0a65f35209d9118c4c13c6c9b3e68784d (Moddable 8.3.1), per git submodule status c/moddable.
The exceptions pseudo-breakpoint is one machine-wide boolean. In fxSetBreakpoint:
if ((theID == 0) && (theLine == 0)) {
if (!c_strcmp(thePath, "exceptions")) {
the->breakOnExceptionsFlag = 1;
mxPushUndefined();
return;
}
if (!c_strcmp(thePath, "start")) {
the->breakOnStartFlag = 1;
...
fxClearBreakpoint mirrors it. Note the guard: the pseudo-path is recognized only when both id and line are zero.
fxDebugThrow consults that single flag and nothing else:
if (the->debugEval)
return;
if (fxIsConnected(the) && (the->breakOnExceptionsFlag))
fxDebugLoop(the, path, line, message);
else { ... fxReportException(...); }
Wire form, exactly as the endo client sends it:
<set-breakpoint path="exceptions" line="0"/>
<clear-breakpoint path="exceptions" line="0"/>
and inside a bulk block as <breakpoint path="exceptions" line="0"/>.
The five call sites of fxDebugThrow are worth enumerating, because the set is not what one would assume:
XS_CODE_RETHROW (xsRun.c:1405) calls fxJump with no debug hook. That omission matters in section 2.
The break echo, fxDebugLoop, emits <break path="..." line="...">, and for message == "throw" it replaces the message with the rendered exception value (fxEchoException). So <break> carries only path and line, and the body is already spoken for by the exception text. There is no free field to put a caught/uncaught marker in.
What xsbug actually offers today
Not a guess: documentation/xs/xsbug.md states, "The Break preferences panel toggles the Break On Start and Break On Exceptions flags for all virtual machines." One switch, machine-wide, no caught/uncaught distinction, and it is the whole exception vocabulary the shipped GUI exposes.
What the Rust port parses
From the unreachable slice commits, rust/engine/endor-debug/src/command.rs has no exception-specific tag. The pseudo-breakpoint rides the ordinary breakpoint shape, and a unit test pins the wire form:
assert_eq!(
parse_all(b"<set-breakpoint path=\"exceptions\" line=\"0\"/>"),
vec![Command::SetBreakpoint { path: "exceptions".to_string(), line: 0, id: 0 }]
);
src/breakpoints.rs then special-cases the path into a break_on_exceptions: bool. Three divergences from the C source above:
- It does not require
line == 0 && id == 0, so <set-breakpoint path="exceptions" line="12"/> arms the pseudo-breakpoint where XS would set a real line breakpoint in a file named exceptions.
- The
start pseudo-breakpoint is not ported at all.
- Its module doc asserts a
path == "unhandled" pseudo-breakpoint exists in XS. It does not: unhandled does not occur anywhere in xsDebug.c at this pin.
And nothing fires it. Slice 2's own commit message names the gap: "break-on-uncaught firing (the exceptions pseudo-breakpoint is parsed + tabled, the interpreter-side firstJump-empty walk is deferred)". The brief for this investigation says slice 2 added the walk. It did not; it deferred it.
The client stack is already three-way, ahead of both engines
packages/daemon/src/debug-session.js (line 498) ships:
setExceptionBreakMode(mode) {
if (mode === 'all') { clear uncaughtExceptions; set exceptions }
else if (mode === 'uncaught') { clear exceptions; set uncaughtExceptions }
else { clear both }
}
packages/daemon/src/debugger.js exposes it on the CapTP Debugger exo (lines 49 and 137), and packages/chat has a panel that calls it. The tests (packages/daemon/test/debugger-captp.test.js lines 240 and 351) assert only the outbound string, never engine behavior.
Consequence on today's C-XS, read from the source above and not executed: uncaughtExceptions is not a recognized pseudo-path, so fxSetBreakpoint falls past the guard into fxNewNameC and registers an ordinary line-0 breakpoint on a source file named uncaughtExceptions, which nothing will ever hit, while the same call clears exceptions. Selecting 'uncaught' today silently disables exception breaking altogether.
There is also prior design work: designs/daemon-xs-worker-debugger.md has a section "Augmentation: Break on Uncaught Exceptions Only" proposing exactly breakOnUncaughtExceptionsFlag, the uncaughtExceptions path, and a firstJump walk. This investigation should be read as validating and correcting that section against the pinned source and against the Rust engine, not as new ground.
2. Can the stack answer it, and where
Yes, and in Ironhorse it is cheaper than in XS.
The live handler chain is Interp.jumps: Vec<CatchJump> (rust/engine/ironhorse-vm/src/interp.rs:3098), innermost last. Its own doc comment states the key structural fact:
An empty chain means the throw escapes every JS handler and propagates to the host boundary as Halt::Throw (the JS/host flag reduced to a structural predicate: every self.jumps entry is a JS jump, XS's jump->flag = 1; the host is the absence of a jump).
CatchJump (interp.rs:3144) records target_pc, stack_len, locals_len, id_map, call_depth, flag. It is pushed by XS_CODE_CATCH_1/2/4 (interp.rs:7161), popped by XS_CODE_UNCATCH (interp.rs:7185) and by unwind_to_jump (interp.rs:15705), which returns None exactly when the chain is empty.
The throw sites that consult it are XS_CODE_THROW (interp.rs:7203), XS_CODE_RETHROW (interp.rs:7223), and BRANCH_STATUS with a ResumeStatus::Throw (interp.rs:6765). Each does match self.unwind_to_jump() { Some(target) => ..., None => return Halt::Throw(...) }.
So the base predicate is !self.jumps.is_empty(): constant time, no allocation, no walk, and exact with respect to what the VM will actually do. XS needs a list walk testing jump->flag; Ironhorse needs a length check, because it has no host jumps in the chain at all.
The awkward cases, with verdicts
finally without catch: defeats the naive predicate. ironhorse-compile's code_try (rust/engine/ironhorse-compile/src/coder.rs:5015, the port of fxTryNodeCode) emits a single XS_CODE_CATCH_1 for a finally-only try, indistinguishable in kind from a real catch. Executed probe: try { throw 7 } finally { } still reaches the host as Halt::Throw("7"). The chain would have said "caught" while the exception was only transiting.
There is a cheap exact fix that requires no bytecode change: peek one byte at the handler's target_pc. Executed probe output, disassembling each CATCH_1 and reading the byte at its target:
try { throw 1 } catch (e) { } catch_1 @13 -> target 23: byte 41 = XS_CODE_CATCH (a real catch clause follows)
catch_1 @23 -> target 42: byte 79 = XS_CODE_EXCEPTION
try { throw 1 } finally { } catch_1 @13 -> target 26: byte 79 = XS_CODE_EXCEPTION (transit + rethrow)
try { throw 1 } catch (e) { } finally { } catch_1 @13 -> target 23: byte 41 = XS_CODE_CATCH
catch_1 @23 -> target 42: byte 79 = XS_CODE_EXCEPTION
This falls straight out of the coder's emission order: when a catch clause is present, the try body's CATCH targets a label placed immediately before the inner CATCH for the catch clause; with only a finally, it targets the label placed immediately before XS_CODE_EXCEPTION. So classify a handler as a genuine catch when code[target_pc] is in the CATCH_1/2/4 family, and as finally-only transit when it is XS_CODE_EXCEPTION, in which case keep scanning outward down the chain. The second row of the try/catch case shows the predicate is right for the inner jump too: a throw inside the catch clause is not caught there. This is deterministic because the coder is oracle-locked to fxTryNodeCode, and it is implementable identically in C-XS (jump->code points at the same bytecode), so it does not create a fork.
This is a better answer than the existing design section's suggestion of giving finally handlers flag == 2 from the compiler, which would perturb emitted bytecode and break the port's byte-identity acceptance bar.
A catch that rethrows: correct with no special handling, since the rethrow is a fresh throw with the chain already popped. But note the C-XS asymmetry above: XS_CODE_RETHROW is not hooked, so on C-XS a finally-transited exception produces no stop at all in an uncaught-only mode, whereas hooking RETHROW in Ironhorse would produce one. That is a divergence to declare deliberately, not to discover later.
Generator and async frames: exact at throw time, bounded by a known port gap. resume_generator and step_async record a jumps_base (interp.rs:7748 and 7881) but use it only to clean up after an escaping Halt; the chain consulted at a throw is the live one, so the predicate is exact inside a resumed body. The bound is that SavedFrame (interp.rs:3182) does not serialize live jumps across a yield: "A generator suspended inside a try ... is an honest named skip for now". XS does serialize and rebuild them (save at xsRun.c:1224, rebuild with flag = 1 at xsRun.c:700). So the answer is exact only where the port's generator surface is exact, which is a pre-existing constraint the debugger inherits rather than one it introduces.
Host and native frames between the throw and the nearest JS handler: no problem in Ironhorse, a real problem in C-XS. Ironhorse has no host jumps; run_callback (interp.rs:7580) installs no boundary, so a callback throw unwinds into the caller's JS catch and the predicate matches. In C-XS the analogue is a flag == 0 mxTry, and those are not uniform: the promise machinery swallows into a rejection (fxOnThenable catches and calls fxRejectException), while fxRunScript's mxCatch cleans up and calls fxJump to rethrow. A plain "any flag == 1 in the chain" walk therefore over-reports caught across a swallowing boundary and under-reports across a rethrowing one. Ironhorse's structural predicate does not inherit that ambiguity.
Promise rejection: confirmed out of scope, and this must be said in the UI vocabulary. XS tracks unhandled rejections in a separate weak list (fxAddUnhandledRejection, fxCheckUnhandledRejections) and only reports at drain or exit, via fxAbort(XS_UNHANDLED_REJECTION_EXIT). It never reaches fxDebugThrow. An unhandled rejection is not a stack-visible uncaught throw and no throw-time classification will find it.
The practical corollary for Endo code is the sharper point: a throw inside a promise reaction runs under an mxTry (ten sites in xsPromise.c), so on C-XS an "uncaught" mode that treats host boundaries as not-catching will still break on every handler throw that becomes a rejection, including ones handled downstream. That is precisely the noise the mode exists to remove. In Ironhorse this path is not implemented yet (interp.rs:10013 self-names Halt::Unsupported("promise:handler-throw")), so the engine gets to choose: when the promise reaction path lands, its host boundary should be visible to the classifier so a rejection-producing throw is not reported as uncaught.
Top-level and job-queue boundary: an empty chain at top level is exactly "uncaught", which is the stop we want.
Prerequisite gap found while checking this
Ironhorse's engine-raised errors do not go through the jump chain at all. Sites such as interp.rs:5149, 5179, 7507, and 7516 return Halt::Throw(...) straight out of dispatch, bypassing unwind_to_jump. Executed probe:
bare undefined fn call => Throw("call: not a function")
undefined fn call in try/catch => Throw("call: not a function")
The try/catch does not catch it. C-XS routes the same class through fxThrowMessage, which both calls fxDebugThrow and fxJumps into the chain. So a break-on-uncaught mode built on the jump chain will simply never see engine-raised errors until Ironhorse gains a raise path that unwinds. That is a VM parity item rather than a debugger item, but it bounds what the mode can deliver and belongs in the proposal's prerequisites.
3. Protocol shape
Three candidates:
- A. A second pseudo-breakpoint path,
<set-breakpoint path="uncaughtExceptions" line="0"/>.
- B. An attribute on the existing one,
<set-breakpoint path="exceptions" line="0" mode="uncaught"/>.
- C. Generalize
<breakpoint-condition>, making the exception mode one instance of a breakpoint-condition mechanism.
The wire grammar decides it. The xsbug command parser recognizes exactly three attribute names, path, line, and id (the XS_*_ATTRIBUTE cases in fxDebugParse, and the Attribute enum at command.rs:106 in the port); any other attribute name becomes Attribute::Unknown and its value is discarded byte by byte. So:
- Option B needs a parser change in both engines and is silently dropped by any client or engine that does not know it, which is the worst failure mode for a debugger control.
- Option A needs no parser change at all. It is one more string compare in
fxSetBreakpoint and in BreakpointTable::set. Both engines already route unknown paths through the ordinary breakpoint list, so an engine that does not implement it degrades to a harmless never-hit breakpoint rather than a misparse.
- Option C is the most work for the least fit. In C-XS a condition attaches to the most recently created breakpoint slot, and the pseudo-breakpoint deliberately creates no slot (
fxSetBreakpoint returns before fxNewSlot). Making the mode a condition means either giving the pseudo-breakpoint a real slot or special-casing the condition path, and the condition text then has to be evaluated in the VM at every throw. It is a reasonable future mechanism for real breakpoints; it is the wrong vehicle for a machine-wide mode.
Client compatibility. Under option A an unmodified xsbug keeps its exact present behavior, because it only ever sends path="exceptions". It cannot select uncaught-only without a client change, and that is unavoidable under any of the three: the mode has to come from somewhere. The endo client stack already speaks option A today, which means adopting it costs zero client work on the surface we control and leaves the GUI we do not control untouched.
How many modes. With two independent flags the reachable states are never, uncaught-only, and all. A caught-only mode (break on exceptions that a catch will receive, ignore the ones that escape) needs a third path, say caughtExceptions, or a mode attribute. It is the least-requested of the four, and the shipped client vocabulary (none | uncaught | all) is three-way. Recommendation: ship the three-way set, keep caughtExceptions as a named, trivially-addable fourth rather than building it unrequested. The proposal should state this explicitly, because the brief asks for a four-way choice and the honest answer is that the fourth is one more string compare whenever someone wants it.
Reporting the classification back. <break> has only path and line, and for a throw the body is already the rendered exception. A new attribute, <break path="..." line="..." caught="0">, is the natural place. It is backward compatible for the endo client: packages/daemon/src/debug-session.js parses attributes generically into el.attrs and reads only path and line, so an unknown attribute is ignored and surfacing it later is a one-line change. Tolerance in the xsbug GUI is unverified (see section 7).
4. Cost when disarmed
The classification runs inside the throw hook, which sits behind the same single dormant branch the stepping seam already established. In slice 2 that branch is self.debug.is_some() at the line, debugger, and file opcodes, with the commit's stated acceptance property being that a disarmed run stays computron-exact against the pre-debugger build.
For this feature the seam moves to the throw sites: XS_CODE_THROW, XS_CODE_RETHROW, BRANCH_STATUS with a throw status, and eventually the engine-raise path. So:
- Disarmed: one predictable, never-taken branch per executed throw opcode, and nothing anywhere else. Throws are rare compared with
line opcodes, so this is strictly cheaper than the seam already accepted for stepping. It stays a single dormant branch.
- Recommendation: test a plain
Copy mode field (a small enum on Interp) rather than Option<Box<dyn DebugHook>>::is_some() at the throw sites, so the check is a register compare and so an attached-but-mode-off debugger costs the same as no debugger.
- Armed: one
Vec::is_empty plus at most one array index per finally-only handler on the chain. No allocation, no walk in the common case, and the seam helpers never touch the meter, which is what preserves metering neutrality.
Evidence status: this is a design argument from the code, not a measurement. Slice 2's metering-neutrality claim for the stepping seam was backed by a curated compile-diff and a targeted equal-computron test; I did not re-run those, and no equivalent evidence exists for a throw-site seam because none is written yet. The proposal should carry the same acceptance property (equal computrons, armed and disarmed) as a required test rather than an assertion.
5. Parity
Adding a pseudo-breakpoint path is additive: an unmodified client never sends it, and breakOnExceptionsFlag semantics stay bit-identical, so the C-XS observable path is unperturbed. Two deliberate divergences and three pre-existing bugs:
Divergences to declare, not hide.
- Hooking
XS_CODE_RETHROW, which C-XS does not hook. Without it, an exception that transits a finally-only try produces no stop at all in uncaught mode. With it, Ironhorse stops where C-XS would not. Recommend hooking and documenting it as an improvement.
- The target-opcode peek makes Ironhorse's classification strictly better than the flag walk. It is implementable identically in C-XS, so the two can be kept in agreement without a compiler change on either side. That is the argument for the peek over the
flag == 2 compiler change the existing design section suggests, which would perturb emitted bytecode and break the oracle-locked byte-identity bar.
Pre-existing port bugs to fix while here. In BreakpointTable: the missing line == 0 && id == 0 guard, the un-ported start pseudo-breakpoint, and the module doc's reference to a "unhandled" pseudo-breakpoint that does not exist at this pin.
6. Recommendation
For the proposal to be written from, in dependency order:
- Recover the debugger row. Nothing here can land while the slices are unreachable from PR 600's head (section 0).
- Adopt option A, the
uncaughtExceptions pseudo-breakpoint, matching both the existing design section and the already-shipped client. Three modes: none, uncaught, all. Name caughtExceptions as the trivially-addable fourth.
- Classify at throw time, before unwinding, with
jumps.is_empty() plus the target-opcode peek for finally-only handlers. No bytecode change, no allocation.
- Hook the sites C-XS hooks, plus
RETHROW, and extend to the engine-raise path once native errors unwind through the chain (which is a prerequisite, not a nicety: without it the mode cannot see a single TypeError).
- Report the classification as a
caught attribute on <break>, ignored by unmodified clients.
- Fix the three
BreakpointTable parity nits.
- Do not claim promise coverage. Unhandled rejection is a separate mechanism. The UI wording should not let
uncaught be read as unhandled, and when the promise reaction path lands in Ironhorse its host boundary must be visible to the classifier so a rejection-producing throw is not misreported as uncaught.
- Carry the metering-neutrality acceptance property (equal computrons armed and disarmed) as a test, matching the row's standing bar.
A fix worth landing independently of all of the above: today's setExceptionBreakMode('uncaught') silently turns exception breaking off on C-XS (section 1). Either implement uncaughtExceptions in C-XS or make the client fall back to exceptions when the engine does not support the mode.
7. What I could not establish
- Whether the xsbug GUI tolerates an unknown attribute on
<break> or an unknown pseudo-breakpoint path. The xsbug application is not in the Moddable tree at this pin (only serial2xsbug and xsbug-log are), so I could not read its parser.
- Whether the
'uncaught' no-op on today's C-XS has been observed in practice. It is read from the pinned source, not executed.
- Why the stage 8 and stage 9 work left PR 600's branch. I observed only that it did.
Research only, no engine change. This is the pre-proposal groundwork for giving the Endor/Ironhorse debugger protocol a real distinction between "break on caught exceptions" and "break on uncaught exceptions", per the maintainer directive of 2026-07-28: it should be obvious from the stack whether there is a catch above the throw.
Every claim below is grounded in code read at a named location. Two claims are backed by an executed probe (a scratch crate outside the repo that links
ironhorse-compileandironhorse-vmby path; no engine file was modified). Claims I could not establish are listed at the end rather than guessed.0. Precondition: the debugger row is not currently on the branch
The brief for this investigation places the
endor-debugcrate atrust/engine/endor-debugon the head of #600. It is not there now.33c68104b3067d0dae205f9c9f74905ad746d7ee(gh pr view 600), which is the commit checked out for this investigation.rust/engineat that commit containsironhorse-262,ironhorse-compile,ironhorse-fuzz,ironhorse-regexp,ironhorse-snapshot,ironhorse-vm, andxs-oracle. There is no debug crate, andDebugHookdoes not appear anywhere underrust/engine.2b6a8d70701c4d7a0cdc77042ed2715b1398f961(slice 1, protocol core),6bac90c2219b13af8b10c24daf0b49a2ad677c48(slice 2, VM seam),8024ee3f55c8aa2cee21185ac61740973fb93661(slice 3, lifecycle tests).git branch -a --containsreports no ref for any of them, and their merge base with the current head is00a04f5b4(2026-07-18).So the whole stage 8 and stage 9 line, debugger included, left the branch. This investigation reads the slice code from those unreachable commits, which is legitimate for research, but nothing proposed here can land until that work is back on a ref. Recovering the debugger row is the first follow-up.
1. Current behavior, precisely
C-XS at the pinned reference
Pin:
c/moddablegitlink23b4d6b0a65f35209d9118c4c13c6c9b3e68784d(Moddable 8.3.1), pergit submodule status c/moddable.The exceptions pseudo-breakpoint is one machine-wide boolean. In
fxSetBreakpoint:fxClearBreakpointmirrors it. Note the guard: the pseudo-path is recognized only when bothidandlineare zero.fxDebugThrowconsults that single flag and nothing else:Wire form, exactly as the endo client sends it:
and inside a bulk block as
<breakpoint path="exceptions" line="0"/>.The five call sites of
fxDebugThroware worth enumerating, because the set is not what one would assume:XS_CODE_THROWXS_CODE_THROW_STATUSXS_CODE_BRANCH_STATUSfxThrow(thexsThrowC entry)fxThrowMessage(every engine-raised error)XS_CODE_RETHROW(xsRun.c:1405) callsfxJumpwith no debug hook. That omission matters in section 2.The break echo,
fxDebugLoop, emits<break path="..." line="...">, and formessage == "throw"it replaces the message with the rendered exception value (fxEchoException). So<break>carries onlypathandline, and the body is already spoken for by the exception text. There is no free field to put a caught/uncaught marker in.What xsbug actually offers today
Not a guess: documentation/xs/xsbug.md states, "The Break preferences panel toggles the Break On Start and Break On Exceptions flags for all virtual machines." One switch, machine-wide, no caught/uncaught distinction, and it is the whole exception vocabulary the shipped GUI exposes.
What the Rust port parses
From the unreachable slice commits,
rust/engine/endor-debug/src/command.rshas no exception-specific tag. The pseudo-breakpoint rides the ordinary breakpoint shape, and a unit test pins the wire form:src/breakpoints.rsthen special-cases the path into abreak_on_exceptions: bool. Three divergences from the C source above:line == 0 && id == 0, so<set-breakpoint path="exceptions" line="12"/>arms the pseudo-breakpoint where XS would set a real line breakpoint in a file namedexceptions.startpseudo-breakpoint is not ported at all.path == "unhandled"pseudo-breakpoint exists in XS. It does not:unhandleddoes not occur anywhere inxsDebug.cat this pin.And nothing fires it. Slice 2's own commit message names the gap: "break-on-uncaught firing (the exceptions pseudo-breakpoint is parsed + tabled, the interpreter-side firstJump-empty walk is deferred)". The brief for this investigation says slice 2 added the walk. It did not; it deferred it.
The client stack is already three-way, ahead of both engines
packages/daemon/src/debug-session.js(line 498) ships:packages/daemon/src/debugger.jsexposes it on the CapTPDebuggerexo (lines 49 and 137), andpackages/chathas a panel that calls it. The tests (packages/daemon/test/debugger-captp.test.jslines 240 and 351) assert only the outbound string, never engine behavior.Consequence on today's C-XS, read from the source above and not executed:
uncaughtExceptionsis not a recognized pseudo-path, sofxSetBreakpointfalls past the guard intofxNewNameCand registers an ordinary line-0 breakpoint on a source file nameduncaughtExceptions, which nothing will ever hit, while the same call clearsexceptions. Selecting'uncaught'today silently disables exception breaking altogether.There is also prior design work:
designs/daemon-xs-worker-debugger.mdhas a section "Augmentation: Break on Uncaught Exceptions Only" proposing exactlybreakOnUncaughtExceptionsFlag, theuncaughtExceptionspath, and afirstJumpwalk. This investigation should be read as validating and correcting that section against the pinned source and against the Rust engine, not as new ground.2. Can the stack answer it, and where
Yes, and in Ironhorse it is cheaper than in XS.
The live handler chain is
Interp.jumps: Vec<CatchJump>(rust/engine/ironhorse-vm/src/interp.rs:3098), innermost last. Its own doc comment states the key structural fact:CatchJump(interp.rs:3144) recordstarget_pc,stack_len,locals_len,id_map,call_depth,flag. It is pushed byXS_CODE_CATCH_1/2/4(interp.rs:7161), popped byXS_CODE_UNCATCH(interp.rs:7185) and byunwind_to_jump(interp.rs:15705), which returnsNoneexactly when the chain is empty.The throw sites that consult it are
XS_CODE_THROW(interp.rs:7203),XS_CODE_RETHROW(interp.rs:7223), andBRANCH_STATUSwith aResumeStatus::Throw(interp.rs:6765). Each doesmatch self.unwind_to_jump() { Some(target) => ..., None => return Halt::Throw(...) }.So the base predicate is
!self.jumps.is_empty(): constant time, no allocation, no walk, and exact with respect to what the VM will actually do. XS needs a list walk testingjump->flag; Ironhorse needs a length check, because it has no host jumps in the chain at all.The awkward cases, with verdicts
finallywithoutcatch: defeats the naive predicate.ironhorse-compile'scode_try(rust/engine/ironhorse-compile/src/coder.rs:5015, the port offxTryNodeCode) emits a singleXS_CODE_CATCH_1for a finally-only try, indistinguishable in kind from a real catch. Executed probe:try { throw 7 } finally { }still reaches the host asHalt::Throw("7"). The chain would have said "caught" while the exception was only transiting.There is a cheap exact fix that requires no bytecode change: peek one byte at the handler's
target_pc. Executed probe output, disassembling eachCATCH_1and reading the byte at its target:This falls straight out of the coder's emission order: when a catch clause is present, the try body's
CATCHtargets a label placed immediately before the innerCATCHfor the catch clause; with only a finally, it targets the label placed immediately beforeXS_CODE_EXCEPTION. So classify a handler as a genuine catch whencode[target_pc]is in theCATCH_1/2/4family, and as finally-only transit when it isXS_CODE_EXCEPTION, in which case keep scanning outward down the chain. The second row of the try/catch case shows the predicate is right for the inner jump too: a throw inside the catch clause is not caught there. This is deterministic because the coder is oracle-locked tofxTryNodeCode, and it is implementable identically in C-XS (jump->codepoints at the same bytecode), so it does not create a fork.This is a better answer than the existing design section's suggestion of giving finally handlers
flag == 2from the compiler, which would perturb emitted bytecode and break the port's byte-identity acceptance bar.A catch that rethrows: correct with no special handling, since the rethrow is a fresh throw with the chain already popped. But note the C-XS asymmetry above:
XS_CODE_RETHROWis not hooked, so on C-XS a finally-transited exception produces no stop at all in an uncaught-only mode, whereas hooking RETHROW in Ironhorse would produce one. That is a divergence to declare deliberately, not to discover later.Generator and async frames: exact at throw time, bounded by a known port gap.
resume_generatorandstep_asyncrecord ajumps_base(interp.rs:7748 and 7881) but use it only to clean up after an escapingHalt; the chain consulted at a throw is the live one, so the predicate is exact inside a resumed body. The bound is thatSavedFrame(interp.rs:3182) does not serialize live jumps across ayield: "A generator suspended inside atry... is an honest named skip for now". XS does serialize and rebuild them (save at xsRun.c:1224, rebuild withflag = 1at xsRun.c:700). So the answer is exact only where the port's generator surface is exact, which is a pre-existing constraint the debugger inherits rather than one it introduces.Host and native frames between the throw and the nearest JS handler: no problem in Ironhorse, a real problem in C-XS. Ironhorse has no host jumps;
run_callback(interp.rs:7580) installs no boundary, so a callback throw unwinds into the caller's JS catch and the predicate matches. In C-XS the analogue is aflag == 0mxTry, and those are not uniform: the promise machinery swallows into a rejection (fxOnThenablecatches and callsfxRejectException), whilefxRunScript'smxCatchcleans up and callsfxJumpto rethrow. A plain "anyflag == 1in the chain" walk therefore over-reports caught across a swallowing boundary and under-reports across a rethrowing one. Ironhorse's structural predicate does not inherit that ambiguity.Promise rejection: confirmed out of scope, and this must be said in the UI vocabulary. XS tracks unhandled rejections in a separate weak list (
fxAddUnhandledRejection,fxCheckUnhandledRejections) and only reports at drain or exit, viafxAbort(XS_UNHANDLED_REJECTION_EXIT). It never reachesfxDebugThrow. An unhandled rejection is not a stack-visible uncaught throw and no throw-time classification will find it.The practical corollary for Endo code is the sharper point: a
throwinside a promise reaction runs under anmxTry(ten sites inxsPromise.c), so on C-XS an "uncaught" mode that treats host boundaries as not-catching will still break on every handler throw that becomes a rejection, including ones handled downstream. That is precisely the noise the mode exists to remove. In Ironhorse this path is not implemented yet (interp.rs:10013 self-namesHalt::Unsupported("promise:handler-throw")), so the engine gets to choose: when the promise reaction path lands, its host boundary should be visible to the classifier so a rejection-producing throw is not reported as uncaught.Top-level and job-queue boundary: an empty chain at top level is exactly "uncaught", which is the stop we want.
Prerequisite gap found while checking this
Ironhorse's engine-raised errors do not go through the jump chain at all. Sites such as interp.rs:5149, 5179, 7507, and 7516
return Halt::Throw(...)straight out of dispatch, bypassingunwind_to_jump. Executed probe:The
try/catchdoes not catch it. C-XS routes the same class throughfxThrowMessage, which both callsfxDebugThrowandfxJumps into the chain. So a break-on-uncaught mode built on the jump chain will simply never see engine-raised errors until Ironhorse gains a raise path that unwinds. That is a VM parity item rather than a debugger item, but it bounds what the mode can deliver and belongs in the proposal's prerequisites.3. Protocol shape
Three candidates:
<set-breakpoint path="uncaughtExceptions" line="0"/>.<set-breakpoint path="exceptions" line="0" mode="uncaught"/>.<breakpoint-condition>, making the exception mode one instance of a breakpoint-condition mechanism.The wire grammar decides it. The xsbug command parser recognizes exactly three attribute names,
path,line, andid(theXS_*_ATTRIBUTEcases infxDebugParse, and theAttributeenum atcommand.rs:106in the port); any other attribute name becomesAttribute::Unknownand its value is discarded byte by byte. So:fxSetBreakpointand inBreakpointTable::set. Both engines already route unknown paths through the ordinary breakpoint list, so an engine that does not implement it degrades to a harmless never-hit breakpoint rather than a misparse.fxSetBreakpointreturns beforefxNewSlot). Making the mode a condition means either giving the pseudo-breakpoint a real slot or special-casing the condition path, and the condition text then has to be evaluated in the VM at every throw. It is a reasonable future mechanism for real breakpoints; it is the wrong vehicle for a machine-wide mode.Client compatibility. Under option A an unmodified xsbug keeps its exact present behavior, because it only ever sends
path="exceptions". It cannot select uncaught-only without a client change, and that is unavoidable under any of the three: the mode has to come from somewhere. The endo client stack already speaks option A today, which means adopting it costs zero client work on the surface we control and leaves the GUI we do not control untouched.How many modes. With two independent flags the reachable states are never, uncaught-only, and all. A caught-only mode (break on exceptions that a catch will receive, ignore the ones that escape) needs a third path, say
caughtExceptions, or a mode attribute. It is the least-requested of the four, and the shipped client vocabulary (none | uncaught | all) is three-way. Recommendation: ship the three-way set, keepcaughtExceptionsas a named, trivially-addable fourth rather than building it unrequested. The proposal should state this explicitly, because the brief asks for a four-way choice and the honest answer is that the fourth is one more string compare whenever someone wants it.Reporting the classification back.
<break>has onlypathandline, and for a throw the body is already the rendered exception. A new attribute,<break path="..." line="..." caught="0">, is the natural place. It is backward compatible for the endo client:packages/daemon/src/debug-session.jsparses attributes generically intoel.attrsand reads onlypathandline, so an unknown attribute is ignored and surfacing it later is a one-line change. Tolerance in the xsbug GUI is unverified (see section 7).4. Cost when disarmed
The classification runs inside the throw hook, which sits behind the same single dormant branch the stepping seam already established. In slice 2 that branch is
self.debug.is_some()at theline,debugger, andfileopcodes, with the commit's stated acceptance property being that a disarmed run stays computron-exact against the pre-debugger build.For this feature the seam moves to the throw sites:
XS_CODE_THROW,XS_CODE_RETHROW,BRANCH_STATUSwith a throw status, and eventually the engine-raise path. So:lineopcodes, so this is strictly cheaper than the seam already accepted for stepping. It stays a single dormant branch.Copymode field (a small enum onInterp) rather thanOption<Box<dyn DebugHook>>::is_some()at the throw sites, so the check is a register compare and so an attached-but-mode-off debugger costs the same as no debugger.Vec::is_emptyplus at most one array index per finally-only handler on the chain. No allocation, no walk in the common case, and the seam helpers never touch the meter, which is what preserves metering neutrality.Evidence status: this is a design argument from the code, not a measurement. Slice 2's metering-neutrality claim for the stepping seam was backed by a curated compile-diff and a targeted equal-computron test; I did not re-run those, and no equivalent evidence exists for a throw-site seam because none is written yet. The proposal should carry the same acceptance property (equal computrons, armed and disarmed) as a required test rather than an assertion.
5. Parity
Adding a pseudo-breakpoint path is additive: an unmodified client never sends it, and
breakOnExceptionsFlagsemantics stay bit-identical, so the C-XS observable path is unperturbed. Two deliberate divergences and three pre-existing bugs:Divergences to declare, not hide.
XS_CODE_RETHROW, which C-XS does not hook. Without it, an exception that transits a finally-only try produces no stop at all in uncaught mode. With it, Ironhorse stops where C-XS would not. Recommend hooking and documenting it as an improvement.flag == 2compiler change the existing design section suggests, which would perturb emitted bytecode and break the oracle-locked byte-identity bar.Pre-existing port bugs to fix while here. In
BreakpointTable: the missingline == 0 && id == 0guard, the un-portedstartpseudo-breakpoint, and the module doc's reference to a"unhandled"pseudo-breakpoint that does not exist at this pin.6. Recommendation
For the proposal to be written from, in dependency order:
uncaughtExceptionspseudo-breakpoint, matching both the existing design section and the already-shipped client. Three modes:none,uncaught,all. NamecaughtExceptionsas the trivially-addable fourth.jumps.is_empty()plus the target-opcode peek for finally-only handlers. No bytecode change, no allocation.RETHROW, and extend to the engine-raise path once native errors unwind through the chain (which is a prerequisite, not a nicety: without it the mode cannot see a singleTypeError).caughtattribute on<break>, ignored by unmodified clients.BreakpointTableparity nits.uncaughtbe read asunhandled, and when the promise reaction path lands in Ironhorse its host boundary must be visible to the classifier so a rejection-producing throw is not misreported as uncaught.A fix worth landing independently of all of the above: today's
setExceptionBreakMode('uncaught')silently turns exception breaking off on C-XS (section 1). Either implementuncaughtExceptionsin C-XS or make the client fall back toexceptionswhen the engine does not support the mode.7. What I could not establish
<break>or an unknown pseudo-breakpoint path. The xsbug application is not in the Moddable tree at this pin (onlyserial2xsbugandxsbug-logare), so I could not read its parser.'uncaught'no-op on today's C-XS has been observed in practice. It is read from the pinned source, not executed.