Keep counting a frame a tail call replaced while its trampoline runs - #3022
Merged
Merged
Conversation
`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
8 tasks
This was referenced Aug 23, 2026
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> [](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 was referenced Aug 26, 2026
This was referenced Sep 6, 2026
This was referenced Sep 13, 2026
This was referenced Sep 20, 2026
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.
Options.LimitRecursionstops firing altogether when a proper tail call is on the recursion path, and the host process dies on a native stack overflow that nocatchcan 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
MaxRecursionDepthis not a stack-depth limit.JintCallStack._statisticsis a multiset of the live stack keyed byJintFunctionDefinitionidentity (CallStackElementComparer), and every push compares that function's occurrence count against the limit. #3020's program is a two-function cycle:return calc()— a proper tail call;calcis re-parsed byengine.Executeon every pass, so every pass is a fresh definition (Engine._functionDefinitionsis keyed on the ASTNode);calc's body readsentity.calc, re-entering the getter throughEngine.Call— not 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, andReplaceTopremoved the displaced function from the statistics:so the stack becomes
[calc₁, calc₂, …, calcₙ, getter]— everycalcᵢa distinct definition counted once, and the getter re-added at depth 0 on every cycle because the previousReplaceToperased it. Nothing ever reaches 21.CallStack.Countgrows 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 fromGetRecursionDepth— which the erasure has just reset to zero.The fix
A displaced activation whose trampoline is still running still holds native stack:
ContinueTailCallsand everything beneath it have not returned. SoReplaceTopnow retains that occurrence and returns the target's depth exactly asPushdoes;ContinueTailCallsreads the depth from there and hands the retentions back throughReleaseTailRetentionin thefinallyit 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 withRecursionDepthOverflowException" — the statistic simply did not agree with the trampoline's private counters.The ledger is tallied against the function each
ReplaceTopdisplaced, 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 callResetCallStack()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. BothLimitRecursionandStackOverflowGuardsay 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
mainthe two regression tests do not fail, they take the runner with them —-1073741571is0xC00000FD,STATUS_STACK_OVERFLOW:Neither needs a large stack afterwards: the limit fires at depth 21, as asked.
HostTailCallTests.RecursionLimitStillFiresWhenATailCallIsOnThePathis the issue's shape as an embedder writes it — a host delegate that re-runs the script. InJint.Tests.PublicInterface, which has noInternalsVisibleTo, so it also proves the shape is reachable by a third party.TailCallOptimizationTests.RecursionLimitFiresWhenATailCallReEntersThroughAGetteris the same mechanism with no host code at all — indirectevalsupplies 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:EvalFunctioncaches 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
mainas well as here — they are the net for the new mechanism, not repros:HostTailCallTests.CompletedTailDelegationDoesNotAccumulateAgainstTheLimit— 100 iterations of one completed tail delegation underLimitRecursion(0). Without the release this fails on the second iteration.TailCallOptimizationTests.RecursionLimitFailureLeavesTheDepthStatisticBalanced— a leaked retention is invisible inCallStack.Countand 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),RecursionLimitFailureLeavesCallStackBalancedandDistinctTailDelegationDoesNotCountAsRecursionare 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/Scriptsis handlebars (template-rendering), and for a no-limit engine — which is every benchmark — the new code is strictly less work per hop:ReplaceTopdrops aCallStackElementcopy and aCallStackElementComparer.Equalscall and returns early on null statistics, against one addedintcompare. The per-trampoline cost is three null checks in afinally. Happy to add atemplate-renderingbefore/after row if you would rather see one.Verification
All Release, all green:
Jint.TestsJint.Tests(JINT_HOST_CONTRACT_VERIFICATION=1)Jint.Tests.PublicInterfaceJint.Tests.PublicInterface(JINT_HOST_CONTRACT_VERIFICATION=1)Jint.Tests.Test262Jint.Tests.CommonScriptsCloses #3020
🤖 Generated with Claude Code
https://claude.ai/code/session_0163Srj3aNzScH1keGb9smyg