Skip to content

Read a thrown value's message without running script when a throw leaves a function body - #4186

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:fix/javascriptexception-message-no-script
Sep 26, 2026
Merged

lahma merged 2 commits into
sebastienros:mainfrom
lahma:fix/javascriptexception-message-no-script

Conversation

@lahma

@lahma lahma commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Found while landing #4183.

The defect

When a throw leaves a function, generator, eval or constructor body, the engine re-raises it as a new JavaScriptException at every frame it unwinds through (Throw.JavaScriptException(Engine, JsValue, in Completion)). That constructor built its CLR message with oi.Get("message").ToString() — and did it twice, once for the exception and once for its private wrapper. So a message getter or a proxy get trap ran on an ordinary throw, two times per frame. Neither throw nor catch reads message, so no other engine does this.

Measured on main at 7d9cdcc8f, plain script, no ShadowRealm:

script observed on main spec
const e = new Error(); Object.defineProperty(e, 'message', { get() { n++; return 'x'; } }); function f() { throw e; } try { f(); } catch {} n === 2 0
the same, thrown from four frames down n === 8 0
function f() { throw new Proxy({}, { get() { n++; } }); } try { f(); } catch {} n === 2 0
a message getter that throws RangeError catch (x) gets the RangeError, not e e
a host Func<JsValue, JsValue> calls back into a function that throws e; the script catches it n === 2 0
the host catches engine.Execute("function f() { throw e; } f();") n === 4 0
engine.Execute("throw e;") (top level) or engine.Invoke("f") n === 2 0
new JavaScriptException(revokedProxy) from host code the constructor throws the proxy's TypeError no throw

A direct try { throw e; } catch {} inside one function never builds an exception, so it read nothing (n === 0) before and after.

The fix

The CLR message is now read once, from descriptors, and nothing is called:

  • An Error built with a message (by far the most common thing thrown) keeps it in JsError's virtual-message field. The field is read directly (JsError.VirtualMessage), so this path does not allocate a descriptor.
  • Otherwise ValueSlotReader.FindOnPrototypeChain walks the chain with GetOwnProperty and stops at a proxy. This is the walk the console's ErrorText already used; DataPropertyText now calls it too.
    • A data property holding a primitive gives that primitive's string, the same text as before.
    • When nothing on the chain has message, the text is "undefined", the same as before.
    • A member of a wrapped CLR object (ReflectionDescriptor) is still read. That runs the host's getter, never script, so throw clrObject and new JavaScriptException(JsValue.FromObject(engine, ex)) keep ex.Message.
    • A DOMException answers from its IErrorTextSlots slot. That slot is what its WebIDL accessor returns.
    • Anything else gives "": an accessor, a proxy on the walk, or a message that is itself an object.
  • Thrown primitives are unchanged (TypeConverter.ToString, and a symbol's descriptive string).

Eager and descriptor-only, rather than lazy on first read of Message.

  • (a) Nothing runs during script execution.
  • (b) A host reading Message after the run reads a stored string, so it runs nothing.
  • (c) The string is computed on the engine's thread when the throw happens. A lazy walk would run on whatever thread the host logs from, racing the engine's property dictionaries if that engine is pooled and running the next script. It would also report message as it is at read time, not as it was when thrown.
  • (d) The constructor does less than before: one walk (or one field read) instead of two [[Get]]s plus two ToStrings. There is one new private constructor, so the value is computed once and passed to the wrapper.

What changes for a host

JavaScriptException.Message is unchanged for ordinary errors: a data-property message on the error or its chain (Error, TypeError, AggregateError, a subclass, a reassigned or redefined or frozen message, Object.create(Error.prototype) with a message), thrown primitives, objects with no message ("undefined"), engine-raised errors, DOMException, and wrapped CLR objects. 26 rows pin this, each at both construction sites.

It changes only where script would have had to run:

thrown Message before Message after
error with a message getter (own, or a subclass get message(), or an inherited object-literal getter) getter's result (getter ran 2× per frame) "", getter not run
a getter that throws the host caught the getter's RangeError in place of the thrown error "", and Error is the thrown error
a proxy, or an object with a proxy on its prototype chain the get trap's result "", no trap
{ message: { toString() {…} } } the toString result (it ran) ""
{ message: ['a', 'b'] } (2)[] (the array's CLR debug rendering) ""
DOMException after Object.defineProperty(DOMException.prototype, 'message', { get … }) the patched getter's result the instance's own message
new JavaScriptException(revokedProxy) constructor threw the proxy's TypeError ""

These have knock-on effects. ToString() / GetJavaScriptErrorString() start Error rather than Error: …. ErrorEvent.message for an uncaught error (built from Message) is empty the same way. The ShadowRealm WrappedFunction copies that #4183 left alone now copy a message whose construction ran nothing. This is row 4.140 in docs/guide/migrating-to-v5.md, including the one line that gets the script's answer back on the engine's thread. JavaScriptException(JsValue) now has a <summary>, so it leaves UndocumentedPublicApi.txt. There is no public API baseline change.

Tests

  • Jint.Tests/Runtime/ThrownValueMessageSideEffectTests.cs (15 cases): a message getter through one and four frames, a generator, eval, a constructor, a built-in callback, and finally; a getter on a subclass prototype; the reported proxy repro; a full-handler proxy logging every trap; a throwing getter; a host CLR call boundary; host-caught top-level, function and Invoke throws.
  • Jint.Tests.PublicInterface/JavaScriptExceptionMessageTests.cs (40 cases on net8.0/net10.0, 39 on net472 where the DOMException case is compiled out): 26 unchanged rows, 9 changed rows, a revoked proxy, a wrapped CLR object, a host-constructed exception, the rendered string, and DOMException.

Fails before, run with the engine sources reverted and the tests kept (on 7d9cdcc8f and again on 85571424b):

  • ThrownValueMessageSideEffectTests: 15 of 15 fail. For example, four function frames gives "Expected value to be 0.0, but found 8.0". NoTrapOfAThrownProxyIsAskedAboutMessage finds "get:message,get:message". AThrowingMessageGetterDoesNotReplaceTheThrownValue catches a RangeError.
  • JavaScriptExceptionMessageTests: 13 of 40 fail. AHostCanWrapARevokedProxy sees JavaScriptException: Cannot perform 'get' on a proxy that has been revoked thrown by the constructor. ADomExceptionKeepsItsMessage gets "patched". The 27 that pass are the unchanged-message pins and the CLR-member pin.

Passes after:

suite net10.0 net8.0 net472
Jint.Tests 12,817 total / 12,812 passed / 0 failed / 5 skipped 12,817 / 12,812 / 0 / 5 8,865 / 8,859 / 0 / 6
Jint.Tests.PublicInterface 3,859 / 3,838 / 0 / 21 3,849 / 3,828 / 0 / 21 3,065 / 3,044 / 0 / 21
host-contract verification (JINT_HOST_CONTRACT_VERIFICATION=1) Jint.Tests 12,817 / 12,812 / 0 / 5; PublicInterface 3,859 / 3,851 / 0 / 8 — —
test262 (full) 102,740 / 102,651 / 0 / 89 (control on main: 102,651 / 0 / 89) — —

Base: 85571424b23877e045b499374a723fb3e33edc84 (upstream main). The branch was written and fully verified on 7d9cdcc8f. Upstream then gained #4182 and #4183, so upstream was fetched again, the branch was rebased onto it (no conflicts), and every run in the table was repeated on the rebased commit. The fail-before numbers above were re-measured on the new base too (15/15 and 13/40). Immediately before opening this, upstream was fetched a third time and had not moved. The full runs are on c50603bc4. The second commit only adds this PR's link to the guide heading, and MigrationGuideTests, SpecCitationTests and AgentInstructionFileTests were run again on it.

Not changed here (same class, separate fixes)

These were measured after this fix, on the same repros:

  • SetJavaScriptCallstack still reads stack through HasProperty + [[Get]] at every frame. A stack getter runs once per frame, and one that throws still replaces the thrown value. A thrown Proxy(new Error()) still logs has:stack,get:stack. A thrown revoked proxy is still replaced by TypeError: Cannot perform 'has' on a proxy that has been revoked, which is why the revoked-proxy test here covers only host construction.
  • The same method installs a non-writable, non-configurable own stack data property on any thrown non-Error object that reaches a function boundary. A frozen object is included, and gains a property it cannot have (an invariant violation).
  • PromiseRejectedException's message interpolates the rejected value ($"… {value}"), which is TypeConverter.ToString. That is the same Throw.*-message hazard; this one was found by reading the code and was not measured.

🤖 Generated with Claude Code

lahma and others added 2 commits September 25, 2026 22:05
…ves a function body

Every frame a throw unwound through rebuilt its JavaScriptException, and that
constructor read the thrown value's "message" through [[Get]] twice - once for
the exception, once for its wrapper. A message getter or a proxy get trap ran
on an ordinary throw, twice per frame, and one that threw replaced the value
being thrown.

The CLR message is now read from descriptors alone, once: a primitive in a data
property on the value's prototype chain, "undefined" when none has one, a
DOMException's slot, a wrapped CLR object's member. Where only a call could
answer - an accessor, a proxy on the walk, an object-valued message - it is
empty. Ordinary errors and thrown primitives keep their text.

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 merged commit 661e1f4 into sebastienros:main Sep 26, 2026
11 checks passed
@lahma
lahma deleted the fix/javascriptexception-message-no-script branch September 26, 2026 09:37
lahma added a commit to lahma/jint that referenced this pull request Oct 9, 2026
…stienros#4186)

JavaScriptException(JsValue) built its CLR message by reading the thrown value's
"message" through [[Get]], and did it twice: once for the exception and once for
its private wrapper. A message getter or proxy get trap therefore ran twice per
re-raise, and one that threw replaced the value being thrown with its own error.

The message is now read once and passed to the wrapper through a private
constructor, and a JavaScriptException raised while reading it falls back to an
empty message. A message from a data property or a getter that returns normally
is unchanged.

This is the throwing-getter half of sebastienros#4186 only. Main reads the message from
descriptors without running script, which changes JavaScriptException.Message
for accessors and proxies; 4.x keeps running the getter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVwU18SgMKKzYrrCh4TMpT
lahma added a commit that referenced this pull request Oct 9, 2026
… (#4252)

JavaScriptException(JsValue) built its CLR message by reading the thrown value's
"message" through [[Get]], and did it twice: once for the exception and once for
its private wrapper. A message getter or proxy get trap therefore ran twice per
re-raise, and one that threw replaced the value being thrown with its own error.

The message is now read once and passed to the wrapper through a private
constructor, and a JavaScriptException raised while reading it falls back to an
empty message. A message from a data property or a getter that returns normally
is unchanged.

This is the throwing-getter half of #4186 only. Main reads the message from
descriptors without running script, which changes JavaScriptException.Message
for accessors and proxies; 4.x keeps running the getter.


Claude-Session: https://claude.ai/code/session_01YVwU18SgMKKzYrrCh4TMpT

Co-authored-by: Claude Opus 5.5 <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