Skip to content

fix(core): perform microtask checkpoint after user callbacks when stack is empty - #36207

Open
jmao0001 wants to merge 5 commits into
denoland:mainfrom
jmao0001:branch_1
Open

fix(core): perform microtask checkpoint after user callbacks when stack is empty#36207
jmao0001 wants to merge 5 commits into
denoland:mainfrom
jmao0001:branch_1

Conversation

@jmao0001

@jmao0001 jmao0001 commented Jul 21, 2026

Copy link
Copy Markdown

Summary

This PR implements the user-code depth counter design proposed by @bartlomieju in this comment on #11731, closing the remaining scope of that issue (the event-listener / internal-JS-callback case).

Background. Web IDL's "clean up after running script" algorithm says browsers must perform a microtask checkpoint after a user callback returns if no other user script is on the stack. Deno, however, was running microtasks only when the entire JS stack emptied — with no distinction between Deno-internal JS and user JS. This produced spec-incompatible ordering for events dispatched from internal code:

const signal = AbortSignal.timeout(10);
signal.addEventListener("abort", () => {
  console.log("listener 1");
  queueMicrotask(() => console.log("microtask 1"));
});
signal.addEventListener("abort", () => {
  console.log("listener 2");
  queueMicrotask(() => console.log("microtask 2"));
});

Browsers print listener 1, microtask 1, listener 2, microtask 2; Deno printed listener 1, listener 2, microtask 1, microtask 2.

The 2021-vintage objection ("V8 auto-microtask policy can't be influenced") no longer applies: deno_core now runs under v8::MicrotasksPolicy::Explicit and already exposes op_run_microtasks. The remaining missing piece was knowing whether user script was on the stack — which is exactly what this PR adds.

What changed

  1. libs/core/runtime/jsrealm.rs — Added a user_code_depth: Box<[u32; 1]> field to ContextState (sized to match the existing tick_info / immediate_info / timer_info shared-buffer pattern). Bumped the counter around script.run(tc_scope) in execute_script and execute_script_with_cache.

  2. libs/core/runtime/jsruntime.rs — In store_js_callbacks, created a Uint32Array backed by user_code_depth and passed it to JS via a new Deno.core.__setUserCodeDepth init-only setter (mirroring the existing __setTickInfo / __setImmediateInfo / __setTimerInfo pattern). Also bumped the counter around the synchronous portion of JsRuntime::mod_evaluate.

  3. libs/core/01_core.js — Added the userCodeDepth shared-buffer reference, the __setUserCodeDepth(buf) setter, and the invokeUserCallback(cb, thisArg, args) helper. The helper increments the counter, calls the user callback via ReflectApply, decrements in finally, and calls op_run_microtasks() when the counter returns to zero. V8's PerformMicrotaskCheckpoint no-ops when already running microtasks, so nested invocations from inside a promise reaction are safe and match the HTML spec's "if performing a microtask checkpoint is true, return" guard.

  4. ext/web/02_event.js — Routed innerInvokeEventListeners's listener callback invocation (both the function and handleEvent paths) through core.invokeUserCallback. This is the single chokepoint that fixes the AbortSignal / MessagePort / unhandledrejection cases for free.

  5. ext/web/02_timers.js — Routed setTimeout and setInterval callbacks through core.invokeUserCallback.

  6. ext/webidl/00_webidl.js — Routed invokeCallbackFunction through core.invokeUserCallback. This covers all stream underlying-source/sink/size algorithms, queueing-strategy callbacks, and any other Web IDL callback entry point without requiring each call site to be updated individually.

Why this is correct

  • Internal-dispatch case (AbortSignal, MessagePort, timer-driven events): depth is 0 when dispatch runs. Each listener invocation bumps to 1, calls the listener, decrements to 0, runs microtasks → browser-canonical ordering.
  • User-dispatch case (target.dispatchEvent(...) from top-level user code): the outer top-level scope holds depth = 1, so inner listener invocations see depth ≥ 2 and skip the checkpoint. Microtasks defer until the user script unwinds → browser-canonical ordering.
  • Promise reactions (async/await continuations): they run inside op_run_microtasks, so any invokeUserCallback calls inside them see the outer checkpoint in progress and the nested op_run_microtasks() becomes a V8-level no-op. Newly queued microtasks are picked up by the outer checkpoint — matching the HTML spec's recursive-checkpoint guard.
  • No new op calls on the hot path: the counter lives in a shared Uint32Array, so both Rust and internal JS touch it with a plain indexed read/write — same pattern already used for tick_info and immediate_info.

Tests

  • tests/unit/event_target_test.ts: added two regression tests — one async test asserting browser-canonical ordering when an event is dispatched from internal code (via setTimeout(0)), and one sync test asserting microtasks do not run between listeners when the event is dispatched from user code.
  • libs/core_testing/unit/microtask_test.ts: added structural tests for Deno.core.invokeUserCallback — return-value pass-through, this binding, and exception propagation with the microtask checkpoint still firing in finally.

Checklist

  • Descriptive title (matches fix(module): brief description convention)
  • Related issue (Microtask queue is not handled correctly in Deno #11731) referenced above
  • Tests added covering both the internal-dispatch and user-dispatch cases
  • ./x fmt — to be run locally before merging
  • ./x lint — to be run locally before merging
  • Opened as draft pending CI run

AI disclosure: An AI assistant was used to help draft this PR description and to translate the design sketch from the issue thread into diffs. The design itself (user-code depth counter, invokeUserCallback helper, bumping at top-level entry points) is the one proposed by @bartlomieju in the issue thread. All code was reviewed and adjusted to match the existing tick_info / immediate_info / timer_info shared-buffer pattern already present in deno_core.

Closes #11731

@deno-cla-assistant

deno-cla-assistant Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deno Individual Contributor License Agreement

All contributors have signed the CLA. Thank you!

Re-run CLA check


This is an automated message from CLA Assistant

@bartlomieju

Copy link
Copy Markdown
Member

No AI tools were used to write this PR beyond the assistant helping organize the implementation plan

AI disclosure: An AI assistant was used to help draft this PR description and to translate the design sketch from the issue thread into the concrete SEARCH/REPLACE diffs above.

So which one is it? The implementation looks totally AI written.

@jmao0001

Copy link
Copy Markdown
Author

Hello @bartlomieju, sorry for the confusion about the PR description. To clarify: I utilized an AI to write the actual implementation and diffs based on your architectural design. I spent my time carefully reviewing the output, orchestrating the fix, and testing it to make sure it solves the issue, but the code generation itself was assisted by AI. The contradictory checklist was a mistake in my PR description that I missed before hitting submit. I've updated the description to be fully accurate. I would appreciate it if you could do one more review of this PR.

@jmao0001

jmao0001 commented Jul 24, 2026

Copy link
Copy Markdown
Author

Hello @bartlomieju, just a quick heads up: I have been doing some extra local verification in a Codespace while waiting for the CI approval. I caught a minor compilation issue (an invalid ASCII character in a JS comment and an immutable Rc reference) during this pass and just pushed a fix for it. Now the code is better, and should not have any error. Looking forward to the CI results whenever you have a moment to trigger them.

@jmao0001

Copy link
Copy Markdown
Author

Hello @bartlomieju and maintainers, just a quick ping on this. Let me know if you need any changes from me before triggering the CI.

@jmao0001

jmao0001 commented Aug 1, 2026

Copy link
Copy Markdown
Author

Follow-up to #36207 to fix the failing unit and spec tests. WPT was already passing, so the core spec logic was fine, but we hit a few edge cases in the Deno tests.

Here is what this fixes:

  • The nextTick invariant (main fix): Wrapping setTimeout in invokeUserCallback was draining microtasks before nextTick, which broke the Node invariant. I added withUserCodeDepth for timers, so it bumps the depth but leaves the microtask drain to the existing event loop.
  • Missing Rust depth bumps: scoped_call_with_args and op_eval_context weren't tracking depth. This caused depth to incorrectly hit 0 and fire microtasks early during test runner callbacks. Added the missing bumps on the Rust side.
  • Test fixes: The async regression test was using setTimeout(0) (which acts as user code, not internal dispatch). Swapped it to AbortSignal.timeout() to properly test system-level behavior. Also added a guard in microtask_test.ts so it skips gracefully if invokeUserCallback is not exposed.
  • Types: Added the missing TS declarations to core.d.ts.

Just in case, Web API event listeners still use invokeUserCallback to drain microtasks between listeners. That is intentional and required to match browser behavior and pass WPT, so the Node tick invariant does not apply there.

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.

Microtask queue is not handled correctly in Deno

2 participants