Skip to content

Backport #4115 to 4.x: bound the evaluated-script set - #4116

Merged
lahma merged 1 commit into
sebastienros:4.xfrom
lahma:backport/4094-evaluated-scripts-bound
Sep 19, 2026
Merged

lahma merged 1 commit into
sebastienros:4.xfrom
lahma:backport/4094-evaluated-scripts-bound

Conversation

@lahma

@lahma lahma commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

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. _evaluatedScripts is a
private 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) and Execute(string) parse a new Script on every call. Four per-engine
handler-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 a HashSet<Script> holds its keys strongly, so it
retained 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 main the same harness
reached ~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.x has no
Jint/Diagnostics/ and no Jint/Runtime/Interpreter/AGENTS.md. The tests are the same two, adapted
to this branch's xUnit fixture ([Fact], inline collects, no EvaluatedScriptCount accessor — the
weak 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):

target result
net472 Failed: 1, Passed: 1
net10.0 Failed: 1, Passed: 1

The failure is AnEngineFedEndlessFreshScriptsDoesNotRetainThemAll — "Expected boolean to be False
because 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 the
observation can see retention at all.

With the fix, the full Jint.Tests suite is green: 7,133 on net10.0 and 7,048 on net472, 0 failed.

🤖 Generated with Claude Code

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
lahma force-pushed the backport/4094-evaluated-scripts-bound branch from 1bb45ee to 278252e Compare September 19, 2026 06:32
@lahma
lahma merged commit 2f036ac into sebastienros:4.x Sep 19, 2026
8 of 10 checks passed
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.16.2&new-version=4.16.3)](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>
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.16.2&new-version=4.16.3)](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>
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