Repository navigation
Read a thrown value's message without running script when a throw leaves a function body - #4186
Merged
lahma merged 2 commits intoSep 26, 2026
Conversation
…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
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>
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.
Found while landing #4183.
The defect
When a throw leaves a function, generator,
evalor constructor body, the engine re-raises it as a newJavaScriptExceptionat every frame it unwinds through (Throw.JavaScriptException(Engine, JsValue, in Completion)). That constructor built its CLR message withoi.Get("message").ToString()— and did it twice, once for the exception and once for its private wrapper. So amessagegetter or a proxygettrap ran on an ordinarythrow, two times per frame. Neitherthrownorcatchreadsmessage, so no other engine does this.Measured on
mainat7d9cdcc8f, plain script, no ShadowRealm:mainconst e = new Error(); Object.defineProperty(e, 'message', { get() { n++; return 'x'; } }); function f() { throw e; } try { f(); } catch {}n === 2n === 8function f() { throw new Proxy({}, { get() { n++; } }); } try { f(); } catch {}n === 2messagegetter that throwsRangeErrorcatch (x)gets theRangeError, noteeFunc<JsValue, JsValue>calls back into a function that throwse; the script catches itn === 2engine.Execute("function f() { throw e; } f();")n === 4engine.Execute("throw e;")(top level) orengine.Invoke("f")n === 2new JavaScriptException(revokedProxy)from host codeTypeErrorA 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:
Errorbuilt with a message (by far the most common thing thrown) keeps it inJsError's virtual-message field. The field is read directly (JsError.VirtualMessage), so this path does not allocate a descriptor.ValueSlotReader.FindOnPrototypeChainwalks the chain withGetOwnPropertyand stops at a proxy. This is the walk the console'sErrorTextalready used;DataPropertyTextnow calls it too.message, the text is"undefined", the same as before.ReflectionDescriptor) is still read. That runs the host's getter, never script, sothrow clrObjectandnew JavaScriptException(JsValue.FromObject(engine, ex))keepex.Message.DOMExceptionanswers from itsIErrorTextSlotsslot. That slot is what its WebIDL accessor returns."": an accessor, a proxy on the walk, or amessagethat is itself an object.TypeConverter.ToString, and a symbol's descriptive string).Eager and descriptor-only, rather than lazy on first read of
Message.Messageafter the run reads a stored string, so it runs nothing.messageas it is at read time, not as it was when thrown.[[Get]]s plus twoToStrings. There is one new private constructor, so the value is computed once and passed to the wrapper.What changes for a host
JavaScriptException.Messageis unchanged for ordinary errors: a data-propertymessageon 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 nomessage("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:
MessagebeforeMessageaftermessagegetter (own, or a subclassget message(), or an inherited object-literal getter)"", getter not runRangeErrorin place of the thrown error"", andErroris the thrown errorgettrap's result"", no trap{ message: { toString() {…} } }toStringresult (it ran)""{ message: ['a', 'b'] }(2)[](the array's CLR debug rendering)""DOMExceptionafterObject.defineProperty(DOMException.prototype, 'message', { get … })new JavaScriptException(revokedProxy)TypeError""These have knock-on effects.
ToString()/GetJavaScriptErrorString()startErrorrather thanError: ….ErrorEvent.messagefor an uncaught error (built fromMessage) is empty the same way. The ShadowRealmWrappedFunctioncopies that #4183 left alone now copy a message whose construction ran nothing. This is row 4.140 indocs/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 leavesUndocumentedPublicApi.txt. There is no public API baseline change.Tests
Jint.Tests/Runtime/ThrownValueMessageSideEffectTests.cs(15 cases): amessagegetter through one and four frames, a generator,eval, a constructor, a built-in callback, andfinally; 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 andInvokethrows.Jint.Tests.PublicInterface/JavaScriptExceptionMessageTests.cs(40 cases on net8.0/net10.0, 39 on net472 where theDOMExceptioncase is compiled out): 26 unchanged rows, 9 changed rows, a revoked proxy, a wrapped CLR object, a host-constructed exception, the rendered string, andDOMException.Fails before, run with the engine sources reverted and the tests kept (on
7d9cdcc8fand again on85571424b):ThrownValueMessageSideEffectTests: 15 of 15 fail. For example,four function framesgives "Expected value to be 0.0, but found 8.0".NoTrapOfAThrownProxyIsAskedAboutMessagefinds"get:message,get:message".AThrowingMessageGetterDoesNotReplaceTheThrownValuecatches aRangeError.JavaScriptExceptionMessageTests: 13 of 40 fail.AHostCanWrapARevokedProxyseesJavaScriptException: Cannot perform 'get' on a proxy that has been revokedthrown by the constructor.ADomExceptionKeepsItsMessagegets"patched". The 27 that pass are the unchanged-message pins and the CLR-member pin.Passes after:
JINT_HOST_CONTRACT_VERIFICATION=1)main: 102,651 / 0 / 89)Base:
85571424b23877e045b499374a723fb3e33edc84(upstreammain). The branch was written and fully verified on7d9cdcc8f. 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 onc50603bc4. The second commit only adds this PR's link to the guide heading, andMigrationGuideTests,SpecCitationTestsandAgentInstructionFileTestswere run again on it.Not changed here (same class, separate fixes)
These were measured after this fix, on the same repros:
SetJavaScriptCallstackstill readsstackthroughHasProperty+[[Get]]at every frame. Astackgetter runs once per frame, and one that throws still replaces the thrown value. A thrownProxy(new Error())still logshas:stack,get:stack. A thrown revoked proxy is still replaced byTypeError: Cannot perform 'has' on a proxy that has been revoked, which is why the revoked-proxy test here covers only host construction.stackdata property on any thrown non-Errorobject 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 isTypeConverter.ToString. That is the sameThrow.*-message hazard; this one was found by reading the code and was not measured.🤖 Generated with Claude Code