镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

proposal for shared microtask queues #65555

Description

@koal44

#34023 added microtaskMode: 'afterEvaluate', which gives a context its own microtask queue and drains it after evaluation. There was discussion about giving authors broader control over the queue, but that was ultimately left out as beyond what users seemed to need.

However, the HTML Standard is riddled with requirements that need this kind of control. For example, HTML assigns a microtask queue to each event loop. Its clean up after running script algorithm then requires:

  • Finishing a script removes its realm execution context from the stack.
  • If the stack is then empty, the event loop performs a microtask checkpoint.

A Window and its same-agent iframe have separate realms but share an event loop. If they are represented by separate vm.Contexts, they therefore need one shared microtask queue.

Currently there's just not enough control over when queues are drained:

  • Default contexts share Node's queue, but there is no public API to synchronously drain it.
  • afterEvaluate drains synchronously, but gives every context a separate queue.

Let's try to explore this with code.

const vm = require('node:vm');

function run(options, drain = () => {}) {
  const trace = [];
  const record = (entry) => trace.push(entry);
  const window = vm.createContext({ record }, options);
  const iframe = vm.createContext({ record }, options);

  vm.runInContext(`
    const pending = new Promise((resolve) => {
      globalThis.resolve = resolve;
    });
    pending.then(() => {
      record('win-rxn');
      Promise.resolve().then(() => record('win-follow-up'));
    });
  `, window);

  vm.runInContext(`
    const pending = new Promise((resolve) => {
      globalThis.resolve = resolve;
    });
    pending.then(() => record('iframe-rxn'));
  `, iframe);

  window.resolveIframe = iframe.resolve;
  vm.runInContext('resolve(); resolveIframe();', window);
  drain(iframe);

  return trace;
}

console.log(run());
// []

console.log(run(
  { microtaskMode: 'afterEvaluate' },
  (iframe) => vm.runInContext('', iframe),
));
// ['win-rxn', 'win-follow-up', 'iframe-rxn']

The HTML model instead requires:

['win-rxn', 'iframe-rxn', 'win-follow-up']

With default contexts, both reactions are placed on Node's shared microtask queue in the required order, but there is no supported public API for performing the required checkpoint before run() returns. That is why the first call returns [].

With afterEvaluate, returning from the Window evaluation immediately drains the Window's private queue to exhaustion. The Window reaction therefore runs its follow-up before the iframe's private queue can be drained:

['win-rxn', 'win-follow-up', 'iframe-rxn']

Even if the iframe's private queue could be drained before the Window's, that would merely reverse the problem:

['iframe-rxn', 'win-rxn', 'win-follow-up']

Node's public vm therefore cannot perform this synchronous shared-queue checkpoint. Well, it isn't entirely impossible to make this example pass: process._tickCallback() does it. I initially thought that might be an acceptable hack for my own code, but it's deprecated and, after trying to build an actual event loop around it, looks DOA as a general solution because it’s a club when I needed a sewing needle (it drains Node’s shared nextTick and microtask queues, including work unrelated to the checkpoint).

My proposal is to expose a queue that multiple contexts can share and let authors be responsible for the draining. Something like:

const queue = vm.createMicrotaskQueue();

console.log(run(
  { microtaskQueue: queue },
  () => queue.runMicrotasks(),
));
// ['win-rxn', 'iframe-rxn', 'win-follow-up']

I do have a working prototype dreamed up with AI assist, but Node/V8 embedding internals are honestly outside my working knowledge.

Activity

  1. added
    vmIssues and PRs related to the vm subsystem.
    on Aug 27, 2026
  2. daeyeon commented on Aug 27, 2026

    @daeyeon
    Member

    Thanks for the proposal. Are there existing projects that would benefit from the behavior shown in the Window/iframe example? That context would help explain the value of a public API.

  3. koal44 commented on Aug 27, 2026

    @koal44
    Author

    TL;DR: Couldn't find an external issue confirming the exact problem. Narrowed the WPT search to microtask checkpoints and cross-realm ordering, but none combined the two. Contrived a cross-realm variation on an existing WPT: Chromium, Firefox, and Safari passed; Playwright WebKit failed; jsdom unexpectedly passed. Conclusion: problem is legit but niche.

    I searched WPT for microtask checkpoints and cross-realm ordering. The most relevant tests I found were:

    They test checkpoints, but none combines a checkpoint with cross-realm ordering. The closest is microtask_after_script.html, which already expects the equivalent of A1, B1, A2.

    So before everyone decides I'm wasting their time, here is a contrived variation on that WPT. It moves B1 into a same-agent iframe and should expose the problem. Green iframe good; red bad.

    <!doctype html>
    <iframe></iframe>
    
    <script>
    const iframe = document.querySelector("iframe");
    
    window.addEventListener("load", () => {
      const events = [];
    
      // Queue A1 in the Window realm.
      Promise.resolve().then(() => {
        events.push("A1");
    
        // A1 queues A2 in the Window realm.
        Promise.resolve().then(() => {
          events.push("A2");
    
          const passed =
            JSON.stringify(events) === JSON.stringify(["A1", "B1", "A2"]);
    
          iframe.contentDocument.body.style.background =
            passed ? "green" : "red";
        });
      });
    
      // Compile and queue B1 second in the iframe realm.
      iframe.contentWindow.Function("events", `
        Promise.resolve().then(() => {
          events.push("B1");
        });
      `)(events);
    });
    </script>
    Runtime Result
    Chromium Green
    Firefox Green
    WebKit 26.5 (via Playwright) Red
    Safari 26.5 Green
    jsdom 29.1.1 Green

    jsdom passing surprised me because it still has known parser and checkpoint problems, including #2413 and two WPT failures marked impossible. However, many of those issues look addressable with parse5's streaming parser and/or Node's afterEvaluate microtask mode, so I don't think they confirm the shared-queue problem I'm proposing here.

  4. added a commit that references this issue on Sep 10, 2026
  5. koal44 commented on Oct 8, 2026

    @koal44
    Author

    I've now built and tested both #66461 and #65582 with the V8 patches and compatibility addon I use for my browser project. Both ran the relevant existing tests without new failures.

    I don't have a preference between them. I like #66461's explicit manual mode contract, and #65582 makes a good case for keeping queue selection and draining policy independent. Both worked for my current requirements.

    Whichever is adopted, I see two natural follow-ups: directly enqueueing a microtask onto a selected queue, and a completion callback that runs after draining but before V8 clears kept objects.

    The usage I have in mind looks like this:

    import vm from 'node:vm';
    import assert from 'node:assert/strict';
    
    // Proposed additions: enqueueMicrotask() and a completion callback (i.e. runMicrotasks + cb)
    const trace = []; // Execution order across contexts and host callbacks.
    const record = value => trace.push(value); // Synchronous; does not enqueue work.
    
    const queue = vm.createMicrotaskQueue(); // Shared native queue.
    
    const createRealm = () => vm.createContext(
      { record }, // Each context supplies its own Promise.
      { microtaskMode: { type: 'manual', queue } }, // #66461
      // { microtaskQueue: queue }, // #65582
    );
    
    const window = createRealm(); // Models a Window realm.
    const iframe = createRealm(); // A same-agent iframe shares its queue.
    
    const otherQueue = vm.createMicrotaskQueue(); // Independently scheduled work.
    const otherTrace = [];
    otherQueue.enqueueMicrotask(() => otherTrace.push('other event loop'));
    
    vm.runInContext([
      'Promise.resolve().then(() => {', // Reaction callback belongs to this context.
      '  record("window");', // Synchronous host call.
      // Key case: this microtask schedules another microtask.
      '  Promise.resolve().then(() => record("follow-up"));',
      '});',
    ].join('\n'), window);
    
    vm.runInContext([
      'Promise.resolve().then(() => record("iframe"));', // Same FIFO, another context.
    ].join('\n'), iframe);
    
    // Explicitly select the queue for a callback created in Node's context.
    queue.enqueueMicrotask(() => record('host microtask'));
    
    assert.deepEqual(trace, []); // Both evaluations left their microtasks pending.
    
    // Proposed completion callback: after the drain, before ClearKeptObjects.
    queue.runMicrotasks(() => {
      record('host cleanup'); // Host-owned checkpoint bookkeeping.
    }); // V8 clears kept objects before returning.
    
    assert.deepEqual(trace, [
      'window',
      'iframe',
      'host microtask',
      'follow-up', // The newly scheduled microtask must precede completion.
      'host cleanup',
    ]);
    
    assert.deepEqual(otherTrace, []); // The checkpoint left the other queue untouched.

    The completion callback could alternatively be registered on the queue, following V8's AddMicrotasksCompletedCallback() model.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature requestIssues requesting new Node.js features.vmIssues and PRs related to the vm subsystem.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions