Skip to content

Backport #4230 to 4.x: Let a host carry its own state across await through HostMakeJobCallback - #4231

Merged
lahma merged 1 commit into
sebastienros:4.xfrom
lahma:backport/4x-job-callback-hooks
Oct 6, 2026
Merged

lahma merged 1 commit into
sebastienros:4.xfrom
lahma:backport/4x-job-callback-hooks

Conversation

@lahma

@lahma lahma commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Backports #4230 to 4.x. Jint now runs ECMA-262's HostMakeJobCallback and HostCallJobCallback for every promise reaction, every thenable job and the FinalizationRegistry cleanup callback, and a host can join in through a new opt-in API: Options.Host.JobCallbacks takes a JobCallbackHooks with Capture, Enter and Exit. This covers the embedder scenario in #4200: each branch of a Promise.all fan-out keeps its own host flow across every await.

The change is purely additive. With no hooks installed, which is the default, each promise registration pays one null test, the reaction record is unchanged, and nothing script can observe changes. That is why it can go to 4.x despite the branch's no-new-API rule.

Commit Main PR What it adds
7aa307b #4230 (ffd2651, d2dee6f) JobCallbackHooks and Options.Host.JobCallbacks, wired into both PerformPromiseThen overloads, the primitive-await shortcut, NewPromiseResolveThenableJob and FinalizationRegistry cleanup.

Adapted for 4.x

The public API matches main exactly: the same namespace, names, signatures and protected internal members. Code written against 4.x will compile unchanged on v5. Only the internals and the tests were adapted.

  • 4.x Options are never frozen, so HostOptions.JobCallbacks is a plain { get; set; } with no ThrowIfReadOnly. The engine reads it once, at construction, as main does after Options.Apply. The XML doc says so.
  • Main's TheHooksAreFrozenWithTheRestOfTheOptions test does not apply to 4.x. It is replaced by TheHooksAreReadOnceWhenTheEngineIsConstructed: setting the property on a live engine's options installs nothing.
  • d2dee6f classifies the option in main's OptionsSecurityPostureTests list. 4.x has neither that list nor ForUntrustedCode, so there is nothing to carry over.
  • 4.x's PromiseReaction has no memory-limit state, so the primitive-await lane wraps its continuation through PromiseOperations.MakeJobCallback without main's CaptureMemoryLimitState() argument. Every other engine hunk applied cleanly. I compared the new PromiseReaction( sites on both branches: 4.x has no extra reaction lane that would need wrapping.
  • The tests are transcribed to xUnit ([Fact]) and Engine.Advanced.ProcessTasks / RegisterPromise. The harness resolves with a JsValue, because 4.x's ManualPromise.Resolve takes one.
  • 4.x has none of main's docs site, design doc, co-located AGENTS files or SpecAnchors.txt. Instead, the guide section goes into README.md under Asynchronous Execution, without main's AsyncContext design-doc link. The contract row and the "a lane that skips PerformPromiseThen still owes its HostMakeJobCallback" gotcha go into the root AGENTS.md. Main's SchedulerQueue remark is about Jint/WebApi, which 4.x does not have.

Public API additions

Each of the five Verify snapshots (net10.0, net8.0, net462, netstandard2.0, netstandard2.1) gains exactly these 8 lines, byte-identical to main's additions:

Options.HostOptions:  public Jint.Runtime.JobCallbackHooks? JobCallbacks { get; set; }
namespace Jint.Runtime:
    public abstract class JobCallbackHooks
    {
        protected JobCallbackHooks() { }
        protected abstract object? Capture(Jint.Engine engine);
        protected abstract object? Enter(Jint.Engine engine, object hostDefined);
        protected abstract void Exit(Jint.Engine engine, object? token);
    }

Evidence

The new tests cannot compile against 4.x without the public type. So, as on main, "unfixed" means the API is present but the engine never takes the hooks from Options. Under that, JobCallbackHooksTests plus the FinalizationRegistry test fail 10 of 14 on both net10.0 and net472, and HostJobCallbackTests fails 2 of 3 on both, including the fan-out scenario. The tests that still pass are the ones asserting nothing is carried or that Enter/Exit stay balanced.

I also removed each wiring site in turn and re-ran the new tests on net10.0. Every site has at least one test that fails, the same as on main:

  • Primitive-await lane: the await test and the PublicInterface fan-out fail.
  • Thenable job: the thenable test fails.
  • FinalizationRegistry CallJobCallback: the cleanup test fails.
  • then handlers: 4 Jint.Tests and 2 PublicInterface tests fail.
  • Engine continuations: 5 tests fail.
  • Exit outside a finally: the throwing-handler test fails.

With the change in place, all of them pass on both frameworks.

To check the code path that captures, I ran a local, uncommitted build that forces an always-capturing no-op hook onto every engine. Jint.Tests net10.0 passed 7,802 of 7,802 non-skipped. A broader test262 async subset (adding for-await-of, async arrows, async generators and AsyncFromSyncIteratorPrototype to the filter below) passed 6,280 of 6,280.

Totals

  • Jint.Tests net10.0: 7,806 total, 7,802 passed, 0 failed, 4 skipped.
  • Jint.Tests net472: 7,721 total, 7,717 passed, 0 failed, 4 skipped.
  • PublicInterface net10.0: 1,959 total, 1,950 passed, 0 failed, 9 skipped.
  • PublicInterface net472: 1,951 total, 1,942 passed, 0 failed, 9 skipped.
  • Host-contract leg (JINT_HOST_CONTRACT_VERIFICATION=1, net10.0): Jint.Tests 7,802 passed; PublicInterface 1,954 passed; 0 failed in both.
  • Test262 net10.0, filtered as on main (built-ins Promise, AsyncFunction, FinalizationRegistry and AsyncGeneratorPrototype; language/expressions/await and language/statements/async-function): 1,861 of 1,861 passed, the same count as main.

Performance

No benchmarks were run. The same rows that gate #4230 should gate this:

  • AsyncAwaitBenchmark, all six rows.
  • AsyncFunctionExitBenchmark, all three rows.
  • The standing SunSpider and Dromaeo tables. Allocated bytes should be identical, since an engine without hooks takes the same path as before plus one null test.

🤖 Generated with Claude Code

…oss await through HostMakeJobCallback

Adapted from ffd2651 (and d2dee6f, whose security-posture classification
has no 4.x counterpart).

ECMA-262's HostMakeJobCallback / HostCallJobCallback now run for every
promise reaction, every thenable job and FinalizationRegistry's
[[CleanupCallback]], and a host can take part through the new, purely
additive Options.Host.JobCallbacks (a Jint.Runtime.JobCallbackHooks with
Capture / Enter / Exit). With no hooks installed nothing changes: each
registration pays one null test and the reaction record is untouched.

Adapted for 4.x: HostOptions.JobCallbacks is a plain { get; set; } read once
at engine construction (4.x options are not frozen), the primitive-await
lane wraps its continuation without main's memory-limit state, the tests are
transcribed to xUnit against Engine.Advanced, main's frozen-options test is
replaced by one pinning the read-once behaviour, and the docs go to README.md
and AGENTS.md instead of the docs site and the co-located AGENTS files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the backport/4x-job-callback-hooks branch from 7aa307b to d162a2f Compare October 6, 2026 21:16
@lahma
lahma merged commit f0e4cbb into sebastienros:4.x Oct 6, 2026
5 checks passed
@lahma
lahma deleted the backport/4x-job-callback-hooks branch October 6, 2026 21:45
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.

1 participant