Repository navigation
Reject concurrent Engine use - #3035
Conversation
|
A lock never hurt anyone :/ ... let's ask your benchmarking agent about the impact. And forget about it if it has any. |
lahma
left a comment
There was a problem hiding this comment.
I validated this branch (c4eddf6) against its base (a1a2343) in two worktrees: full suites, targeted probes for each ownership transition, and an A/B benchmark pass in four run orientations.
The direction is right and most of the machinery holds up. But the cross-thread host-callback path is broken in three independent ways, and none of them is covered by the new tests — all 17 HostEngineConcurrencyTests block the engine thread inside a callback, so none of them ever reaches EnterTransferredHostCall. That is why CI is green.
Suites (PR head)
| Suite | Result |
|---|---|
Jint.Tests |
5761 / 0 (net10.0), 5676 / 0 (net472) |
Jint.Tests.PublicInterface |
1505 / 0 (net10.0), 1504 / 0 (net472) |
Jint.Tests.PublicInterface, JINT_HOST_CONTRACT_VERIFICATION=1 |
1509 / 0, 1508 / 0 |
Jint.Tests.CommonScripts |
28 / 0 both TFMs |
Jint.Tests.Test262 |
102,326 passed / 0 failed, 342 skipped |
HostEngineConcurrencyTests × 20 consecutive runs |
20/20 — not flaky |
dotnet build -c Release Jint/Jint.csproj |
all 5 TFMs, 0 warnings |
Blocking
1. A host callback on a foreign thread permanently bricks the engine
Jint/Engine.cs:215. EnterTransferredHostCall has five exits; four return
new HostCallScope(this, callback ? owner : null). The one a transferred callback actually takes —
the success tail after AcquireHostCall — drops the callback owner:
_ownerDepth = 1;
_ownerToken = owner;
return new HostCallScope(this); // <-- callback owner lostSo HostCallScope.Dispose never calls ReleaseHostCallbackAdmission, and EnterHostCallback
releases the admission only in its catch. Every admitted cross-thread callback leaks one
_hostCallbackAdmission. ReleaseAsyncHostOperation (Engine.cs:361) then sees
_hostCallbackAdmission > 0, parks _asyncReleasePending and returns without clearing
_asyncOwner — and nothing else ever clears it. The engine is dead: every later entry throws
"already in use".
The shape is ordinary — a JS callback handed to a CLR API that runs it on a pool thread:
var engine = new Engine();
engine.SetValue("schedule", new Func<Func<JsValue, JsValue[], JsValue>, Task<int>>(
cb => Task.Run(() => { cb(JsValue.Undefined, []); return 1; })));
var value = await engine.EvaluateAsync("(async () => { await schedule(() => 1); return 42; })()");
value.AsNumber().Should().Be(42); // passes — the async operation itself succeeds
engine.Evaluate("1 + 1").AsNumber().Should().Be(2);
// InvalidOperationException: This Engine is already in use by another thread or has an
// asynchronous operation in progress.With a 50 ms delay on the pool thread (so the admission window is certainly open) this is
25/25 runs bricked, _hostCallbackAdmission == 1 left behind. With no delay, 63/100 brick and
the other 37 hit finding 2. There is no benign outcome.
Supplying the missing argument fixes this half — verified, 25/25 → all usable:
_ownerDepth = 1;
_ownerToken = owner;
- return new HostCallScope(this);
+ return new HostCallScope(this, callback ? owner : null);2. The admission window is a timing window, so the same callback is spuriously rejected
Jint/Engine.cs:166. _hostCallbackAdmission is non-zero only while
AwaitPromiseSettlementAsync is inside await eventLoop.WaitForEventAsync(...). A callback whose
Interlocked.Increment lands anywhere else during the same pending async operation sees == 1,
concludes there was no window to claim, falls through to EnterHostCall() and throws — even though
the engine is idle and no other thread holds it.
Because the throw happens inside the host's Task, it comes back as a rejection of the awaited JS
promise. In the repro above, with no artificial delay, 19–37% of runs fail with
PromiseRejectedException wrapping "already in use". It survives the fix for finding 1 (81 usable /
19 rejected with the patch applied), so it needs its own answer: whether a callback may be admitted
should be a function of state — is anyone executing? — not of whether the awaiter has reached its
await yet.
3. A synchronous cross-thread hand-off inside a host method now throws
A host method that dispatches the callback to another thread and blocks on it — the
dispatcher / UI-thread / render-thread shape — is strictly serialized: exactly one thread touches the
engine at a time, and the engine thread is parked inside the host method throughout. Works on the
base branch, throws here:
engine.SetValue("onOtherThread", new Func<Func<int, int>, int>(cb =>
{
var result = 0;
var t = new Thread(() => result = cb(41));
t.Start();
t.Join();
return result;
}));
engine.Evaluate("onOtherThread(x => x + 1)");
// base: 42 head: InvalidOperationExceptionOwnership is keyed on thread identity rather than on mutual exclusion, so a provably-serialized
hand-off is rejected. It need not be — the owner thread is blocked and _ownerDepth is known — but
if that is the intended contract it should be stated outright, because it silently breaks working
embeddings. Note the exception surfaces on the callback thread, where a host that spawned a bare
Thread has no handler at all.
Behaviour changes missing from "Breaking change?"
All four verified as fails-on-head / passes-on-base:
ExecuteAsyncandModules.ImportAsyncstopped beingasyncmethods, so parse errors and
top-level throws now propagate synchronously instead of arriving as a faultedTask. A host
writingvar t = engine.ExecuteAsync(src); try { await t; } catch { … }stops catching.Dispose()can throw. Fine as a contract, awkward as afinally: inside
using var engine = new Engine();with an outstanding async operation, the
InvalidOperationExceptionfromDisposereplaces the exception that was already unwinding,
and the recent-wrapper cache is never released. Worth considering fail-fast on use but
best-effort on teardown.ManualPromisecontinuations no longer drain inline unless the settling thread is the thread
that calledRegisterPromise.TryEnterHostCallalready proves exclusive ownership by itself;
the extraregistrationThreadIdcomparison is what changes behaviour for a legitimate host that
registers on one thread and then drives the engine exclusively from another (the pooled-engine
shape) —Resolvesilently stops running continuations and the host now needs
Advanced.ProcessTasks(). If the narrowing is deliberate it belongs in the README paragraph.Engine.Advanced.RegisterPromise()now takes ownership, so registering a promise from a
background thread while script runs starts throwing.
Confirmed good
-
The
DefaultTypeConverterbinder change fixes a real pre-existing cross-engine bug.
_targetBinderDelegateCacheisstaticand keyed on the AST, so with aPrepared<Script>shared
across engines the binder built for engine A bakedExpression.Constant(_engine)and then
converted engine B's callback arguments with engine A. Isolated probe — fails on base, passes here:var prepared = Engine.PrepareScript("capture(x => x instanceof Object)"); // engineA and engineB both Execute(prepared), each capturing its callback callbackA(new object()).Should().BeTrue(); callbackB(new object()).Should().BeTrue(); // base: False
That deserves its own PR — it is unrelated to concurrency and would be easy to lose in a revert.
-
BreakPointCollectionbecoming independently thread-safe, and theModuleImportOperation
publication ordering (_completedwritten last), are both straightforwardly right. -
The trimming/AOT concern I had about
engineType.GetMethod("EnterHostCallback", NonPublic)does
not bite:Function.CallFromHostcalls it directly, so the trimmer keeps it. A fully trimmed
(TrimMode=full, Jint not rooted) self-contained publish converts a JS function to a CLR
delegate correctly. Still a latent fragility; anameof-based lookup or[DynamicDependency]
would cost nothing.
Performance
Base = a1a2343, head = c4eddf6, default job, serial, each run from its own worktree. Four
independent A/B pairs per suite, alternating which side runs first; a row counts only if it moves the
same way in every pair.
Gate — passes. SunSpider, 26 rows × 4 pairs: no row regresses in all four pairs. Suite
medians −0.36% / +0.06% / −0.67% / +0.09%. Two rows that looked like regressions after two pairs
(bitops-nsieve-bits, regexp-dna) did not survive the third and fourth. Dromaeo, 24 rows × 2
pairs: the suite mean itself flips sign between orientations (−1.16% → +1.61%), i.e. cross-run drift
exceeds the effect; nothing attributable.
Interop, 4 pairs each (median of the four):
| Row | median Δ | note |
|---|---|---|
InteropBulkConversion.JsArrayToIntArray |
−7.6% | alloc 123.22 → 115.40 KB |
InteropBulkConversion.JsArrayToListOfInt |
−4.2% | alloc 119.97 → 112.15 KB |
FunctionInvocation.Warm_Apply |
−1.9% | |
FunctionInvocation.Warm_Call / Warm_BindThenCall |
−1.0% / −1.1% | |
ListInterop.Filter / Indexing |
−0.3% / −2.0% | signs disagree across pairs |
EngineConstruction.BuildEngine |
flat (584.0 → 583.6 ns) | alloc 7.77 → 7.80 KB |
FunctionInvocation.Warm_Bind |
+4.4% | 337.0 → 352.8 ns |
ListInterop.HasProperty |
+6.1% | 1.831 → 1.950 µs |
The two rows that do move consistently are not attributable to the guard, and I would not treat
them as a regression in the changed code: their siblings carry the identical guard and either don't
move or move the other way. Most tellingly, Warm_BindThenCall does everything Warm_Bind does
and more, and got faster by a larger absolute margin (−41.6 ns) than Warm_Bind got slower
(+15.8 ns). Same for HasProperty against Filter/Indexing in the same class. This is the
signature of the six new Engine instance fields shifting field offsets and code layout, not of
added work — reproducible within a build, but not a cost the guard imposes.
The one clearly attributable allocation change is +30 B per Engine (BuildEngine
7.77 → 7.80 KB) — the six new fields. Allocation is byte-identical everywhere else except
InteropBulkConversion, which allocates 7.8 KB less per op and runs 4–8% faster in all four pairs.
One note on method: the figure quoted in the PR body (EvaluateSimple 584.2 vs 585.0 ns) is a single
pair, and that row's StdDev reached 25.7 ns in my runs — not enough on its own to support a
conclusion, though the conclusion it reaches (flat) does match mine.
InteropMethodDispatchBenchmark was dropped deliberately: its own StdDev is ~5% per row, so it
cannot resolve an effect of this size, and quoting it would be quoting noise. No benchmark covers the
JS-function-as-CLR-delegate direction (Function.ToObject → CallFromHost → the try/finally-wrapped
compiled body), so that surface is unmeasured.
Smaller notes
_ownershipReleased(ManualResetEventSlim) is never disposed;Engine.Disposedoesn't release it.AcquireHostCallwaits without a timeout, so transferred callbacks are blocked, not failed fast —
worth reconciling with the "non-blocking" framing in the summary.ReleaseHostCallbackAdmissionwrites_ownerToken = nullfrom a foreign thread while the owner
thread reads it non-volatilely inEnterHostCall.ObjectInstance.ToObject'sObjectClass.Functionbranch now discards itsICallablepattern
variable and re-casts insideCallFromHost.
c4eddf6 to
d158208
Compare
lahma
left a comment
There was a problem hiding this comment.
The ownership protocol is well thought out — same-thread re-entry, async reservation with thread handoff, the admission window for background callbacks, and the fail-fast semantics are all coherent, and the test coverage for the rejection paths is thorough. One path is broken, though, and it permanently bricks the engine in what is probably the single most common async embedding pattern, so I have to request changes.
Blocker: callback admission token leaks on the cross-thread transfer path
EnterHostCallback increments _hostCallbackAdmission and documents that "a larger count keeps the transfer token alive until this callback's scope exits". Two of the three success branches in EnterTransferredHostCall honour that by constructing the scope with callback ? owner : null, but the normal cross-thread branch — the one an actual background callback takes — returns new HostCallScope(this) and drops the token:
_ownerDepth = 1;
_ownerToken = owner;
return new HostCallScope(this); // <- loses the admission token when callback == trueThe count never returns to zero, so ReleaseAsyncHostOperation (and the sync drain's ReleaseHostCallbackAdmission) defers the release forever and _asyncOwner stays set. Every subsequent public entry then throws InvalidOperationException — the engine is permanently unusable.
Repro (the canonical setTimeout bridge; passes on main, fails on this branch — first operation returns 42, the engine is dead afterwards):
var engine = new Engine();
engine.SetValue("later", new Action<Action>(cb => Task.Delay(50).ContinueWith(_ => cb())));
var result = engine.Evaluate("new Promise(r => later(() => r(42)))").UnwrapIfPromise();
result.AsNumber().Should().Be(42); // passes
engine.Evaluate("1 + 1"); // throws: "This Engine is already in use by another thread
// or has an asynchronous operation in progress..."The fix is the same one the other two branches already have:
_ownerDepth = 1;
_ownerToken = owner;
return new HostCallScope(this, callback ? owner : null);I verified locally that with this change the repro passes and all 17 HostEngineConcurrencyTests stay green. Please also add the repro above as a regression test — the suite currently exercises EnterHostCallback only in situations where the transfer is rejected (engine actively owned, cross-engine binder); nothing covers a background callback that successfully claims a suspended engine, which is exactly the scenario EnterTransferredHostCall(owner, callback: true) exists for and the one this PR's summary calls out as preserved ("thread-safe background completion", "top-level async thread handoff").
Not blocking
- The same pattern through
EvaluateAsync(instead of the blockingUnwrapIfPromise) times out after 10 s withPromiseTimeoutbecause a barer(42)settle from the callback thread enqueues nothing and never wakesWaitForEventAsync. That reproduces identically onmain, so it is pre-existing and not this PR's problem — but it may be worth a follow-up issue, since the fixed callback path makes the async variant look like it should work. - The PR body still says "Stacked on #3030; this PR should merge into
initial-threat-model" — stale now that the base ismain.
Add fail-fast engine ownership across synchronous, asynchronous, module, debugger, and conversion entry points while preserving same-thread reentrancy and callback handoff. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reject nested async entry before work starts, retain callback admission through callback exit, safely publish debugger state, and synchronize breakpoint administration independently. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5371a31-b609-476a-92b0-086b46eb0f0f
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5371a31-b609-476a-92b0-086b46eb0f0f
d158208 to
1bdea3c
Compare
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5371a31-b609-476a-92b0-086b46eb0f0f
|
Addressed the changes-requested reviews in
GitHub retargeted the PR to |
lahma
left a comment
There was a problem hiding this comment.
Re-verified the reworked ownership protocol on 8a10ea1a6:
- The admission leak is fixed on every path: the cross-thread transfer success path now carries the callback owner into
HostCallScope, both bail-out paths inEnterTransferredHostCalldo too, and the throw path releases in acatch— admission is balanced everywhere. Close/admit serialized under the operation-token lock closes the race with completion. DelayedCrossThreadCallbackReleasesBlockingPromiseOwnershipis exactly the reported repro (delayedAction<Action>bridge + blockingUnwrapIfPromise), including the post-operation assertion that the engine still works.- Hand-traced sync blocking drain, same-thread dispatcher reentry, nested suspensions, async operation lifetime, and straggler callbacks at completion — no deadlock (the resume waiter holds no engine ownership while it waits on the admission drain, and the last release always pulses).
- Local verification on this head: PublicInterface 1796/1565 (net10/net472) green, host-contract verification leg 1800/1569 green, Jint.Tests 7795/6663 green, and 10× stress iterations of HostEngineConcurrencyTests green on both TFMs.
One documented edge to keep in mind (not blocking, pre-existing in the earlier revision too): during a synchronous blocking drain, a background callback that lands while the loop is actively running jobs — rather than parked in WaitForWork — is rejected with the concurrent-use error instead of waiting for the next window. Async operations don't have that window since the reservation spans the whole operation. It's a clean rejection with no state mutated, consistent with the PR's contract.
…Work
A host driving one engine per thread had `TimeUntilNextScheduledWork` to tell it
*when* to pump and `ProcessTasks()` to do the pumping, and nothing at all for the
gap between them: a job enqueued from another thread — an interop `Task` settling,
an asynchronous module load completing, a message posted in from a second engine —
carries no due time, so the property cannot report it and the only way to find one
was to poll. A loop sleeping on a 20 ms ceiling therefore paid up to 20 ms of
latency on every such arrival, which for a worker-shaped host is the entire traffic.
public bool WaitForScheduledWork(TimeSpan timeout, CancellationToken cancellationToken = default);
public Task<bool> WaitForScheduledWorkAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
The internals were already complete and are only assembled here: `EventLoop`'s
work-arrived event and its reset-then-check `WaitForWork`, `WaitForEventAsync` for
the asynchronous form, and `TimeUntilNextPumpScheduledWork` for the clamp — so a
`setTimeout(f, 1)` wakes the wait in about a millisecond rather than at the caller's
ceiling. It deliberately **does not pump**: `ProcessTasks()` stays the pump and there
is still no third method that drains for a budget.
Four decisions worth stating:
* **The synchronous form claims the engine for its whole duration.** That is the
sebastienros#3035 admission rule and nothing this method adds, and it is what makes one
drainer per engine self-enforcing — a second thread asking for the wait, or for
any other guarded entry, gets *"This Engine is already in use by another thread"*.
`EventLoop.Enqueue` is deliberately unguarded, so waking a parked engine from
another thread is unaffected. Documented, including the consequence: a host
callback arriving from elsewhere is refused while the wait is parked, where a
`Thread.Sleep` would have admitted it.
* **The asynchronous form cannot hold thread-affine ownership across an await**, so
it takes the reservation every other async host entry takes — synchronously, so an
engine already in use is refused before a `Task` exists — and re-claims the
resuming thread with `EnterTransferredHostCall` for each look at the schedule.
It does not mirror `AwaitPromiseSettlementAsync`'s `_hostCallbackAdmission`
bracket (that exists to keep a converted callback admissible while script is
suspended, and this wait runs no script) nor touch `_pendingAsyncOperations`.
* **Inside a running job nothing counts as available** — the rule `WaitForWork` and
`DrainEventLoopUntil` already apply, for their reason: the re-entrancy guard makes
the queue unrunnable from there, so a `true` would hand back a verdict whose
`ProcessTasks` does nothing and spin the caller's loop hot for its whole ceiling.
* The ceiling is measured on `Stopwatch`, not the wall clock, so an NTP step cannot
cut it short or stretch it — and as elapsed-since-start rather than an absolute
deadline, so a `TimeSpan.MaxValue` ceiling cannot overflow the arithmetic.
`HasImmediatePumpWork` is extracted so that the wait and
`TimeUntilNextScheduledWork` answer "is there work right now" through one helper and
can never disagree.
Core engine surface: every target framework, no `#if`, no web-API flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd
…Work
A host driving one engine per thread had `TimeUntilNextScheduledWork` to tell it
*when* to pump and `ProcessTasks()` to do the pumping, and nothing at all for the
gap between them: a job enqueued from another thread — an interop `Task` settling,
an asynchronous module load completing, a message posted in from a second engine —
carries no due time, so the property cannot report it and the only way to find one
was to poll. A loop sleeping on a 20 ms ceiling therefore paid up to 20 ms of
latency on every such arrival, which for a worker-shaped host is the entire traffic.
public bool WaitForScheduledWork(TimeSpan timeout, CancellationToken cancellationToken = default);
public Task<bool> WaitForScheduledWorkAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
The internals were already complete and are only assembled here: `EventLoop`'s
work-arrived event and its reset-then-check `WaitForWork`, `WaitForEventAsync` for
the asynchronous form, and `TimeUntilNextPumpScheduledWork` for the clamp — so a
`setTimeout(f, 1)` wakes the wait in about a millisecond rather than at the caller's
ceiling. It deliberately **does not pump**: `ProcessTasks()` stays the pump and there
is still no third method that drains for a budget.
Four decisions worth stating:
* **The synchronous form claims the engine for its whole duration.** That is the
sebastienros#3035 admission rule and nothing this method adds, and it is what makes one
drainer per engine self-enforcing — a second thread asking for the wait, or for
any other guarded entry, gets *"This Engine is already in use by another thread"*.
`EventLoop.Enqueue` is deliberately unguarded, so waking a parked engine from
another thread is unaffected. Documented, including the consequence: a host
callback arriving from elsewhere is refused while the wait is parked, where a
`Thread.Sleep` would have admitted it.
* **The asynchronous form cannot hold thread-affine ownership across an await**, so
it takes the reservation every other async host entry takes — synchronously, so an
engine already in use is refused before a `Task` exists — and re-claims the
resuming thread with `EnterTransferredHostCall` for each look at the schedule.
It does not mirror `AwaitPromiseSettlementAsync`'s `_hostCallbackAdmission`
bracket (that exists to keep a converted callback admissible while script is
suspended, and this wait runs no script) nor touch `_pendingAsyncOperations`.
* **Inside a running job nothing counts as available** — the rule `WaitForWork` and
`DrainEventLoopUntil` already apply, for their reason: the re-entrancy guard makes
the queue unrunnable from there, so a `true` would hand back a verdict whose
`ProcessTasks` does nothing and spin the caller's loop hot for its whole ceiling.
* The ceiling is measured on `Stopwatch`, not the wall clock, so an NTP step cannot
cut it short or stretch it — and as elapsed-since-start rather than an absolute
deadline, so a `TimeSpan.MaxValue` ceiling cannot overflow the arithmetic.
`HasImmediatePumpWork` is extracted so that the wait and
`TimeUntilNextScheduledWork` answer "is there work right now" through one helper and
can never disagree.
Core engine surface: every target framework, no `#if`, no web-API flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd
…Work (#3217) * Engine: park a host thread on the pump with Advanced.WaitForScheduledWork A host driving one engine per thread had `TimeUntilNextScheduledWork` to tell it *when* to pump and `ProcessTasks()` to do the pumping, and nothing at all for the gap between them: a job enqueued from another thread — an interop `Task` settling, an asynchronous module load completing, a message posted in from a second engine — carries no due time, so the property cannot report it and the only way to find one was to poll. A loop sleeping on a 20 ms ceiling therefore paid up to 20 ms of latency on every such arrival, which for a worker-shaped host is the entire traffic. public bool WaitForScheduledWork(TimeSpan timeout, CancellationToken cancellationToken = default); public Task<bool> WaitForScheduledWorkAsync(TimeSpan timeout, CancellationToken cancellationToken = default); The internals were already complete and are only assembled here: `EventLoop`'s work-arrived event and its reset-then-check `WaitForWork`, `WaitForEventAsync` for the asynchronous form, and `TimeUntilNextPumpScheduledWork` for the clamp — so a `setTimeout(f, 1)` wakes the wait in about a millisecond rather than at the caller's ceiling. It deliberately **does not pump**: `ProcessTasks()` stays the pump and there is still no third method that drains for a budget. Four decisions worth stating: * **The synchronous form claims the engine for its whole duration.** That is the #3035 admission rule and nothing this method adds, and it is what makes one drainer per engine self-enforcing — a second thread asking for the wait, or for any other guarded entry, gets *"This Engine is already in use by another thread"*. `EventLoop.Enqueue` is deliberately unguarded, so waking a parked engine from another thread is unaffected. Documented, including the consequence: a host callback arriving from elsewhere is refused while the wait is parked, where a `Thread.Sleep` would have admitted it. * **The asynchronous form cannot hold thread-affine ownership across an await**, so it takes the reservation every other async host entry takes — synchronously, so an engine already in use is refused before a `Task` exists — and re-claims the resuming thread with `EnterTransferredHostCall` for each look at the schedule. It does not mirror `AwaitPromiseSettlementAsync`'s `_hostCallbackAdmission` bracket (that exists to keep a converted callback admissible while script is suspended, and this wait runs no script) nor touch `_pendingAsyncOperations`. * **Inside a running job nothing counts as available** — the rule `WaitForWork` and `DrainEventLoopUntil` already apply, for their reason: the re-entrancy guard makes the queue unrunnable from there, so a `true` would hand back a verdict whose `ProcessTasks` does nothing and spin the caller's loop hot for its whole ceiling. * The ceiling is measured on `Stopwatch`, not the wall clock, so an NTP step cannot cut it short or stretch it — and as elapsed-since-start rather than an absolute deadline, so a `TimeSpan.MaxValue` ceiling cannot overflow the arithmetic. `HasImmediatePumpWork` is extracted so that the wait and `TimeUntilNextScheduledWork` answer "is there work right now" through one helper and can never disagree. Core engine surface: every target framework, no `#if`, no web-API flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd * README: point the host-loop section at WaitForScheduledWork The "Driving the engine from your own loop" section ended by telling a host to keep a cadence of its own for work that arrives from a background thread, which was the honest advice while polling was the only option and is now the wrong one. Replace it with the wait, a pump-loop sample, and the four properties a host has to know before using it: it does not pump, it clamps to the engine's own next due time, a `true` is a hint, and it claims the engine for its whole duration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…lly has Main has moved 181 commits past v4.16.0 and nothing records what an embedder has to react to. Most of it is invisible to a compiler: every entry below still compiles exactly as it did in 4.16 and behaves differently at run time, and six of them flip a default. `docs/v5-migration.md` is the artefact those entries go in. It is deliberately not a second README: a table per change, before/after code where it helps, and the rationale left in the pull request it cites. Seeded from git history, every claim checked against the tree rather than against the pull request title: * sebastienros#3054 `Interop.AllowWrite` true -> false. A projected CLR write is now silently ignored in sloppy mode and a TypeError in strict mode. * sebastienros#3056 `Interop.ArrayConversion` LiveView -> Copy. `Array.isArray` flips to true, `push`/`length` stop throwing, and CLR-side mutations after the crossing stop being visible. * sebastienros#3057 `Constraints.StackOverflowGuard` false -> true. RangeError instead of a process that ends with no exception in the log. * sebastienros#3058 `AgentCanSuspend` true -> false. `Atomics.wait` is a TypeError before a waiter is registered; `waitAsync` is untouched. * sebastienros#3052 namespace type discovery loses its implicit fallback to `Assembly.GetCallingAssembly()` / `GetExecutingAssembly()` / `Type.GetType(name)` and becomes the `AllowedAssemblies` allow-list. * sebastienros#3051 host exception, module-load and CLR-resolution messages are redacted from script; `ExposeDetailedErrors()` restores all three. * sebastienros#3035 concurrent `Engine` use fails fast, and an engine stays reserved for the lifetime of a returned async Task. * sebastienros#3036 `LimitMemory` charges allocations across async continuations, so a budget can now trip where one synchronous segment never reached it. * sebastienros#3252 every `*Async` entry reports the operation's failures through the task and only a usage error out of the call. * sebastienros#3248 an array-like `length` above 2^32-1 stops answering differently per target framework. * sebastienros#3037, sebastienros#3045, sebastienros#3046 add parser, module-graph and result bounds that all default to unlimited, so they change nothing until configured; sebastienros#3059 and sebastienros#3060 add the diagnostics and the hardened profile on top. Two entries the prompt for this work had slightly differently, both checked and written as the tree has them: the stack-overflow guard raises a catchable `RangeError`, not a `RecursionDepthOverflowException`, and `ArrayOperations` is internal, so sebastienros#3248 removes no public member — its break is what a script sees. Sections 2, 3 and 6 (removed API, renamed API, AOT) are explicit empty placeholders. Nothing public has been removed or renamed since v4.16.0, and a parallel task is measuring the AOT state. The target-framework section is written and marked pending: `Jint.csproj` still lists net462, and the net472 change is not on main yet. `Jint/AGENTS.md` gains the rule that makes the guide stay current — a change to anything in its public-contract table is a row in the guide, in the same pull request, including a change that breaks nothing at compile time. The root `AGENTS.md` is untouched; it is at 23 KB against a 24 KiB budget. README's "Branches and releases" described `main` alone and did not mention the `3.x` branch at all. It now has a row per live branch, in the same wording sebastienros#3291 gives the 4.x branch's own copy, plus a pointer to the guide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
…lly has (#3294) Main has moved 181 commits past v4.16.0 and nothing records what an embedder has to react to. Most of it is invisible to a compiler: every entry below still compiles exactly as it did in 4.16 and behaves differently at run time, and six of them flip a default. `docs/v5-migration.md` is the artefact those entries go in. It is deliberately not a second README: a table per change, before/after code where it helps, and the rationale left in the pull request it cites. Seeded from git history, every claim checked against the tree rather than against the pull request title: * #3054 `Interop.AllowWrite` true -> false. A projected CLR write is now silently ignored in sloppy mode and a TypeError in strict mode. * #3056 `Interop.ArrayConversion` LiveView -> Copy. `Array.isArray` flips to true, `push`/`length` stop throwing, and CLR-side mutations after the crossing stop being visible. * #3057 `Constraints.StackOverflowGuard` false -> true. RangeError instead of a process that ends with no exception in the log. * #3058 `AgentCanSuspend` true -> false. `Atomics.wait` is a TypeError before a waiter is registered; `waitAsync` is untouched. * #3052 namespace type discovery loses its implicit fallback to `Assembly.GetCallingAssembly()` / `GetExecutingAssembly()` / `Type.GetType(name)` and becomes the `AllowedAssemblies` allow-list. * #3051 host exception, module-load and CLR-resolution messages are redacted from script; `ExposeDetailedErrors()` restores all three. * #3035 concurrent `Engine` use fails fast, and an engine stays reserved for the lifetime of a returned async Task. * #3036 `LimitMemory` charges allocations across async continuations, so a budget can now trip where one synchronous segment never reached it. * #3252 every `*Async` entry reports the operation's failures through the task and only a usage error out of the call. * #3248 an array-like `length` above 2^32-1 stops answering differently per target framework. * #3037, #3045, #3046 add parser, module-graph and result bounds that all default to unlimited, so they change nothing until configured; #3059 and #3060 add the diagnostics and the hardened profile on top. Two entries the prompt for this work had slightly differently, both checked and written as the tree has them: the stack-overflow guard raises a catchable `RangeError`, not a `RecursionDepthOverflowException`, and `ArrayOperations` is internal, so #3248 removes no public member — its break is what a script sees. Sections 2, 3 and 6 (removed API, renamed API, AOT) are explicit empty placeholders. Nothing public has been removed or renamed since v4.16.0, and a parallel task is measuring the AOT state. The target-framework section is written and marked pending: `Jint.csproj` still lists net462, and the net472 change is not on main yet. `Jint/AGENTS.md` gains the rule that makes the guide stay current — a change to anything in its public-contract table is a row in the guide, in the same pull request, including a change that breaks nothing at compile time. The root `AGENTS.md` is untouched; it is at 23 KB against a 24 KiB budget. README's "Branches and releases" described `main` alone and did not mention the `3.x` branch at all. It now has a row per live branch, in the same wording #3291 gives the 4.x branch's own copy, plus a pointer to the guide. Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Fail fast when one Engine is used by concurrent host operations.
Details
maindirectly.Linked issue
N/A
Test plan
dotnet build Jint/Jint.csproj -c Release: 0 warnings, 0 errorsJint.Tests.PublicInterfaceRelease: 1796 passed, 9 skippedJINT_HOST_CONTRACT_VERIFICATION=1 Jint.Tests.PublicInterfaceRelease: 1800 passed, 5 skippedJint.TestsRelease: 7794 passed, 4 skipped, excluding the timezone-sensitive year-10000 Date test previously confirmed failing on the basegit diff --check origin/main...HEADBreaking change?
No public API signatures change. Concurrent Engine use was already unsupported; it now throws
InvalidOperationExceptiondeterministically instead of racing or silently serializing. Authorized callbacks belonging to the active operation may transfer ownership one turn at a time, while unrelated callers still fail immediately. Hosts must await async Engine operations before reuse or disposal, and async Engine entry from inside an active callback is rejected.