Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ Jint is not only an engine to work on, it is an engine that gets **embedded**. I
| `Module.Location` — the name a module knows itself by, never null, and the `referencingModuleLocation` its own imports resolve against | `Jint/Runtime/Modules/Module.cs` |
| `ModuleFactory.LocationOf` — public **static**, the rule deriving that name from a `ResolvedSpecifier`, which a host naming a module itself has to match exactly | `Jint/Runtime/Modules/ModuleFactory.cs` |
| `IAsyncModuleLoader` + `ModuleLoadCompletion` + `AsyncModuleLoader` + `Module.LoadRequestedModules` + `Engine.Modules.StartImport` / `ImportAsync` + `ModuleImportOperation` | `Jint/Runtime/Modules/`, `Jint/Engine.Modules.cs` |
| The `*Async` failure channel — which of the two ways out of an `*Async` entry a given failure takes | `Jint/Engine.Async.cs`, `Jint/Engine.Modules.cs`, `Jint/Engine.Pump.cs` |

`Get` and `Set` are overridable too, but they are `public override` of `JsValue` virtuals rather than declared on `ObjectInstance`.

Expand Down Expand Up @@ -324,6 +325,7 @@ Each of these cost a real integrator or a real bug.
- **The handler-tree caches engage only on the *second* evaluation of a given script on a given engine.** A host that builds a fresh engine per operation never reaches them, by design. This concerns that cross-run carry-over only — caches which are not engine-scoped still pay back, and CLR member resolution in particular now survives across engines through the shared `TypeResolver` described above, which is the main reason a fresh engine per operation costs less than it used to. `Engine.Advanced.RestoreGlobalSnapshot` deliberately **preserves** these caches (`_scriptStatementLists`, `_functionDefinitions`, `_evaluatedScripts`) while resetting the globals — reaching them from a host that used to need a fresh engine per evaluation is the entire point of that API, and `Jint.Tests/Runtime/GlobalSnapshotInternalsTests.cs` pins it.
- **A warmed member-read site retains its last receiver.** The prototype-method inline cache stores the receiver, its direct prototype and the resolved descriptor on the handler node (`JintMemberExpression._cachedProtoReceiver`), and only replaces that entry when the same site later caches a *different* receiver — a miss that does not qualify for caching leaves the old one in place. Handler trees are engine-owned and survive between evaluations on a re-running engine, so a pooled engine can hold one host object alive per warmed call site until its next run. Nothing clears these: `Engine.Advanced.ResetCallStack` releases call frames only. A host whose receivers wrap large native state that must not outlive a run should drop the engine rather than pool it. **A warmed *call* site retains its last callee the same way, and one of the two caches is heavier than it looks.** `JintCallExpression._fastCallee` (the built-in fast-call lane) usually holds a realm intrinsic, which the realm roots anyway — but the field is written for whatever `Function` an eligible site dispatched, before and regardless of the shape verdict, so a site with at most two arguments and no spread calling an *interpreted* function pins that too. `_regCallee` (the register-argument lane for interpreted callees) holds a `ScriptFunction` by construction, and a `ScriptFunction` holds the environment it closed over — so a warmed site pins one closure instance, and transitively whatever that closure captured, for the engine's lifetime. It is the same bounded, one-entry-per-site hazard, with the same remedy: drop the engine rather than pool it when the retained graph matters. The bound is per site, not per callee: a polymorphic site overwrites its entry rather than accumulating. The sibling `_regProbedCallee` records what the probe last examined so a rejected callee is not re-probed on every dispatch, and it retains that reference too — for an accepted callee it is the same object as `_regCallee`, so the ceiling stays one live callee per site either way. **Which sites are in the retaining set is decided at build time**, by `_fastArgsEligible` and `_regLaneEligible`: a site with five arguments, or with a spread, can arm neither lane and so records nothing at all. Note also that a site only enters the set once its handler tree outlives the evaluation, i.e. from the second run of the same program on the same engine — a host re-parsing its source every time never warms one, and a `WeakReference` test written against `Execute(string)` therefore proves nothing.
- **`MaxRecursionDepth` counts one function's occurrences, not stack depth — and a tail call's displaced frame keeps counting.** `JintCallStack._statistics` is a multiset of the live stack keyed by `JintFunctionDefinition` identity (`CallStackElementComparer`), so the limit asks "how many times is *this* function on the stack", never "how deep is the stack". Two consequences. A recursion whose every level is a function created for that level — `eval`, `new Function`, a host re-running a script through a callback — repeats no definition and is invisible to the limit however deep it goes; `Options.Constraints.StackOverflowGuard` is the only thing that covers that shape, and no version of Jint has covered it any other way. And `JintCallStack.ReplaceTop` deliberately does **not** discount the frame a proper tail call replaced: the frame is gone, but the activation is not over while `ScriptFunction.ContinueTailCalls` is still on the native stack, so the occurrence is retained and handed back through `ReleaseTailRetention` in that trampoline's `finally`. Discounting it (which is what the code did through 4.16.0) made the limit blind to a recursion that leaves and re-enters the trampoline by a non-tail route — a getter, `new`, a coercion, a Proxy trap, a host callback — because every re-entry pushed the displaced function again at depth zero while the native stack kept growing, and `LimitRecursion` then ended in a process-killing stack overflow instead of a `RecursionDepthOverflowException`. Pinned from both sides in `Jint.Tests.PublicInterface/HostTailCallTests.cs` and `Jint.Tests/Runtime/TailCallOptimizationTests.cs`; the release half has its own test, because without it a loop over one *completed* tail delegation accumulates against the limit.
- **An `*Async` entry has two ways out, and which one a failure takes is a contract, not an accident.** Everything the operation itself does — parsing, running the script, an execution constraint tripping, a promise rejecting — is delivered through the returned `Task`. Only a **usage error** is thrown out of the call: a `null` argument, a `Prepared<Script>` that did not come from `PrepareScript`, and the `InvalidOperationException` refusing the call because the engine is already in use. Those three say the operation never started, so there is nothing for a task to describe; everything else says it started and failed, and a host that wrapped its `await` must see it. The rule holds for the whole family — `EvaluateAsync` (both overloads), `ExecuteAsync`, `InvokeAsync`, `Modules.ImportAsync`, `Advanced.WaitForScheduledWorkAsync`, `JsValue.UnwrapIfPromiseAsync` — which is the point of stating it: before it did, the family was split by nothing more principled than which methods happened to be declared `async`. `ExecuteAsync` was, so a budget failure reached its task; `EvaluateAsync` was not, so the identical failure on the identical script erupted from the call. Worse, it erupted *conditionally*: `MemoryLimitConstraint` charges a host callback running on another thread to the same operation, so whether that charge landed before or after the engine thread reached `ScriptEvaluation`'s post-script check decided whether the exception came back as a faulted task or on the caller's stack — a coin flip resolved by thread scheduling, which a host cannot write a `catch` against (issue #3241; it flaked two unrelated PRs in one day). The mechanics that make the rule true: the entry validates arguments, takes the reservation, and *then* hands off to a `private async` body which owns the release in a `finally`. An `async` body captures its synchronous phase into the task, which is exactly the property wanted, so **never move the reservation into that body** (the failure it reports is a usage error) and **never hoist work out of it** (everything else belongs to the task). Nothing about the constraints themselves changed: same exception type, same message, still fatal, still bounding the same work — only the channel is now deterministic. Pinned from the embedder's side in `Jint.Tests.PublicInterface/HostAsyncFailureChannelTests.cs`, in both directions, including a forced-ordering reproduction of the cross-thread charge that needs no repetition to mean something.
- **Saturated sentinels register nothing.** `MaxStatements(int.MaxValue)`, `LimitMemory(long.MaxValue)` and `TimeoutInterval(TimeSpan.MaxValue)` — and any non-positive value, including `MaxStatements()`'s own parameter default — produce exactly the same engine as never calling the method, and additionally *remove* any previously registered constraint of that kind. A host spelling "effectively unlimited" that way has no limit, not a very large one.
- **`RestoreGlobalSnapshot` bumps version counters, it never restores them.** `_propertiesVersion`, `GlobalEnvironment._lexicalMutations`, `Engine._envBindingInjectionEpoch` and `EventLoop.Generation` are what every inline cache — and, for the last one, every queued job — validates against, so putting a counter *back* could make an entry built before the capture compare equal again and be revalidated against state it never saw. Anything added to the restore path obeys the same rule. The API's other half is its honesty: it reverts the global *binding table*, and explicitly not intrinsic/prototype mutations, object graphs behind restored bindings, host CLR state (including `Engine.Advanced.HostDefined`, which a pooled engine keeps across a restore and the host swaps per request itself), `Symbol.for`, or the module registry — it is a configuration-reuse primitive, not an isolation boundary, and the non-guarantees are pinned as surviving in `Jint.Tests.PublicInterface/GlobalSnapshotTests.cs`. Since bare identifiers resolve through the global's whole prototype chain, surviving intrinsic pollution is now a surviving **binding** as well as a surviving property: `Object.prototype.leaked = 1` in one cycle makes `leaked` resolvable as a free identifier in the next, where before it was readable only as `globalThis.leaked` and `typeof leaked` answered `"undefined"`. The global's own `[[Prototype]]` is a different matter and *is* captured and restored.
- **Discarding the event loop is a fence, not a flush.** `EventLoop.Clear()` can only throw away what is already queued, and the case that matters is the one where nothing is: a fire-and-forget async function suspended on a CLR `Task` enqueues its settle whenever that task happens to complete, which can be after a restore — and the resumed body then writes the previous cycle's data into the restored globals, a cross-cycle channel the fresh-engine-per-evaluation pattern never had. The fix is a generation captured at promise **registration** (engine thread), carried in the `EventLoopJob`, and checked at **dequeue** (engine thread): both ends are ordered by the single-thread contract, where a check inside the settle closure would race the restore. Any new enqueue path must stamp the registering cycle's generation, not the current one, whenever the two can differ. The consequence for hosts is real and documented: a promise registered before a restore never settles into the engine afterwards.
Expand Down
Loading