Skip to content

Keep counting a frame a tail call replaced while its trampoline runs - #3022

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/tail-call-recursion-depth
Aug 15, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/tail-call-recursion-depth

Conversation

@lahma

@lahma lahma commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Options.LimitRecursion stops firing altogether when a proper tail call is on the recursion path, and the host process dies on a native stack overflow that no catch can see. A regression from 4.15.3, introduced by #2975.

Related to #3012/#3017 by theme only, and independent of them in code: there the limit fires and the exception overflows the stack on the way out; here the limit cannot fire at all.

What breaks

MaxRecursionDepth is not a stack-depth limit. JintCallStack._statistics is a multiset of the live stack keyed by JintFunctionDefinition identity (CallStackElementComparer), and every push compares that function's occurrence count against the limit. #3020's program is a two-function cycle:

  • a strict getter (one stable definition) ends in return calc() — a proper tail call;
  • calc is re-parsed by engine.Execute on every pass, so every pass is a fresh definition (Engine._functionDefinitions is keyed on the AST Node);
  • calc's body reads entity.calc, re-entering the getter through Engine.Callnot a tail call, so this genuinely grows the native stack, one cycle at a time, forever.

Through 4.15.3 the getter's frame stayed under calc, the stack read [getter, calc₁, getter, calc₂, …], the getter's count grew 0,1,2,… and the limit fired at 21. From 4.16.0 the tail call replaces the getter's frame, and ReplaceTop removed the displaced function from the statistics:

if (_statistics[previous] == 0) _statistics.Remove(previous);   // the getter drops out entirely
else _statistics[previous]--;

so the stack becomes [calc₁, calc₂, …, calcₙ, getter] — every calcᵢ a distinct definition counted once, and the getter re-added at depth 0 on every cycle because the previous ReplaceTop erased it. Nothing ever reaches 21. CallStack.Count grows without bound while every counter stays at zero.

The compensating bookkeeping inside ContinueTailCalls (depth0/depth1/tailDepths) does count tail hops, but it is local to one activation of that loop. A target that re-enters the engine by a non-tail route lands in a nested trampoline, which re-seeds from GetRecursionDepth — which the erasure has just reset to zero.

The fix

A displaced activation whose trampoline is still running still holds native stack: ContinueTailCalls and everything beneath it have not returned. So ReplaceTop now retains that occurrence and returns the target's depth exactly as Push does; ContinueTailCalls reads the depth from there and hands the retentions back through ReleaseTailRetention in the finally it already had. Retaining is also what the option already promised — "repeated tail transfers are still included in this limit so an infinite strict tail-recursive function terminates with RecursionDepthOverflowException" — the statistic simply did not agree with the trampoline's private counters.

The ledger is tallied against the function each ReplaceTop displaced, not the one it installed: the replacement is accounted by the frame it now names, so it is the displaced occurrence that outlives its element. Two fields carry direct and mutual tail recursion without allocating; anything wider spills to a dictionary. The release is tolerant of a missing entry, because a host callback can call ResetCallStack() from inside a running trampoline.

The shadow counters are gone, and with them GetRecursionDepth/GetNextRecursionDepth, which had no other callers.

Not in scope, now documented

The limit counts occurrences of one function definition, so a recursion whose every level is a function created for that level — eval, new Function, a host re-running a script — repeats no definition and is outside the limit however deep it goes. That predates proper tail calls and was equally true in 4.15.3; Options.Constraints.StackOverflowGuard (#3005) is the only thing that has ever covered it. Both LimitRecursion and StackOverflowGuard say so now — the latter's remarks previously listed only two cases where the probe is the one that answers, and this is a third.

Pre-fix behaviour of the new tests

On main the two regression tests do not fail, they take the runner with them — -1073741571 is 0xC00000FD, STATUS_STACK_OVERFLOW:

[xUnit.net 00:01:00.99]     [FATAL ERROR] Xunit.Sdk.TestPipelineException
[xUnit.net 00:01:00.99] Catastrophic failure: Test process crashed with exit code -1073741571.
No test matches the given testcase filter `FullyQualifiedName~HostTailCallTests.RecursionLimitStillFiresWhenATailCallIsOnThePath` in ...\Jint.Tests.PublicInterface\release_net10.0\Jint.Tests.PublicInterface.dll
[xUnit.net 00:01:01.92]     [FATAL ERROR] Xunit.Sdk.TestPipelineException
[xUnit.net 00:01:01.92] Catastrophic failure: Test process crashed with exit code -1073741571.
No test matches the given testcase filter `FullyQualifiedName~TailCallOptimizationTests.RecursionLimitFiresWhenATailCallReEntersThroughAGetter` in ...\Jint.Tests\release_net10.0\Jint.Tests.dll

Neither needs a large stack afterwards: the limit fires at depth 21, as asked.

  • HostTailCallTests.RecursionLimitStillFiresWhenATailCallIsOnThePath is the issue's shape as an embedder writes it — a host delegate that re-runs the script. In Jint.Tests.PublicInterface, which has no InternalsVisibleTo, so it also proves the shape is reachable by a third party.
  • TailCallOptimizationTests.RecursionLimitFiresWhenATailCallReEntersThroughAGetter is the same mechanism with no host code at all — indirect eval supplies the per-level function — so this is script-reachable, not an artifact of the reporter's harness. The eval source varies per level on purpose: EvalFunction caches parses by source text, and a cached parse would be one definition, would be counted, and would stop the recursion for the wrong reason.

Two further tests guard the release half, and both pass on main as well as here — they are the net for the new mechanism, not repros:

  • HostTailCallTests.CompletedTailDelegationDoesNotAccumulateAgainstTheLimit — 100 iterations of one completed tail delegation under LimitRecursion(0). Without the release this fails on the second iteration.
  • TailCallOptimizationTests.RecursionLimitFailureLeavesTheDepthStatisticBalanced — a leaked retention is invisible in CallStack.Count and shows up only later, as a second run of the same functions overflowing before it has recursed at all.

InfiniteStrictTailRecursionHonorsRecursionLimit, MultiFunctionTailCycleHonorsRecursionLimit (the three-definition path, i.e. the dictionary), RecursionLimitFailureLeavesCallStackBalanced and DistinctTailDelegationDoesNotCountAsRecursion are unchanged and still pin the firing depths and the balance.

Benchmarks

Not run, and deliberately. Both changed methods are entered only on a proper tail call, which requires strict mode; SunSpider and Dromaeo are sloppy throughout, so the gate's workloads never reach the diff. The one strict script in Jint.Benchmark/Scripts is handlebars (template-rendering), and for a no-limit engine — which is every benchmark — the new code is strictly less work per hop: ReplaceTop drops a CallStackElement copy and a CallStackElementComparer.Equals call and returns early on null statistics, against one added int compare. The per-trampoline cost is three null checks in a finally. Happy to add a template-rendering before/after row if you would rather see one.

Verification

All Release, all green:

suite net10.0 net472
Jint.Tests 5725 passed, 4 skipped 5640 passed, 4 skipped
Jint.Tests (JINT_HOST_CONTRACT_VERIFICATION=1) 5724 passed, 4 skipped 5639 passed, 4 skipped
Jint.Tests.PublicInterface 1488 passed, 9 skipped 1487 passed, 9 skipped
Jint.Tests.PublicInterface (JINT_HOST_CONTRACT_VERIFICATION=1) 1492 passed, 5 skipped 1491 passed, 5 skipped
Jint.Tests.Test262 99779 passed, 122 skipped, 0 failed n/a (net10.0 only)
Jint.Tests.CommonScripts 28 passed 28 passed

Closes #3020

🤖 Generated with Claude Code

https://claude.ai/code/session_0163Srj3aNzScH1keGb9smyg

`Options.LimitRecursion` stopped firing altogether when a proper tail call was on
the recursion path, and the host process died on a native stack overflow instead.

`JintCallStack.ReplaceTop` discounted the function a tail call displaced. The frame
is gone, which is what PrepareForTailCall asks for, but the activation is not over:
`ContinueTailCalls` and every native frame beneath it are still on the stack. So a
recursion that leaves the trampoline through a route that is not a tail call — a
getter, `new`, a coercion, a Proxy trap, a host callback — and re-enters it pushed
the displaced function again at depth zero on every pass, while the native stack
grew without bound. The compensating counters lived in `ContinueTailCalls` locals,
which hold only while one activation of that loop is on the stack, so the nested
trampoline restarted them from zero too.

`ReplaceTop` now retains the displaced occurrence and returns the target's depth the
way `Push` does; `ContinueTailCalls` reads the depth from there and hands the
retentions back through `ReleaseTailRetention` in its `finally`. Retaining is also
what the option already promised: repeated tail transfers count against the limit.

What is not in scope, and is now documented on `LimitRecursion` and
`StackOverflowGuard`: the limit counts occurrences of one function *definition*, so
a recursion whose every level is a function created for that level (`eval`,
`new Function`, a host re-running a script) repeats no definition and is outside it.
No version of Jint has covered that shape; `Options.Constraints.StackOverflowGuard`
is what does.

Closes sebastienros#3020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163Srj3aNzScH1keGb9smyg
@lahma
lahma merged commit 3bc449e into sebastienros:main Aug 15, 2026
5 checks passed
@lahma
lahma deleted the fix/tail-call-recursion-depth branch August 15, 2026 10:15
legrab added a commit to legrab/pocok that referenced this pull request Aug 25, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.16.0 to
4.16.1.

<details>
<summary>Release notes</summary>

_Sourced from [Jint's
releases](https://github.com/sebastienros/jint/releases)._

## 4.16.1

Jint 4.16.1 is the **first release from the new `4.x` maintenance
branch**, and it marks the point where the two lines separate: `main` is
now **5.0.0 development**, and `4.x` is where the 4.16.x line continues.

**What that means for you.** If you are on 4.16.0, this is a drop-in
update — it is correctness and conformance work only, **no API change
and no changed default**. Every public signature is the same one 4.16.0
shipped, on all five target frameworks. If you want the 4.x line, take
it from `4.x` and expect fixes rather than features. If you want to
follow where the engine is going, watch `main` — v5 brings breaking API
changes, an opt-in WHATWG web API surface, Web Workers, Node
compatibility and a raised .NET Framework floor, and every one of them
is recorded as it lands in
[`docs/v5-migration.md`](https://github.com/sebastienros/jint/blob/main/docs/v5-migration.md).

From this release onward the 4.x public surface is snapshotted per
target framework in `Jint.Tests.PublicInterface/Verify/`, so "did the
API move?" is a diff rather than a judgement call — on this branch a
diff there is a bug, and comparing those files against `main`'s is the
v4→v5 delta.

### Highlights

**Conformance, from a suite that now runs more of test262.** The
`staging/` directory is generated and executed for the first time
(#​3016), which is roughly 2,800 additional cases — largely
SpiderMonkey's own suite contributed upstream, covering behaviour the
stable directories never reach. Much of the work below is what it found.

**Built-ins do what the spec says, step by step.** The array built-ins
perform the internal methods they name rather than equivalents (#​3066);
`Array.from` honours `IsConstructor` and a typed array's `length` write
throws (#​3043); an array truncation walks downwards and the generics
report the writes they fail (#​3072); argument validation and evaluation
order are corrected in five built-ins (#​3069); `Map` and `Set` get the
`[[SetData]]` tombstone their traversals are specified over (#​3073);
`Date.prototype.setTime` stores the clipped time value (#​3042); and
`Array.prototype.values`/`keys`/`entries` no longer gate on an
array-like receiver (#​3236).

**Iterators and control flow.** A throw from the iterator step no longer
closes the iterator (#​3047); the `done` flag is consulted before
stepping again (#​3048); a rejected `return()` propagates out of an
abandoned `for await` loop (#​3113); an optional-chain short circuit is
distinguished from a genuine `undefined` (#​3040); a computed property
key is evaluated even when spelled as a literal (#​3039) and survives an
`await` or `yield` intact (#​3144, #​3150); and destructuring the rest
of an exhausted array yields an empty array rather than 2³² elements
(#​3263).

**Numeric and string accuracy.** `Math.acosh`, `asinh`, `atanh`, `cbrt`,
`expm1` and `log1p` are ported from fdlibm for correctly-rounded results
across every target framework (#​3050); `toFixed` formats from the
double's exact value and reads `this` from `[[NumberData]]` (#​3071);
`String.prototype` case conversion derives from Jint's own Unicode
tables rather than the host's culture data (#​3068); and the regex
engine is chosen per subject, with `RegExp.prototype.replace` no longer
rewriting `lastIndex` (#​3070).

**Bounds that hold.** JavaScript strings have a maximum length instead
of a wrapped array rent (#​3015); a JSON document too long to become a
string is refused while it is being built (#​3028); a frame displaced by
a proper tail call keeps counting while its trampoline runs, so
`MaxRecursionDepth` cannot be evaded by leaving and re-entering the
trampoline (#​3022); and an `Atomics` waiter is released when nothing
can ever notify it again (#​3029).

**Error messages no longer run user JavaScript** (#​3041) — rendering a
message for a value with a script-supplied `toString` used to invoke it,
from inside the failure path.

**Internationalization.** The five Temporal members the proposal removed
are dropped (#​3014), and `u`-extension options are canonicalized with
every date format the spec allows (#​3018).

Two fixes in this release come from **@​svenrog** — a sloppy function
answering its own `arguments` (#​3061) and the outer link on a parked
`Function`-constructor environment (#​3063).

## What's Changed
* Drop the five Temporal members the proposal removed by @​lahma in
sebastienros/jint#3014
* Canonicalize u-extension options and format every date the spec allows
by @​lahma in sebastienros/jint#3018
* Mark a global created by an unresolvable assignment, and stop a
waitAsync timeout outliving its engine by @​lahma in
sebastienros/jint#3019
* Run test262's staging/ directory too by @​lahma in
sebastienros/jint#3016
* Give JavaScript strings a maximum length instead of a wrapped array
rent by @​lahma in sebastienros/jint#3015
* Let a for-of frame decline the unwind it can only rethrow by @​lahma
in sebastienros/jint#3017
* Keep counting a frame a tail call replaced while its trampoline runs
by @​lahma in sebastienros/jint#3022
* Unpark staging/Temporal/removed-methods.js, which #​3014 already fixed
by @​lahma in sebastienros/jint#3023
* Drop the Islamic date conversions no calendar path reaches by @​lahma
in sebastienros/jint#3027
* Let an Atomics waiter go when nothing can ever notify it again by
@​lahma in sebastienros/jint#3029
* Refuse a JSON document too long to be a string while it is being built
by @​lahma in sebastienros/jint#3028
* Bump the microsoft group with 3 updates by @​dependabot[bot] in
sebastienros/jint#3033
* Bump the analyzers group with 1 update by @​dependabot[bot] in
sebastienros/jint#3031
* Add initial threat model for untrusted scripts by @​sebastienros in
sebastienros/jint#3030
* Stop ClassBenchmark rebuilding its engine per iteration by @​lahma in
sebastienros/jint#3053
* createRealm installs a full $262 on the new realm and returns it by
@​lahma in sebastienros/jint#3044
* Give the benchmark suite a measurement environment by @​lahma in
sebastienros/jint#3055
* Evaluate a computed property key even when it is spelled as a literal
by @​lahma in sebastienros/jint#3039
* Stop error messages from running user JavaScript by @​lahma in
sebastienros/jint#3041
* Array.from honours IsConstructor, and a typed array's length write
throws by @​lahma in sebastienros/jint#3043
* Consult the iterator's done flag before stepping it again by @​lahma
in sebastienros/jint#3048
* Date.prototype.setTime must store the clipped time value by @​lahma in
sebastienros/jint#3042
* Answer a sloppy function's own arguments instead of throwing by
@​svenrog in sebastienros/jint#3061
* Keep the outer link on a parked Function-constructor environment by
@​svenrog in sebastienros/jint#3063
* A throw from the iterator step must not close the iterator by @​lahma
in sebastienros/jint#3047
 ... (truncated)

Commits viewable in [compare
view](sebastienros/jint@v4.16.0...v4.16.1).
</details>

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

Stack overflow instead of RecursionDepthOverflowException in 4.16.0 for tail call with nested execute

1 participant