Skip to content

Seven defects a real application hits before it renders - #89

Merged
pathscale merged 8 commits into
masterfrom
feat/script-recursion-limit
Sep 9, 2026
Merged

pathscale merged 8 commits into
masterfrom
feat/script-recursion-limit

Conversation

@pathscale

@pathscale pathscale commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

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_versions extension. 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. rustls also brings rustls-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_runtime implements URL, TextEncoder and TextDecoder and nothing registered them; only Console was.

@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. A blank page and one log line naming the entry chunk, which is why this survived so long.

URLSearchParams is still missing and URL.searchParams still throws "not implemented" from boa_runtime itself. 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 /login threw RuntimeLimitError part-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.

deepest frame reached
defaults 511
call limit raised only 1462
both raised 8191

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_stops pins that.

An on<event> handler died while its element lived

The 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 the load acknowledgement was dropped. The loader never removed its own body{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. error joins the event list; it was missing, so 'onerror' in el was false.

getContext did 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 threw TypeError: not a callable function before 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_form directly, and Enter in a form's only field did the same. No submit event was dispatched, so a page's handler never ran and preventDefault had nothing to prevent — apply_generated_text_input_event carried a TODO saying so.

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. The press now dispatches a cancelable, bubbling submit and the submission is that event's default action.

Three tests, because two of them pass for the wrong reason alone.

Anchors had no href IDL property, so client-side routing was impossible

element.rs defined accessors for src, value, checked, type and the rest, and never for href. a.href read undefined; only getAttribute("href") worked.

@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. With href undefined the listener returned at its own guard before reaching preventDefault, 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

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>
@pathscale pathscale changed the title fix(script): let a page nest calls as deeply as a browser does Two defects a real application hits on its first page Sep 8, 2026
meh and others added 2 commits September 8, 2026 14:40
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
pathscale force-pushed the feat/script-recursion-limit branch from c00b98c to 6bb46f8 Compare September 8, 2026 10:49
meh and others added 3 commits September 8, 2026 18:33
`@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>
@pathscale pathscale changed the title Two defects a real application hits on its first page Seven defects a real application hits before it renders Sep 9, 2026
@pathscale
pathscale merged commit f21210f into master Sep 9, 2026
11 checks passed
@pathscale
pathscale deleted the feat/script-recursion-limit branch September 9, 2026 03:48
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.

1 participant