Seven defects a real application hits before it renders - #89
Merged
Merged
Conversation
Boa defaults to 512 nested calls and a 10240-slot value stack, which at roughly seven slots a frame is what actually runs out first. Both are a sandbox's numbers. A framework spends the frames before the page's own code runs: a Solid application renders its component tree as nested calls and threads each through the reactive graph's owner chain, so depth follows how deeply the page is nested rather than anything an author would call recursion. Measured on support.cafe, driven headlessly through the new chuzz host: the home page renders and following the link to /login throws RuntimeLimitError: reached the maximum number of recursive calls part-way through the render. Nothing in that route recurses. With the limits below it renders with no errors at all. The error is not catchable either. It unwinds the execution rather than arriving as a JavaScript exception, so the page's own error boundary never sees it and the only trace is a line in the host's log; what a person sees is a fragment of a page. Both limits move together, because raising one alone does not help: with only the call limit raised the stack ran out at 1462 frames and reported "reached the maximum stack size", naming the wrong thing. The pair reaches about 8000 frames, the range browsers are in, for 1.6 MB of value stack. Runaway recursion still stops, with the same error. Measured by recursing from a page and reporting the deepest frame reached: 511 with the defaults, 1462 with only the call limit raised, 8191 with both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing a submit button called `submit_form` directly, and pressing Enter in a form's only field did the same. No `submit` event was ever dispatched, so a page's own handler never ran and `preventDefault` had nothing to prevent -- `apply_generated_text_input_event` even carried a TODO saying so. Every framework form in every application therefore navigated the browser to the form's action instead of running its own code. For a single-page application that is a full reload: measured on 24x.ai's sign-in, pressing Continue left for `/login?username=abc` and came back with the field empty and the step reset, which reads as a login form that does nothing. The press now dispatches a cancelable, bubbling `submit` at the form and the submission is that event's default action, so it runs only if no listener prevented it. The queue in the event driver already had the shape this needs: listeners first, default afterwards, skipped when cancelled. Three tests, because two of them pass for the wrong reason alone. A handler that runs while the navigation happens anyway is still broken; a navigation that stops because the event was never dispatched is the bug being replaced; and a form nobody listens to still has to submit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A workspace version bump is the release trigger, so this is what carries the submit event and the call-depth limits to the consumers that need them: chuzz cannot serve a fleet site without either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JS wrapper cache is weak, and an `on<event>` handler was an ordinary
property of the wrapper rather than of the node. So a page that created an
element, wired it up and dropped its own reference lost the handler to the
collector while the element was still in the document. In a browser the
element owns that property and the document owns the element, so it lives
as long as the element is in the tree.
Every CDN loader is exactly this shape: create a script, set `onload`,
append it, return, and wait on the promise the handler resolves. Measured
on nofilter.io's bootstrap: the bundle was fetched and ran, 568 nodes were
built, and only the `load` acknowledgement was dropped, because the
collector had taken the wrapper during the second the fetch took. The
loader then never removed its own `body{display:none}` and the site read
as one that renders nothing, with no error anywhere to say otherwise. A
small script loaded fine, which is what made it look like a size limit.
`on<event>` becomes an accessor on the node prototype. The handler is
stored on the instance under a hidden key, and assigning one roots the
wrapper for as long as the node is in the document; a handler assigned
before insertion is rooted by the insertion instead. Reading is unchanged:
`el.onclick` answers with what was assigned or `null`, and `'onclick' in
el` is still true, which is what frameworks probe for.
A detached node is deliberately not rooted, or this would keep alive
exactly what the weak cache exists to release. There is a test for that as
well as for the two orders of assignment.
`error` joins the event list. It was missing, so `'onerror' in el` was
false, which is the same probe with a different answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pathscale
force-pushed
the
feat/script-recursion-limit
branch
from
September 8, 2026 10:49
c00b98c to
6bb46f8
Compare
`@pathscale/ui`'s metal-border effect asks a canvas for a WebGL context
during render and already carries the guard for not getting one:
t = c.getContext("webgl", {...}) ?? c.getContext("experimental-webgl");
if (!t) throw Error("metal-fx: WebGL not supported");
The method did not exist, so the call threw `TypeError: not a callable
function` before the `??` could run and the guard never got its turn.
Under Solid 2 an error with no boundary above it halts the reactive
system permanently, so consulting.parcle.ai painted once and then
answered nothing: no effect, no handler, no later render.
Null is not a stub standing in for a canvas implementation. It is what
the specification says an implementation answers for a context
identifier it does not support, and what a browser answers when WebGL
is unavailable. A page that handles "no WebGL" now gets to handle it.
One prototype backs every element here, so this is reachable on a
`<div>` too. It answers null there as well, which is as close as a
shared prototype gets to a method only canvases have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every endpoint-libs backend in the fleet is TLS 1.3 only, and macOS
native-tls offers a TLS 1.2 ClientHello with no `supported_versions`
extension. The handshake is refused before a byte of HTTP, and the
server names the reason:
peer is incompatible: SupportedVersionsExtensionRequired
Measured against auth-dev.honey.id, api-dev.honey.id, auth.honey.id,
api.honey.id and api.support.cafe, all of which refuse TLS 1.2 outright.
The one fleet backend the engine could reach, api.crates.vip, is the one
where Fly terminates TLS rather than the app. A browser that cannot
reach the sites it renders is not a browser.
`rustls` also brings `rustls-platform-verifier`, so trust anchors come
from the operating system rather than a bundle compiled in. A
certificate the user's machine trusts, including one an employer
installed, is one this engine trusts too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`boa_runtime` implements `URL`, `TextEncoder` and `TextDecoder`, and nothing registered them. Only `Console` was, so a page received a JavaScript engine with a console and no web platform around it. The failure this came from does not look like a missing global. `@solidjs/router` constructs a `URL` during module evaluation, so a router-based application does not break when you navigate: it throws before the first component renders. What you see is a blank page and one line in the log naming the entry chunk, which is why this survived so long. Registered per extension rather than through `boa_runtime::register`, which would also install its own `setTimeout`/`setInterval` over the ones in `timers.rs` that are wired to this document's event loop. `URLSearchParams` is still missing, and `URL.searchParams` still throws "not implemented" from `boa_runtime` itself. Both need fixing upstream in the fork rather than papered over here.
…cept clicks
`element.rs` defined accessors for `src`, `value`, `checked`, `type` and the
rest, but never for `href`, so `a.href` read `undefined` and only
`getAttribute("href")` worked.
That is enough to break client-side routing entirely. `@solidjs/router` 2.0
exports no link component; it intercepts in-app navigation with a `document`
level click listener that reads `a.href` and hands it to `new URL(href)` with
no base argument. With `href` undefined the listener returned at its own guard
before reaching `preventDefault`, so blitz ran the anchor's default action and
every in-app navigation became a full document load: a fresh script context, a
re-bootstrapped application, and every WebSocket the page held closed with it.
Measured against honey.id's platform navigation, each press cost a reload and
a lost session; the same presses now settle in 111ms to 268ms.
Everything else the interception needs was already right, which is why this was
the only missing piece: `document` is on the propagation chain, `composedPath`,
`nodeName` and `nodeType` are all present, and default actions are already
gated on cancellation.
The accessor resolves against the document URL rather than reflecting the raw
attribute. Reflecting it would only move the failure one step along, into
`new URL("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/platform/users")`, which throws without a base. A `<base>` element
is not consulted yet and is noted in the comment as the remaining gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seven defects, all found by driving the fleet's built sites through the headless host, and every one of them invisible to the tests either repository had. Collected into a single pull request because they release together: a workspace version bump is the trigger, and no consumer wants seven of them.
Ordered by how early a page hits them.
The engine could not reach a TLS 1.3 server
Every endpoint-libs backend in the fleet is TLS 1.3 only, and macOS native-tls offers a TLS 1.2 ClientHello with no
supported_versionsextension. The handshake is refused before a byte of HTTP:peer is incompatible: SupportedVersionsExtensionRequired.Measured against auth-dev.honey.id, api-dev.honey.id, auth.honey.id, api.honey.id and api.support.cafe. The one fleet backend the engine could reach, api.crates.vip, is the one where Fly terminates TLS rather than the app.
rustlsalso bringsrustls-platform-verifier, so trust anchors come from the operating system rather than a bundle compiled in.A page received a JavaScript engine with no web platform around it
boa_runtimeimplementsURL,TextEncoderandTextDecoderand nothing registered them; onlyConsolewas.@solidjs/routerconstructs aURLduring module evaluation, so a router-based application does not break when you navigate — it throws before the first component renders. A blank page and one log line naming the entry chunk, which is why this survived so long.URLSearchParamsis still missing andURL.searchParamsstill throws "not implemented" fromboa_runtimeitself. Both belong upstream in the fork.A real render ran out of call frames
Boa defaults to 512 nested calls and a 10240-slot value stack, which at roughly seven slots a frame is what actually runs out first. A Solid application renders its component tree as nested calls and threads each through the reactive graph's owner chain, so depth follows page nesting rather than anything an author would call recursion.
support.cafe's
/loginthrewRuntimeLimitErrorpart-way through the render, leaving 15 nodes where there should be 87. It is not catchable: it unwinds the execution rather than arriving as an exception, so the page's error boundary never sees it.Both limits move together. Raising one alone moved the failure to 1462 frames and reported "reached the maximum stack size", which names the wrong thing. Runaway recursion still stops, and
runaway_recursion_still_stopspins that.An
on<event>handler died while its element livedThe JS wrapper cache is weak and an
on<event>handler was a property of the wrapper, not of the node. A page that created an element, wired it up and dropped its own reference lost the handler to the collector while the element was still in the document.Every CDN loader is that shape: create a script, set
onload, append it, wait on the promise the handler resolves. Measured on nofilter.io's bootstrap — the bundle was fetched and ran, 568 nodes were built, and only theloadacknowledgement was dropped. The loader never removed its ownbody{display:none}and the site read as one that renders nothing. A small script loaded fine, which is what made it look like a size limit.on<event>is now an accessor on the node prototype, and assigning one roots the wrapper for as long as the node is in the document. A detached node is deliberately not rooted, or this would keep alive exactly what the weak cache exists to release.errorjoins the event list; it was missing, so'onerror' in elwas false.getContextdid not exist, so a guard never got its turn@pathscale/ui's metal-border effect asks a canvas for WebGL during render and already carries the guard for not getting one. The method did not exist, so the call threwTypeError: not a callable functionbefore the??could run. Under Solid 2 an error with no boundary above it halts the reactive system permanently, so consulting.parcle.ai painted once and then answered nothing.Null is not a stub standing in for a canvas implementation: it is what the specification says an implementation answers for a context identifier it does not support.
A page could not handle its own form submission
Pressing a submit button called
submit_formdirectly, and Enter in a form's only field did the same. Nosubmitevent was dispatched, so a page's handler never ran andpreventDefaulthad nothing to prevent —apply_generated_text_input_eventcarried a TODO saying so.Measured on 24x.ai's sign-in: pressing Continue left for
/login?username=abcand came back with the field empty and the step reset. The press now dispatches a cancelable, bubblingsubmitand the submission is that event's default action.Three tests, because two of them pass for the wrong reason alone.
Anchors had no
hrefIDL property, so client-side routing was impossibleelement.rsdefined accessors forsrc,value,checked,typeand the rest, and never forhref.a.hrefreadundefined; onlygetAttribute("href")worked.@solidjs/router2.0 exports no link component. It intercepts in-app navigation with a document-level click listener that readsa.hrefand hands it tonew URL(href)with no base. Withhrefundefined the listener returned at its own guard before reachingpreventDefault, so every in-app navigation became a full document load: a fresh script context, a re-bootstrapped application, and every WebSocket the page held closed with it.Measured on honey.id after the fix: a navigation that had been a 25-second timeout became 111–268ms, and a suite of 103 checks across roughly twenty pages now runs on one document load.
🤖 Generated with Claude Code