Skip to content

Refresh async memory accounting for current runtime - #3216

Merged
lahma merged 2 commits into
mainfrom
sebros/improve-memory-accounting
Aug 22, 2026
Merged

lahma merged 2 commits into
mainfrom
sebros/improve-memory-accounting

Conversation

@sebastienros

Copy link
Copy Markdown
Owner

Summary

  • refresh Account memory across async continuations #3036 memory accounting onto the current pump-driven web/runtime seams
  • carry allocation state through finite continuations (fetch/streams, timers, idle callbacks and scheduler tasks) while giving persistent external events a fresh per-delivery budget
  • coordinate memory segments with nested cross-thread callback ownership transfer and preserve fatal constraint provenance across host Tasks
  • abort and clean transient async work safely when a memory operation exceeds its budget

Context

Follow-up to merged #3036. This is the semantic current-main refresh requested in the cascade review comment. The latest QuotaExceededError, pump-driven timeout, ownership, WPT and web-API changes remain intact.

Validation

  • all-target dotnet build Jint/Jint.csproj -c Release
  • full unit suite: 9,810 passed / 5 skipped (excluding the known local timezone-sensitive year-10000 test)
  • public interface: 2,105 passed / 9 configuration skips
  • host-contract public interface: 2,109 passed / 5 inverse-configuration skips
  • focused memory/concurrency/web stress: 112 passed per run, repeated 3 times
  • post-rebase focused unit tests: 352 passed
  • post-rebase focused public tests: 112 passed
  • git diff --check

Benchmark project restore is currently blocked by the mandated package proxy not carrying YantraJS.Core 1.2.462 (nearest available 1.2.422); no alternate feed was used.

@lahma

lahma commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Heads-up on the rebase this now needs: the merged #3036 carries one thing that was not in the version you refreshed from.

main had grown around thirteen cross-thread AddToEventLoop sites since #3036 was written — timers, fetch, streams, WebSocket, EventSource, MessagePort/BroadcastChannel, finalization-registry cleanup — and none was in that PR's stated scope. Rather than quietly extend the budget into them, the merged version adds an explicit unaccounted overload, AddToEventLoop(Action continuation, int generation), whose doc comment names those subsystems and says that extending the budget to them is a deliberate change with its own tests.

That is precisely what this PR does, so on rebase those call sites should move to the three-argument accounted overload, and the unaccounted one can go with them if nothing is left using it. Keeping it around after this lands would just be a documented hole nobody needs.

Everything else in #3036 is as you had it; the only other change from the refresh was composing ConstraintFailure.MustPropagate over Throw.MustPropagateHostException rather than keeping two overlapping lists.

sebastienros and others added 2 commits August 22, 2026 09:21
Carry allocation state through the pump-driven web continuations, preserve persistent-event task boundaries, coordinate transferred callback threads, and abort transient work safely when a memory operation fails.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d07275e-227c-4672-becd-4fe336386da4
The rebase across the eighteen commits since #3036 leaves five things that had
to change, plus two defects the refresh introduced.

Reconciliation. `ConstraintFailure.MustPropagate` composes over
`Throw.MustPropagateHostException` on main, and that list already carries a
marker — `Throw.TimeoutException` and `Throw.OperationCanceledException` register
the exception they mint in `EngineAbortRegistry`, so Jint's own timeout and
cancellation are already distinguishable from a host's. The refresh brought a
second marker of its own (an `Exception.Data` key, `Throw.Constraint*Exception`
duplicates of three throw helpers, a `MemoryLimitPlatformNotSupportedException`
subclass) and a standalone list to read it with. All of that is gone; what it was
for is kept, in the one place it was actually needed: the faulted-Task scan in
`JsValue.ConvertTaskToPromise` reads `Throw.MustPropagateHostException` rather
than `ConstraintFailure.MustPropagate`, because for a queued turn a
`TimeoutException` is Jint's own and nothing else, while for a host Task it is an
`HttpClient` timeout and must stay a promise rejection.

`Constraint.BeforeFailure` is gone too: the same refresh added
`Engine.CheckConstraint`, which does the same job at the call site and covers
user-derived constraints the hook could never reach. It is now a `try` around
each of the three constraint loops rather than a wrapper per constraint, so the
non-throwing path is unchanged instruction for instruction, and the filter
excludes `JavaScriptException` — script can catch that one, and abandoning the
operation under it would hand the script a fresh budget.

The five `AddToEventLoop` overloads collapse to three: `EventLoopRegistration`
now carries every deferred registration, so `(Action, int, OperationState?)` and
`EnqueueModuleLoadCompletion`'s triple go away, and `ModuleLoadCompletion` and
the `Atomics.waitAsync` waiter each lose a field. `HostStreamBridge` hands the
registration back from `TryBeginOperation` instead of parking it in a field for
the next line to read, and its now-unread `Generation` goes with it.
`GetHostCallbackOwner` no longer returns an owner, so it is
`GetHostCallbackAuthorization`.

Defects. A `setInterval` inherited the budget of the operation that scheduled it
and charged every tick to it, so an engine whose heartbeat allocates a few bytes
a second died after enough ticks — the refresh's own rule says repeating work
starts fresh, and its docs said timers were finite continuations. A repeating
`TimerEntry` now keeps the cycle stamp and drops the budget, which is what every
persistent event source already does; `AnIntervalStartsAFreshBudgetOnEveryTick`
fails without it. And `MemoryLimitConstraint`'s teardown runs host code — a
worker host told its connection ended, a host stream disposed — which may
re-enter the engine and find a fresh operation over the same budget, so it is
guarded to run once.

Two tests join it: an engine is usable again after a teardown, and a teardown
discards the timers the failed operation scheduled.

Co-authored-by: Sébastien Ros <1165805+sebastienros@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VecfHbij4EMQFzL7NCpdWW
@lahma
lahma force-pushed the sebros/improve-memory-accounting branch from 234cb6b to 5cc597f Compare August 22, 2026 08:07
@lahma

lahma commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto main (9817530) and pushed, with a second commit for what the rebase and a read-through turned up. Your commit is untouched underneath it, so the two are reviewable apart.

What main had grown under this

ConstraintFailure was the only textual conflict, and the interesting one. Main's version composes over Throw.MustPropagateHostException, and that list already carries a marker: Throw.TimeoutException and Throw.OperationCanceledException register what they mint in EngineAbortRegistry, so Jint's own timeout and cancellation are already distinguishable from a host's. This PR brought a second marker for the same job — an Exception.Data key, Throw.Constraint*Exception duplicates of three helpers, and a MemoryLimitPlatformNotSupportedException subclass — plus a standalone list to read it with. I dropped all of it and kept what it was for, in the one place that actually needed it: the faulted-Task scan in ConvertTaskToPromise reads Throw.MustPropagateHostException rather than ConstraintFailure.MustPropagate. The distinction matters exactly there — for a queued turn a TimeoutException is Jint's own and nothing else, but for a host Task it is an HttpClient timeout, and HostTaskTimeoutRemainsACatchablePromiseRejection still passes because of it.

Constraint.BeforeFailure went too. The same commit added Engine.CheckConstraint, which does the same job at the call site and covers user-derived constraints the hook could never reach — DirectAccessorMemoryTeardownDoesNotMaskACustomConstraintFailure only passes because of the wrapper, not the hook. Two mechanisms for one thing; the hook was the one that also put a settable property on the public Constraint base and a delegate per constraint per engine. It is now a try around each of the three constraint loops rather than a wrapper per constraint, so the non-throwing path is unchanged instruction for instruction, and the filter excludes JavaScriptException — script can catch that one, and abandoning the operation under it would hand the script a fresh budget.

On the AddToEventLoop note

I did not move the thirteen sites, because this PR's answer is better than the one I suggested and the diff argues it: a WebSocket frame or a MessagePort delivery is not one finite continuation of the operation that created the object, so inheriting that operation's budget would fail a long-lived socket permanently once the creating operation had spent it. Keeping the unaccounted overload for those, with the doc rewritten to say so, is right.

What I did do is collapse the accounted overloads. EventLoopRegistration is exactly (generation, memory state), so (Action, int, OperationState?) and EnqueueModuleLoadCompletion's triple were a second spelling of it; five overloads are now three, and ModuleLoadCompletion and the Atomics.waitAsync waiter each lose a field. Same for HostStreamBridge, which parked the registration in a field for the next line to read — TryBeginOperation hands it back instead, and the now-unread Generation goes with it. GetHostCallbackOwner no longer returns an owner, so it is GetHostCallbackAuthorization.

Two defects

setInterval inherited one budget and charged every tick to it. The rule this PR states is that repeating work starts fresh and only finite continuations inherit — but its docs list "timers" as finite, and TimerEntry carries one registration for the life of the entry. So an engine with LimitMemory and a heartbeat that allocates a few bytes a second dies after enough ticks. A repeating TimerEntry now keeps the cycle stamp and drops the budget, which is what every persistent event source already does; AnIntervalStartsAFreshBudgetOnEveryTick fails without the fix. README and THREAT_MODEL now say setTimeout where they said "timers".

The teardown could run twice. MemoryLimitConstraint.Check → AbortMemoryLimitedOperation → ResetTransientEvaluationState runs host code — a worker host told its connection ended, a host stream disposed — and host code may re-enter the engine, where the next statement check finds a fresh operation over the same budget and starts a second teardown from inside the first. Guarded to run once.

Also folded Check's double-throw into an if/else (both branches threw), documented AbortMemoryLimitedOperation and RunWithMemoryAccounting, and reverted a signature-reflow in Host.cs that had no change in it.

Two things worth a second opinion before this lands

  • The teardown's blast radius. ResetTransientEvaluationState is engine-wide, so on net8+ a memory overrun in one operation ends every worker connection the engine is party to and closes every host-stream bridge, not just the failing operation's. That is what makes the limit end the work rather than report on it, and it is documented in both files — but it is a real behaviour change for a pooled engine, and it is the piece I would most want you to confirm is the intent.
  • Moving EndOperation() off the engine thread in the stream bridges is right, and it fixes a latent leak on main: EndOperation's own doc says it must be called before the completion is enqueued precisely because a job can be dropped by the generation fence, and the code called it inside the job. The consequence is that ReleaseHostStream — and therefore HostStream.Dispose() — can now run on the I/O completion thread rather than the engine thread. Consistent with the class's stated split, but worth naming.

Validation

All on the rebased head, Release.

suite result
Jint.Tests 10,052 net10.0 / 7,102 net472, 0 failed
Jint.Tests + Jint.Tests.PublicInterface, JINT_HOST_CONTRACT_VERIFICATION=1 0 failed
Jint.Tests.PublicInterface 2,395 net10.0 / 1,874 net472, 0 failed
Jint.Tests.CommonScripts 28 + 28, 0 failed
Jint.Tests.SourceGenerators 52, 0 failed
Jint.Tests.Test262 102,488 passed, 7 failed — see below

The seven are staging/sm/TypedArray/{element-setting-converts-using-ToNumber,sort_large_countingsort}.js, ArrayBuffer.prototype.sliceToImmutable/argument-coercion.js and Atomics/notify/notify-renotify-noop.js, all of them 30-second engine timeouts on a loaded machine. main at 9817530 fails the same set on the same box — 22 of them in one full run, and 3 of 3 when the subset is run alone against each branch in turn. No delta.

Not benchmarked. Two things touch a hot path and both look free by inspection: HostCallScope grows two fields whose only writer is the transferred-callback constructor (SROA'd, constant branch in Dispose), and the constraint loops gain a try region that emits nothing until it throws. Say the word if you want a gate run on EngineConstructionBenchmark / EngineComparisonBenchmark anyway.

@lahma

lahma commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

CI is green on 5cc597f8a — all five legs, including windows and linux - host-contract verification. MERGEABLE / CLEAN.

The first windows attempt failed and the re-run of the same commit passed. Both failures were wall-clock, and neither engine in them configures LimitMemory, so every path this PR touches is inert for them — RunEventLoopJob takes its memoryLimit is null early return, and CaptureEventLoopRegistration() yields (generation, null), which makes the rewritten enqueues byte-equivalent to the ones they replaced.

I reproduced the deadline one on both trees anyway rather than assume, by pinning the test host to two logical CPUs under contention: main 1/12, this branch 3/12, and 0/40 on each once the box was quiet. It is the OperationDeadlineConstraint budget-split family already tracked in #3221, whose standing rule is a budget-widening fix on recurrence — evidence and a proposed fix shape are in that issue rather than here, since the test belongs to #3046 and not to this PR. The second failure, WebApiMultipartTests.ReadsAMultipartResponseBackAsFormData hitting the 10 s PromiseTimeout on an in-memory HttpMessageHandler, is noted there too; it did not reproduce in 52 pinned runs on either tree.

Test262 on the CI runner: 102,495 passed, 0 failed. (The seven I reported locally were all 30 s engine timeouts on a loaded box, and main failed the same set there.)

@lahma
lahma merged commit 4c24f58 into main Aug 22, 2026
9 of 10 checks passed
@lahma
lahma deleted the sebros/improve-memory-accounting branch August 22, 2026 09:08
lahma added a commit that referenced this pull request Aug 23, 2026
…ider (#3268)

`TimerQueue` has taken its clock from a `TimeProvider` since #3216, which is
what lets its tests drive it with a `ManualClock` and assert exactly.
`TimeConstraint` and `OperationDeadlineConstraint` read `Stopwatch.GetTimestamp()`
directly, so nothing could move their clock and every test of them was a
real-time test — one-sided at best, plus a skip for the run where a contended
machine stalled past the whole budget. That is the shape behind #3123, #3154,
#3221 and the sighting in #3230; each was fixed by widening or by weakening the
claim rather than by removing the cause.

The seam is public, because the problem is not Jint's alone: any embedder that
configures `TimeoutInterval` has the same untestable wall clock. Two entry
points, matching who creates the constraint — `Options.Constraints.TimeProvider`
for the one the engine registers, and an `OperationDeadlineConstraint(TimeProvider)`
overload for the one the host constructs itself. Both are `net8.0`-and-later:
reaching `TimeProvider` from `net462`/`netstandard2.x` would mean a second
runtime dependency on a package that has exactly one, which is not a trade worth
making for a seam whose main consumer is a test.

Cost on the amortized lane, which is where `TimeConstraint.Check` is driven:

  * `ConstraintClock.Resolve` folds `TimeProvider.System` — and no provider — to
    a null field, and a null field means the constraint reads `Stopwatch`
    directly exactly as before. `TimeProvider`'s own base `GetTimestamp` and
    `TimestampFrequency` *are* `Stopwatch`'s, so the fold changes no answer.
  * Default-path codegen gains three instructions: a load from the same cache
    line as `_deadline`, a test, and a never-taken branch, in the shadow of an
    ~18 ns `QueryPerformanceCounter`. Measured per check across six rounds of a
    monomorphic micro-benchmark: |delta| <= 0.26 ns, best-conditioned rounds
    ~0.1 ns.
  * `Check` runs once per 64 statements. On the most check-dense row in the
    suite (`ConstrainedExecutionBenchmark`'s 1M-iteration loop) that is exactly
    15,625 checks per evaluation, so the delta is <= 4.1 us of a ~20.4 ms
    evaluation — <= 0.02% of the row, and <= 0.8% of what the whole amortized
    timeout lane already costs it (~2.4%). Two orders of magnitude below what a
    paired BenchmarkDotNet A/B can resolve, which is why the evidence here is
    codegen plus an isolated per-check measurement rather than a suite A/B.
  * On the downlevel targets the field does not exist and neither does the
    branch: the only change is a static `AggressiveInlining` helper that inlines
    to the instruction it replaced.

No constraint behaviour changes. Same exception types, same messages, same
firing points, same `IsAmortizable`, same no-op `Reset` on the operation
deadline; the interval-to-ticks arithmetic and its overflow clamp move to
`ConstraintClock.ToTimestampTicks` unaltered. A clock reporting a non-positive
`TimestampFrequency` is rejected where it is supplied, since it would otherwise
arm every deadline at its own start and fail the first check of every execution.

The payoff, on `HostResultLimitsTests.OperationDeadlineSpansEvaluationAndConversion`:
it used to spend the budget with a 500 ms `Thread.Sleep` and could then make
only two claims — "the evaluation did not throw", skipped when a stalled runner
made it throw anyway, and "the conversion did throw" — because how much of a
wall-clock budget an entry consumes is a fact about the machine. It now straddles
the budget to the tick: one tick short of it a fresh entry still completes, and
the tick that exhausts it fails the next one. Neither half was expressible
before, there is no skip and no sleep, and everything except the clock is the
shipped path. The downlevel leg keeps the old row. Both halves were confirmed
live by mutation: arming `Begin` off the seam, and arming the deadline one tick
early, each fail it.

Verified: solution builds Release on all five target frameworks; `Jint.Tests`
(10498/7256) and `Jint.Tests.PublicInterface` (2490/1899) green on both target
frameworks, plain and with `JINT_HOST_CONTRACT_VERIFICATION=1`; test262
102495 passed / 189 skipped / 102684 total, identical to an `upstream/main`
baseline run on the same machine.

Fixes #3232


Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants