Backport #4115 to 4.x: bound the evaluated-script set - #4116
Merged
lahma merged 1 commit intoSep 19, 2026
Merged
Conversation
3 of 8 tasks
Evaluate(string) and Execute(string) parse a new Script on every call, and the engine kept every one of them: ~528 bytes per call on a long-lived engine, climbing forever and reclaimed by nothing short of dropping the engine. Four per-engine handler-tree caches key on a script's AST, and three of them reset wholesale at 2048 entries so a host streaming endless distinct sources cannot grow them without bound. The fourth, _evaluatedScripts, never got that ceiling, and a HashSet holds its keys strongly, so it retained the AST of every distinct script the engine had ever evaluated. MarkScriptEvaluated gives it the same ceiling, and the three literal 2048s become one named HandlerTreeCacheCeiling. Clearing the marker is unobservable from script: it decides only whether a cache engages, and a run that misses it rebuilds a handler tree exactly as a first run always did. Fixes sebastienros#4094 on 4.x. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma
force-pushed
the
backport/4094-evaluated-scripts-bound
branch
from
September 19, 2026 06:32
1bb45ee to
278252e
Compare
This was referenced Sep 19, 2026
PatrickSt1991
pushed a commit
to Apps2Samsung/Apps2Samsung
that referenced
this pull request
Sep 21, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.16.2 to 4.16.3. <details> <summary>Release notes</summary> _Sourced from [Jint's releases](https://github.com/sebastienros/jint/releases)._ ## 4.16.3 Jint 4.16.3 is a maintenance release from the `4.x` branch: **correctness and conformance fixes backported from `main`, and nothing that changes an existing API or an existing default.** If you are on 4.16.2 it is a drop-in update — every public signature is the one 4.16.0 shipped, on all five target frameworks, and the per-framework snapshots in `Jint.Tests.PublicInterface/Verify/` are unchanged. `main` remains 5.0.0 development; what is coming there is recorded as it lands in [`docs/v5-migration.md`](https://github.com/sebastienros/jint/blob/main/docs/v5-migration.md). ### Highlights **A long-lived engine stops accumulating what it has already run.** `Evaluate(string)` and `Execute(string)` parse a fresh `Script` on every call, and the engine kept every one of them. Three of the four per-engine handler-tree caches already reset wholesale at 2048 entries so a host streaming endless distinct sources cannot grow them without bound; the fourth, `_evaluatedScripts`, never got that ceiling and held its keys strongly, retaining the AST of every distinct script the engine had ever evaluated — about 528 bytes per call, climbing forever and reclaimed by nothing short of dropping the engine (#4116). The realm's tagged-template map had the same shape and a harder constraint: `Realm._templateMap` was a `Dictionary<Node, JsArray>`, strong on both ends and never cleared, costing roughly 1.35 KB per call for a frozen array and its raw array. A ceiling is no remedy there, because evicting a live template site is script-visible — `f() === f()` must hold for one site — so it becomes a `ConditionalWeakTable<Node, WeakReference<JsArray>>`, weak on both halves (#4119). Both matter most to exactly the embedding that looks innocuous: one engine, kept for the lifetime of the process, handed ad-hoc source. **A suspended frame no longer dereferences what the suspension produced.** `await` and `yield` suspend by returning a plain `undefined`, and the enclosing member link turns that into a sentinel reference that every consumer must recognise before reading. Nine did not, so they read `undefined.undefined` and raised a `TypeError` *inside a frame that was already suspended*. `AsyncBlockStart` swallowed that throw, but not before the statement-list resume position had been cleared on the way out — so the resume replayed the body from the first statement: one extra run of every un-awaited side effect per suspension point, and a re-entrancy guard silently truncating the rest. In a generator nothing swallows it and the `TypeError` comes straight out of `next()`. Two shapes were wrong answers rather than repeated ones — `(await p).x = 1` rejected the promise, and `o[await k] = 1` assigned to the literal key `"undefined"` instead of the real one — and `for await ((await p).a of it)` never terminated at all. Optional chaining was not the trigger despite where the report put it: the guarded fast lane needs a literal property name, so *every computed member read* of an awaited or yielded value fell through, `(await p)[0]` as much as `(await p)[k]` (#4089, reported by @davidwengier in #4086). ### Verification Every change was verified failing-first against the unfixed branch. The suspension fix is pinned by 35 new cases in `Jint.Tests/Runtime/SuspendedOptionalChainTests.cs`: against 4.16.2's code **28 fail and 6 pass on both .NET 10 and .NET Framework 4.7.2**, with a 35th — the `for await` shape — hanging the test host outright rather than failing; after the fix all 35 pass on both. The retention fixes are pinned by `Jint.Tests/Runtime/GarbageCollectionTests.cs` and `TaggedTemplateCacheTests.cs`. Release diagnostics on the tagged commit, in Release: `Jint.Tests` 7,170 (net10.0) and 7,085 (net472); `Jint.Tests.PublicInterface` 1,852 and 1,844; `Jint.Tests.CommonScripts` 28 and 28; `Jint.Tests.SourceGenerators` 52; the host-contract verification leg (`JINT_HOST_CONTRACT_VERIFICATION=1`) 7,170 / 7,085 and 1,856 / 1,848 — zero failures anywhere. test262: **102,498 passed, 183 skipped**, with three files crossing the engine's default 30-second budget under whole-suite CPU contention and passing in three seconds when run alone. The paired SunSpider and Dromaeo comparison against 4.16.2 was run after the tag rather than before it, which is a departure from how 4.16.2 was gated; it is recorded here because the result is what the release notes should carry, not the order it arrived in. **No row regressed.** Fifty-one rows, paired, alternating order, `DefaultJob`, on an idle machine: the three-round screen left two candidates clearing the sign-agreement and magnitude bar, both of them `Dromaeo.StringBase64`, and re-measuring those at eight rounds read −1.72% [−3.01, +1.98] and +0.30% [−3.10, +4.26] — no change, with a third parameter combination coming out faster. `StringBase64` is the row `Jint.Benchmark/AGENTS.md` already documents as a three-round false positive, and it behaved as documented. The `Cube` control rows moved +0.6% to +0.8%, which is this machine's floor on rows the change cannot reach. Nothing here is a performance change by intent. #4119 does move a tagged-template lookup from a `Dictionary` to a `ConditionalWeakTable` and #4089 adds suspension checks to several interpreter lanes, and neither is visible above the noise floor. ## What's Changed * Backport #4115 to 4.x: bound the evaluated-script set by @lahma in sebastienros/jint#4116 * Backport #4118 to 4.x: stop the realm's template map retaining every site it ran by @lahma in sebastienros/jint#4119 * Async and generators: a suspended frame dereferences nothing the suspension produced (backport of #4088) by @lahma in sebastienros/jint#4089 **Full Changelog**: sebastienros/jint@v4.16.2...v4.16.3 Commits viewable in [compare view](sebastienros/jint@v4.16.2...v4.16.3). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Sep 21, 2026
legrab
added a commit
to legrab/pocok
that referenced
this pull request
Sep 30, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.16.2 to 4.16.3. <details> <summary>Release notes</summary> _Sourced from [Jint's releases](https://github.com/sebastienros/jint/releases)._ ## 4.16.3 Jint 4.16.3 is a maintenance release from the `4.x` branch: **correctness and conformance fixes backported from `main`, and nothing that changes an existing API or an existing default.** If you are on 4.16.2 it is a drop-in update — every public signature is the one 4.16.0 shipped, on all five target frameworks, and the per-framework snapshots in `Jint.Tests.PublicInterface/Verify/` are unchanged. `main` remains 5.0.0 development; what is coming there is recorded as it lands in [`docs/v5-migration.md`](https://github.com/sebastienros/jint/blob/main/docs/v5-migration.md). ### Highlights **A long-lived engine stops accumulating what it has already run.** `Evaluate(string)` and `Execute(string)` parse a fresh `Script` on every call, and the engine kept every one of them. Three of the four per-engine handler-tree caches already reset wholesale at 2048 entries so a host streaming endless distinct sources cannot grow them without bound; the fourth, `_evaluatedScripts`, never got that ceiling and held its keys strongly, retaining the AST of every distinct script the engine had ever evaluated — about 528 bytes per call, climbing forever and reclaimed by nothing short of dropping the engine (#4116). The realm's tagged-template map had the same shape and a harder constraint: `Realm._templateMap` was a `Dictionary<Node, JsArray>`, strong on both ends and never cleared, costing roughly 1.35 KB per call for a frozen array and its raw array. A ceiling is no remedy there, because evicting a live template site is script-visible — `f() === f()` must hold for one site — so it becomes a `ConditionalWeakTable<Node, WeakReference<JsArray>>`, weak on both halves (#4119). Both matter most to exactly the embedding that looks innocuous: one engine, kept for the lifetime of the process, handed ad-hoc source. **A suspended frame no longer dereferences what the suspension produced.** `await` and `yield` suspend by returning a plain `undefined`, and the enclosing member link turns that into a sentinel reference that every consumer must recognise before reading. Nine did not, so they read `undefined.undefined` and raised a `TypeError` *inside a frame that was already suspended*. `AsyncBlockStart` swallowed that throw, but not before the statement-list resume position had been cleared on the way out — so the resume replayed the body from the first statement: one extra run of every un-awaited side effect per suspension point, and a re-entrancy guard silently truncating the rest. In a generator nothing swallows it and the `TypeError` comes straight out of `next()`. Two shapes were wrong answers rather than repeated ones — `(await p).x = 1` rejected the promise, and `o[await k] = 1` assigned to the literal key `"undefined"` instead of the real one — and `for await ((await p).a of it)` never terminated at all. Optional chaining was not the trigger despite where the report put it: the guarded fast lane needs a literal property name, so *every computed member read* of an awaited or yielded value fell through, `(await p)[0]` as much as `(await p)[k]` (#4089, reported by @davidwengier in #4086). ### Verification Every change was verified failing-first against the unfixed branch. The suspension fix is pinned by 35 new cases in `Jint.Tests/Runtime/SuspendedOptionalChainTests.cs`: against 4.16.2's code **28 fail and 6 pass on both .NET 10 and .NET Framework 4.7.2**, with a 35th — the `for await` shape — hanging the test host outright rather than failing; after the fix all 35 pass on both. The retention fixes are pinned by `Jint.Tests/Runtime/GarbageCollectionTests.cs` and `TaggedTemplateCacheTests.cs`. Release diagnostics on the tagged commit, in Release: `Jint.Tests` 7,170 (net10.0) and 7,085 (net472); `Jint.Tests.PublicInterface` 1,852 and 1,844; `Jint.Tests.CommonScripts` 28 and 28; `Jint.Tests.SourceGenerators` 52; the host-contract verification leg (`JINT_HOST_CONTRACT_VERIFICATION=1`) 7,170 / 7,085 and 1,856 / 1,848 — zero failures anywhere. test262: **102,498 passed, 183 skipped**, with three files crossing the engine's default 30-second budget under whole-suite CPU contention and passing in three seconds when run alone. The paired SunSpider and Dromaeo comparison against 4.16.2 was run after the tag rather than before it, which is a departure from how 4.16.2 was gated; it is recorded here because the result is what the release notes should carry, not the order it arrived in. **No row regressed.** Fifty-one rows, paired, alternating order, `DefaultJob`, on an idle machine: the three-round screen left two candidates clearing the sign-agreement and magnitude bar, both of them `Dromaeo.StringBase64`, and re-measuring those at eight rounds read −1.72% [−3.01, +1.98] and +0.30% [−3.10, +4.26] — no change, with a third parameter combination coming out faster. `StringBase64` is the row `Jint.Benchmark/AGENTS.md` already documents as a three-round false positive, and it behaved as documented. The `Cube` control rows moved +0.6% to +0.8%, which is this machine's floor on rows the change cannot reach. Nothing here is a performance change by intent. #4119 does move a tagged-template lookup from a `Dictionary` to a `ConditionalWeakTable` and #4089 adds suspension checks to several interpreter lanes, and neither is visible above the noise floor. ## What's Changed * Backport #4115 to 4.x: bound the evaluated-script set by @lahma in sebastienros/jint#4116 * Backport #4118 to 4.x: stop the realm's template map retaining every site it ran by @lahma in sebastienros/jint#4119 * Async and generators: a suspended frame dereferences nothing the suspension produced (backport of #4088) by @lahma in sebastienros/jint#4089 **Full Changelog**: sebastienros/jint@v4.16.2...v4.16.3 Commits viewable in [compare view](sebastienros/jint@v4.16.2...v4.16.3). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details>
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.
Backport of #4115 to
4.x. Fixes #4094 on the maintenance branch, where the issue was reported(4.12.0 through 4.16.2).
Passes 4.x's rule — a correctness fix, no API and no default changed.
_evaluatedScriptsis aprivate field and the marker it holds is not script-visible: it decides only whether a per-engine
handler-tree cache engages, and a run that misses it rebuilds the tree exactly as a first run always
did.
What held them
Evaluate(string)andExecute(string)parse a newScripton every call. Four per-enginehandler-tree caches key on a script's AST; three reset wholesale at 2048 entries so a host streaming
endless distinct sources through one long-lived engine cannot grow them without bound. The fourth,
_evaluatedScripts, never got that ceiling — and aHashSet<Script>holds its keys strongly, so itretained the AST of every distinct script the engine had ever evaluated, with the hoisting scope and
var-name lists cached on that AST. ~528 bytes per call, climbing forever; on
mainthe same harnessreached ~425 MB over 800,000 calls and a sawtooth of 1.0–1.9 MB after the fix.
The marker arrived in #2613, which is why the issue bisects to 4.12.0.
Divergence from main
Identical engine change. The two documentation edits in #4115 do not apply here:
4.xhas noJint/Diagnostics/and noJint/Runtime/Interpreter/AGENTS.md. The tests are the same two, adaptedto this branch's xUnit fixture (
[Fact], inline collects, noEvaluatedScriptCountaccessor — theweak reference carries the claim on its own).
Evidence on 4.x
Both tests run against the unfixed 4.x engine (ceiling disabled, everything else in place):
The failure is
AnEngineFedEndlessFreshScriptsDoesNotRetainThemAll— "Expected boolean to be Falsebecause an engine must not go on holding the AST of every script it has ever run, but found True."
The pass is the control,
AnEvaluatedScriptIsStillHeldBelowTheCeiling, which is what proves theobservation can see retention at all.
With the fix, the full
Jint.Testssuite is green: 7,133 on net10.0 and 7,048 on net472, 0 failed.🤖 Generated with Claude Code