Skip to content

Throw a ShadowRealm cross-realm TypeError after its catch block, not inside it - #4176

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:fix/shadowrealm-wrapped-chain
Sep 25, 2026
Merged

lahma merged 2 commits into
sebastienros:mainfrom
lahma:fix/shadowrealm-wrapped-chain

Conversation

@lahma

@lahma lahma commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

The bug

A function passed back and forth across a ShadowRealm boundary is wrapped once per crossing, so script builds a
chain of wrapped functions as deep as it likes:

const sr = new ShadowRealm();
const id = sr.evaluate('x => x');
let f = function () { return 1; };
for (let i = 0; i < 500; i++) f = id(f);   // 1 000 wrapped levels
f();

Calling it descends a few native frames per level, and with the default options (StackOverflowGuard on) the
native stack probe raises its catchable RangeError at the bottom. It never reached the script. On
the way back up, every level's WrappedFunction.[[Call]] catches the failure and copies it into a TypeError
created in the caller's realm, as the proposal requires — and it threw that copy from inside its
catch (JavaScriptException) block. A throw from a handler is dispatched on top of the exception being handled,
whose frames (everything from the handler down to where it was raised) stay on the native stack until the handler
returns. So each level nested one more exception dispatch on top of the whole chain, nothing probes an exception
dispatch, and the host process died:

Stack overflow.
   at System.Runtime.EH.DispatchEx(System.Runtime.StackFrameIterator ByRef, ExInfo ByRef)
   at Jint.Runtime.Throw.TypeError(Jint.Runtime.Realm, System.String)
   at Jint.Native.ShadowRealm.ShadowRealm.ThrowCrossRealmError(Jint.Runtime.Realm, System.String)
   at Jint.Native.ShadowRealm.ShadowRealm+WrappedFunction.Call(Jint.Native.JsValue, Jint.Native.JsValue[])
   at Jint.Engine.CallNativeFunction(...)
   ...

Measured on a 1 MB thread, guard on, x64: 250 round trips (500 levels) fit and answer 1; 500 round trips
(1 000 levels) end the process on both net10.0 (Stack overflow., exit 127) and net472 (Process is terminated due to StackOverflowException.)
, and so does every depth tried above that (1 000, 2 000, 5 000).

WrappedFunctionCreate had the same shape around CopyNameAndLength, and needs no depth input at all. Wrapping
a function whose name getter is itself a wrapped function wraps the getter's receiver again, which reads name
again — a native-only recursion of three lines of script:

const sr = new ShadowRealm();
const g = function () {};
Object.defineProperty(g, 'name', { get: sr.evaluate('(function () {})') });
sr.evaluate('f => f')(g);

The probe fires, and every level's catch around CopyNameAndLength threw its copy from inside the handler: the
same Stack overflow. in EH.DispatchEx under ShadowRealm.WrappedFunctionCreate, on net10.0, net8.0 and
net472.

The fix

Both sites record the failure's message in the catch and throw the copy once the handler has returned, so
by the time a level throws, the frames it handled are gone. The cross-realm failure now unwinds one level at a
time, as any other exception does. With the fix, both scripts above raise a catchable
TypeError: Cross-Realm Error: Maximum call stack size exceeded on net10.0 and net472 at every depth tried —
500, 1 000, 5 000 and 20 000 round trips on a 1 MB thread, and 20 000 on a 16 MB thread.

PerformShadowRealmEvalInternal's throw sits in a try/finally, not a catch, and nests nothing, so it is
untouched.

The message: embed once, never compound

CreateTypeErrorCopy (§2.2) only requires a
new TypeError created in the caller's realm; its note says "Host environments may provide implementation-specific
message value" and that the operation "must not cause any ECMAScript code execution". test262 asserts no message
anywhere under built-ins/ShadowRealm (checked at the pinned 7ab7fafa), so the message is Jint's to choose.

ThrowCrossRealmError prefixed Cross-Realm Error: to the inner message at every hop, so a failure crossing
n levels built n ever-longer strings: quadratic in a depth script chooses. Measured with only the
throw-after-catch half applied, on net10.0: the message came back 14 244 characters long on a 1 MB thread
(~750 levels) and 260 009 characters on a 16 MB thread (~13 700 levels), roughly 3.5 GB of string allocation
summed over the copies on the way up, to say Cross-Realm Error: thirteen thousand times.

I kept the inner message rather than dropping it: it is the only diagnostic an embedder gets for a failure inside
a realm (Cross-Realm Error: boom against a bare TypeError), and the proposal explicitly leaves room for it.
What changed is that a message which already carries the mark is reused as it is, so the copy's message is the
innermost failure marked once, whatever the depth, and no hop after the first builds a message string. The
check is an ordinal prefix test. A script error whose own message happens to begin with Cross-Realm Error: now
crosses without a second mark, which still says what happened. The message still comes from ex.Message, which the
JavaScriptException computed when it was raised, so the copy runs no script, as the note requires.

The one observable change: BoundFunctionChainWalkTests' ShadowRealm route (a call that crosses the boundary
twice) now reads TypeError:Cross-Realm Error: Maximum call stack size exceeded instead of
TypeError:Cross-Realm Error: Cross-Realm Error: Maximum call stack size exceeded; its expectation is updated.

Hot paths

Neither site is a hot path, and the success path of both is unchanged apart from one null local; the extra
branch runs only after a caught exception. No benchmark was run; measurement belongs to the lead.

Instructions

Jint/Constraints/AGENTS.md gains one bullet next to the probe placement rules: nothing probes an exception
dispatch, so a recursive native path records a failure and throws after its catch, never inside it. The file
stays under its 32 KiB budget (32 514 bytes on disk with CRLF). Jint.Tests/SpecAnchors.txt registers
proposal-shadowrealm sec-create-type-error-copy, regenerated with JINT_SPEC_ANCHORS=update (one anchor added,
nothing else changed apart from the date and count).

Tests

Jint.Tests.PublicInterface/HostNativeRecursionGuardTests.AFailureCopiedAcrossAShadowRealmBoundaryAtEveryLevelIsCatchable,
two rows on a 1 MB DedicatedThread. Each asserts the host catches a JavaScriptException whose message is
exactly Cross-Realm Error: Maximum call stack size exceeded and whose error is a TypeError of the principal
realm, then that the engine still evaluates 6 * 7:

  • wrapped call: the loop above at 5 000 round trips (10 000 levels), ten times the 500 at which the
    unfixed engine dies on every framework;
  • wrapped create: the name getter recursion above, which has no depth to choose.

Fail before, pass after

Unfixed ShadowRealm.cs (base main), each row run on its own:

row net10.0 net8.0 net472
wrapped call host dies, Stack overflow. in WrappedFunction.Call / ThrowCrossRealmError host dies, Stack overflow. host dies, StackOverflowException
wrapped create host dies, Stack overflow. in WrappedFunctionCreate / ThrowCrossRealmError host dies, Stack overflow. host dies, StackOverflowException

Test run summary: Zero tests ran / total: 0 with the overflow in the error output is the confirmation: an
unguarded case cannot fail, it can only take the runner with it. Each row reaches only one of the two sites. The
call chain passes no function-valued argument, this or result, so it never reaches WrappedFunctionCreate, and
the getter recursion throws from the this wrapping, which sits before WrappedFunction.Call's try. With the
fix, both rows pass on all three frameworks.

What was run

On b9dcf5888, rebased onto main at b630bf424, each project per framework, whole project, Release, no
--timeout:

project net10.0 net8.0 net472
Jint.Tests 12 704 (12 699 passed, 5 skipped) 12 704 (12 699 / 5) 8 752 (8 746 / 6)
Jint.Tests.PublicInterface 3 800 (3 779 / 21) 3 790 (3 769 / 21) 3 007 (2 986 / 21)

0 failures anywhere. test262: 102 740, 102 637 passed, 0 failed, 103 skipped. The
JINT_HOST_CONTRACT_VERIFICATION=1 leg was not run locally; nothing here touches a host-facing type.

Not in this PR, stated rather than left to be found

  • ShadowRealm.prototype.evaluate's copy runs script. PerformShadowRealmEval builds its message from
    result.Value.ToString(), which for an object thrown by the evaluated code is TypeConverter.ToString or
    Error.prototype.toString. That contradicts CreateTypeErrorCopy's "must not cause any ECMAScript code
    execution", and a throwing toString replaces the copy altogether. Measured on this branch:
    sr.evaluate("throw { toString() { ran++; return 'custom'; } }") runs the method (ran is 1 afterwards), and
    sr.evaluate("throw { toString() { throw new RangeError('from toString'); } }") surfaces
    RangeError: from toString instead of a TypeError. That is a conformance change with its own test262 run, so
    it is left for a separate PR. The message would have to come from something that runs no script, such as an
    ErrorInstance's own data-property message.

Follow-up to #4076 / #4078 / #4165.

🤖 Generated with Claude Code

lahma and others added 2 commits September 25, 2026 11:38
…inside it

WrappedFunction's [[Call]] and WrappedFunctionCreate copied a failure into
a TypeError by throwing from inside the catch handling it. A throw from a
handler is dispatched on top of every frame down to the throw it handles,
so a failure crossing a chain of wrapped functions nested one exception
dispatch per hop, which no native stack probe sees: the StackOverflowGuard's
catchable RangeError at the bottom of the chain became a process-ending
stack overflow on the way back up. Five hundred round trips of a function
across the boundary were enough on a 1 MB thread; a name getter that wraps
its own receiver needed no depth at all.

Both sites now record the failure and throw once the handler has returned.
The copy's "Cross-Realm Error: " mark is also added once instead of once per
hop, which kept the message, and the allocation of every copy on the way
up, growing with the depth of the chain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the fix/shadowrealm-wrapped-chain branch from b9dcf58 to e0e63a7 Compare September 25, 2026 08:39
@lahma
lahma merged commit c1c180b into sebastienros:main Sep 25, 2026
9 checks passed
lahma added a commit to lahma/jint that referenced this pull request Sep 25, 2026
…hmark/AGENTS.md

sebastienros#4176 and this branch each added a gotcha to Jint/Constraints/AGENTS.md, and together they put the file
over the 32 KiB budget AgentInstructionFileTests enforces. The bullet recording the measured NO-GO on the
double native-stack probe repeated the mechanism and the verdict that Jint.Benchmark/AGENTS.md already
holds with its table, so it keeps the trap (the route, the verdict, the issue) and points there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Sep 25, 2026
#4173)

* Pin that a deferred concatenation escapes LimitMemory (repro for #4162)

Seven of the eight tests fail on main: a long `a + b` builds a RopeString
whose characters are allocated only when something flattens it, so under
LimitMemory(4 MB) a 16 MB doubling, a shared-operand accumulator, a value
grown across entries and a host string with one character appended all
leave the engine uncharged. The eighth is the guard on the other side: a
fix must not charge an accumulator as if every intermediate were copied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin the aggregate and deferral halves of the #4162 repro

IndependentConcatenationsShareOneBudget fails on main and on a fix that
only refuses a single value larger than the budget; the leftover-size
assertions now demand that a failed run leaves nothing larger than its
budget behind. AccumulatingWithPlusStaysDeferredUnderAMemoryLimit passes
on main and guards against a fix that copies eagerly whenever a limit
is configured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what the #4162 repro tests do allocate

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Charge a deferred concatenation to LimitMemory when it is built

A long `a + b` returns a RopeString, which is 56 bytes whatever length it
stands for; its characters are allocated by whoever flattens it, and that can
be a host's ToString() after every constraint is disarmed. JsString.Concat now
charges the active operation on its deferring branch: the shorter operand,
which keeps `s = s + x` linear, or the whole length, refused before the node
exists, when the flat form alone exceeds the budget.

Concat takes the EvaluationContext the interpreter already holds and reads
its engine only on the deferring branch, so the short `+` path loads nothing
new. A constant fold evaluates with an engine-less context, and the charge
skips it: the node is then made from source literals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Document that a deferred string is charged when it is built

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Cut the double-probe bullet to its trap; the table lives in Jint.Benchmark/AGENTS.md

#4176 and this branch each added a gotcha to Jint/Constraints/AGENTS.md, and together they put the file
over the 32 KiB budget AgentInstructionFileTests enforces. The bullet recording the measured NO-GO on the
double native-stack probe repeated the mechanism and the verdict that Jint.Benchmark/AGENTS.md already
holds with its table, so it keeps the trap (the route, the verdict, the issue) and points there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lahma
lahma deleted the fix/shadowrealm-wrapped-chain branch September 25, 2026 18:10
lahma added a commit that referenced this pull request Sep 26, 2026
…stack overflow or an endless thread hop (#4187)

Evaluating eval's source re-enters the interpreter without entering a
function, so no function-entry probe saw it: `var s = 'eval(s)'; eval(s)`,
`(0, eval)(s)`, `eval?.(s)`, `[s].forEach(eval)` and eval installed as
@@hasInstance all ended the host with a native stack overflow on net10.0,
net8.0 and net472 under the default StackOverflowGuard.

PerformEval now probes before it parses, gated like the ScriptFunction
entries (off on the MaxExecutionStackCount lane). On that lane a direct
eval and eval?.() were the one call a call expression dispatches without a
call-stack frame, so a recursion of them never grew the count and hopped to
a fresh thread at every exhaustion without ever throwing; they now count as
the call they are, through a counter rather than a frame because a frame
is observable in error.stack, the debugger and MaxRecursionDepth.

Follow-up to #4076 / #4078 / #4165 / #4176.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Oct 5, 2026
…lling the host or running script (#4220)

* Backport #4176 to 4.x: Throw a ShadowRealm cross-realm TypeError after its catch block, not inside it

WrappedFunction's [[Call]] and WrappedFunctionCreate copied a failure into
a TypeError by throwing from inside the catch handling it. A throw from a
handler is dispatched on top of every frame down to the throw it handles,
so a failure crossing a chain of wrapped functions nested one exception
dispatch per hop, which no native stack probe sees: the StackOverflowGuard's
catchable RangeError at the bottom of the chain became a process-ending
stack overflow on the way back up.

Both sites now record the failure and throw once the handler has returned,
and the "Cross-Realm Error: " mark is added once instead of once per hop.

Adapted for 4.x: StackOverflowGuard is opt-in on this branch, so the new
test rows run on the file's Guarded() engine; xUnit TheoryData instead of
main's TestCases; the gotcha goes in the root AGENTS.md (4.x has no
Jint/Constraints/AGENTS.md) and there is no SpecAnchors.txt to register.

Adapted from c1c180b

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Backport #4183 to 4.x: Describe a value thrown out of a ShadowRealm without running any of its code

ShadowRealm.prototype.evaluate copied a failure into the caller's TypeError
by formatting the thrown value with ToString, which calls toString,
@@toPrimitive, a name or message getter and every proxy trap on the way.
CreateTypeErrorCopy must not cause any ECMAScript code execution, and when
one of those calls threw, its exception - an object of the shadow realm -
reached the caller instead of the TypeError.

The copy's message is now read without calling anything: a primitive's own
string form, and for an error its name and message where each is a data
property holding a string. Any other object, a proxy included, is described
by kind alone. An error that is itself a copy keeps its message, so a failure
leaving nested shadow realms is marked "Cross-Realm Error: " once.

importValue's rejection handler was %ThrowTypeError%, which reported a failed
import as a 'caller'/'callee'/'arguments' access. It is now the proposal's
ImportValueError function, performing the same copy.

Adapted for 4.x: main reads the error through Jint.Diagnostics.ValueSlotReader
(ErrorText/ErrorLine), which this branch does not have; the same call-free
descriptor walk is written as private helpers in ShadowRealm.cs, without the
IErrorTextSlots branch (DOMException lives in Jint/WebApi, absent here). The
[[ErrorData]] test is `is JsError`, as Error.isError's is on this branch.
Tests use xUnit InlineData; there is no SpecAnchors.txt to register.

Adapted from 8557142

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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