Try   HackMD

console.createTask API

Chrome supports automatic tracking of stack traces through asynchronous operation. For example, the following code:

async function f1() {
  setTimeout(function f2() {
    console.trace("hi!")
  });
}

function root() {
  f1();
}

root();

will show the following stack trace:

f2           @	VM115:5
setTimeout		
f1           @	VM115:4
root         @	VM115:8
(anonymous)  @	VM115:11

To allow custom schedulers to show up in these "stitched together stack traces of the async flow", Chrome exposes a console.createTask(name: string).run(fn: Function) API which captures the current stack trace when you call console.createTask, and appends it to stack traces generated by async flows that continue from the .run callback.

For example, this code:

function f1() {
  setTimeout(function f2() {
    console.trace("hi!")
  });
}

let task;
function setupTask() {
    task = console.createTask("MY TASK!");
}

function root() {
    task.run(f1);
}

setupTask();
root();

will log

f2           @	VM677:3
setTimeout		
f1           @	VM677:2
root         @	VM677:13
(anonymous)  @	VM677:17
MY TASK
setupTask    @	VM677:9
(anonymous)  @	VM677:16

Effectively, built-in schedulers like setTimeout are implemented as if they were doing:

function setTimeout(callback, time) {
  const task = console.createTask("setTimeout");
  __internalSetTimeout(() => {
    task.run(callback);
  }, time)
}

console.createTask and AsyncContext

Given that console.createTask "tracks async flow spawned by the .run" callback, it could be implemented on top of AsyncContext:

const __currentTaskTrace = new AsyncContext.Variable({ defaultValue: "" });
function __addTrace(prefix) {
  const stack = new Error().stack.split("\n").slice(3).join("\n")
  return `${prefix}\n${stack}${__currentTaskTrace.get()}`
}

const cool_console = {
  trace(message) {
    console.log(__addTrace(message));
  },

  createTask(name) {
    const taskTrace = __addTrace(`\n    at ${name}`);
    return {
      run: fn => __currentTaskTrace.run(taskTrace, fn)
    };
  }
};

console.createTask and events

One of the most difficult question to answer when it comes to AsyncContext is what to do when it comes to events: is the logical code flow continuing from where the event listener was registered, or from where some code caused the event to be dispatched?

console.createTask seems to have different answers depending on the event.

AbortSignal's abort (triggered synchronously by JS)

let ac = new AbortController();
function register() {
  ac.signal.addEventListener("abort", () => console.trace("hi"));
}
function dispatch() {
  ac.abort();
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains only dispatch.

HTMLElement's click (either triggered synchronously by JS, or by user interaction)

function register() {
  document.addEventListener("click", () => console.trace("hi"));
}

function dispatch() {
  document.body.click();
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

// now, also click manually and see the second trace

The first logged trace contains only dispatch. The logged trace when manually clicking contains neither.

Window's message (triggered asynchronously by JS)

function register() {
  window.addEventListener("message", () => console.trace("hi"));
}

function dispatch() {
  window.postMessage("hi");
  console.log("after dispatch")
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains dispatch, which has a browser-injected postMessage task as a child.

HTMLStyleElement's load (triggered asynchronously by JS)

const style = document.createElement("style");

function register() {
  style.addEventListener("load", () => console.trace("hi"));
}

function dispatch() {
  document.head.appendChild(style)
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains register, which has a browser-injected load task as a child.

HTMLStyleElement's load (triggered synchronously through dispatchEvent)

const style = document.createElement("style");

function register() {
  style.addEventListener("load", () => console.trace("hi"));
}

function dispatch() {
  style.dispatchEvent(new Event("load"))
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains register, which has a browser-injected load task as a child.

Custom foo on HTMLStyleElement (triggered synchronously through dispatchEvent)

const style = document.createElement("style");

function register() {
  style.addEventListener("foo", () => console.trace("hi"));
}

function dispatch() {
  style.dispatchEvent(new Event("foo"))
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains only dispatch.

load on a custom EventTarget instance (triggered synchronously through dispatchEvent)

const et = new EventTarget();

function register() {
  et.addEventListener("load", () => console.trace("hi"));
}

function dispatch() {
  style.dispatchEvent(new Event("load"))
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains register, which has a browser-injected load task as a child.

foo on a custom EventTarget instance (triggered synchronously through dispatchEvent)

const et = new EventTarget();

function register() {
  et.addEventListener("foo", () => console.trace("hi"));
}

function dispatch() {
  style.dispatchEvent(new Event("foo"))
}

console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);

The logged trace contains only dispatch.

Conclusion

By default, events propagate the current task from the dispatch context, falling back to the root context if the event has been dispatched by the browser.

However for some events, such as "load", they propagate the current task from the registration context. Whether this happens depends only on the event name, and not on the EventTarget instance or on how the event is dispatched.

That "load" and "error" are the only events for which the registration context is used, as defined at https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/events/event_target.cc;drc=7df47a37470130cdf03467448a1f9d0d228fc47a;l=114.

Implementing console.createTask on top of AsyncContext

To re-explain the console.createTask propagation on top of AsyncContext, we must use the dispatch target for event listeners. EventTarget must special-case "error" and "load" to either propagate the full registration context, or to only propagate the the registration-time value relative to console.createTask.

I would recommend the second, to avoid "error"/"load" behaving "weirdly" in the general case.

An .addEventListener would look as follows, based on the above cool_console example:

class EventTarget {
  #callbacks = new Map();
  
  addEventListener(name, callback) {
    // https://github.com/tc39/proposal-upsert
    const callbacks = this.#callbacks.emplace(name, []);
    
    if (name === "load" || name === "error") {
      callbacks.push({ callback, currentTask: __currentTaskTrace.get() });
    } else {
      callbacks.push({ callback, currentTask: undefined });
    }
  }
  
  dispatchEvent(e) {
    const callbacks = this.#callbacks.get(e.name) ?? [];
    const defaultTask = __currentTaskTrace.get();
    
    for (const { callback, currentTask = defaultTask } of callbacks) {
      __currentTaskTrace.run(currentTask, callback.bind(null, e));
    }
  }
}

Which is similar to how it's already implemented at https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/events/event_target.cc;drc=4b44bd63028a5aa82976dc4d7ed51cb78cbc6891;l=677.