├── src ├── types.ts ├── index.ts ├── promise-polyfill.ts ├── snapshot.ts ├── variable.ts ├── mapping.ts ├── fork.ts └── storage.ts ├── .gitignore ├── .prettierrc.json ├── tsconfig.json ├── .mocharc.json ├── .github └── workflows │ ├── github-pages.yml │ └── test.yml ├── shadowrealm-biblio.json ├── package.json ├── SNAPSHOT.md ├── SCOPING.md ├── COPYING.txt ├── MEMORY-MANAGEMENT.md ├── USE-CASES.md ├── MUTATION-SCOPE.md ├── CONTINUATION.md ├── FRAMEWORKS.md ├── tests └── async-context.test.ts ├── PRIOR-ARTS.md ├── README.md └── WEB-INTEGRATION.md /src/types.ts: -------------------------------------------------------------------------------- 1 | export type AnyFunc = (this: T, ...args: any) => any; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | 4 | npm-debug.log 5 | deploy_key 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": "*.md", 5 | "options": { 6 | "proseWrap": "always" 7 | } 8 | } 9 | ] 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Snapshot } from "./snapshot"; 2 | import { Variable } from "./variable"; 3 | 4 | export const AsyncContext = { 5 | Snapshot, 6 | Variable, 7 | }; 8 | export { Snapshot, Variable }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "esnext", 5 | "allowSyntheticDefaultImports": true, 6 | "moduleResolution": "nodenext", 7 | "noEmit": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "@esbuild-kit/cjs-loader", 3 | "loader": "@esbuild-kit/esm-loader", 4 | "spec": ["tests/**"], 5 | "extensions": ["ts"], 6 | "watch-files": ["src"], 7 | "node-option": ["no-warnings"] 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ljharb/actions/node/install@main 15 | name: 'nvm install lts/* && npm install' 16 | with: 17 | node-version: lts/* 18 | - run: npm run build 19 | - uses: JamesIves/github-pages-deploy-action@v4 20 | with: 21 | branch: gh-pages 22 | folder: build 23 | clean: true 24 | -------------------------------------------------------------------------------- /shadowrealm-biblio.json: -------------------------------------------------------------------------------- 1 | {"location":"https://tc39.es/proposal-shadowrealm/","entries":[{"type":"op","aoid":"CopyNameAndLength","refId":"sec-copynameandlength","kind":"abstract operation","signature":{"parameters":[{"name":"_F_","type":{"kind":"opaque","type":"a function object"}},{"name":"_Target_","type":{"kind":"opaque","type":"a function object"}}],"optionalParameters":[{"name":"_prefix_","type":{"kind":"opaque","type":"a String"}},{"name":"_argCount_","type":{"kind":"opaque","type":"a Number"}}],"return":{"kind":"completion","completionType":"mixed","typeOfValueIfNormal":{"kind":"unused"}}},"effects":[]},{"type":"clause","id":"sec-copynameandlength","aoid":"CopyNameAndLength","title":"CopyNameAndLength ( F, Target [ , prefix [ , argCount ] ] )","titleHTML":"CopyNameAndLength ( F, Target [ , prefix [ , argCount ] ] )","number":"3.1.2"}]} -------------------------------------------------------------------------------- /src/promise-polyfill.ts: -------------------------------------------------------------------------------- 1 | import { AsyncContext } from "./index"; 2 | 3 | import type { AnyFunc } from "./types"; 4 | 5 | export const nativeThen = Promise.prototype.then; 6 | const { wrap } = AsyncContext.Snapshot; 7 | 8 | function wrapFn>(fn: F | null | undefined) { 9 | if (typeof fn !== "function") return undefined; 10 | return wrap(fn); 11 | } 12 | 13 | export function then( 14 | this: Promise, 15 | onFul?: Parameters[0], 16 | onRej?: Parameters[1] 17 | ): Promise { 18 | // The onFul and onRej are always called _after at least 1_ tick. So it's 19 | // possible that a new Request has been handled (and a new async context 20 | // created). We must wrap the callbacks to restore our creation context 21 | // when they are invoked. 22 | const ful = wrapFn(onFul); 23 | const rej = wrapFn(onRej); 24 | 25 | return nativeThen.call(this, ful, rej) as Promise; 26 | } 27 | -------------------------------------------------------------------------------- /src/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "./storage"; 2 | 3 | import type { FrozenRevert } from "./fork"; 4 | import type { AnyFunc } from "./types"; 5 | 6 | export class Snapshot { 7 | #snapshot = Storage.snapshot(); 8 | 9 | static wrap>(fn: F): F { 10 | const snapshot = Storage.snapshot(); 11 | 12 | function wrap(this: ThisType, ...args: Parameters): ReturnType { 13 | return run(fn, this, args, snapshot); 14 | } 15 | 16 | return wrap as unknown as F; 17 | } 18 | 19 | run>(fn: F, ...args: Parameters) { 20 | return run(fn, null as any, args, this.#snapshot); 21 | } 22 | } 23 | 24 | function run>( 25 | fn: F, 26 | context: ThisType, 27 | args: any[], 28 | snapshot: FrozenRevert 29 | ): ReturnType { 30 | const revert = Storage.switch(snapshot); 31 | try { 32 | return fn.apply(context, args); 33 | } finally { 34 | Storage.restore(revert); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/variable.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "./storage"; 2 | 3 | import type { AnyFunc } from "./types"; 4 | 5 | export interface VariableOptions { 6 | name?: string; 7 | defaultValue?: T; 8 | } 9 | 10 | export class Variable { 11 | #name = ""; 12 | #defaultValue: T | undefined; 13 | 14 | constructor(options?: VariableOptions) { 15 | if (options) { 16 | if ("name" in options) { 17 | this.#name = String(options.name); 18 | } 19 | this.#defaultValue = options.defaultValue; 20 | } 21 | } 22 | 23 | get name() { 24 | return this.#name; 25 | } 26 | 27 | run>( 28 | value: T, 29 | fn: F, 30 | ...args: Parameters 31 | ): ReturnType { 32 | const revert = Storage.set(this, value); 33 | try { 34 | return fn.apply(null, args); 35 | } finally { 36 | Storage.restore(revert); 37 | } 38 | } 39 | 40 | get(): T | undefined { 41 | return Storage.has(this) ? Storage.get(this) : this.#defaultValue; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proposal-async-context", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Async Context proposal for JavaScript", 6 | "scripts": { 7 | "build": "npm run build-loose -- --strict", 8 | "build-loose": "mkdir -p build && ecmarkup --load-biblio @tc39/ecma262-biblio --verbose --lint-spec spec.html build/index.html", 9 | "watch": "npm run build-loose -- --watch", 10 | "lint": "tsc -p tsconfig.json", 11 | "test": "mocha" 12 | }, 13 | "repository": "legendecas/proposal-async-context", 14 | "keywords": [ 15 | "async", 16 | "context", 17 | "async-context", 18 | "zone", 19 | "tc39", 20 | "javascript", 21 | "ecmascript", 22 | "spec" 23 | ], 24 | "author": "Chengzhong Wu ", 25 | "license": "CC0-1.0", 26 | "devDependencies": { 27 | "@esbuild-kit/cjs-loader": "2.4.1", 28 | "@esbuild-kit/esm-loader": "2.5.4", 29 | "@tc39/ecma262-biblio": "^2.1.2925", 30 | "@types/mocha": "10.0.1", 31 | "@types/node": "18.11.18", 32 | "ecmarkup": "^21.3.1", 33 | "mocha": "10.2.0", 34 | "prettier": "2.8.7", 35 | "typescript": "4.9.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | name: "Test" 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.x, 16.x, 18.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - run: npm test 27 | 28 | lint: 29 | name: "Lint" 30 | runs-on: ubuntu-latest 31 | 32 | strategy: 33 | matrix: 34 | node-version: [18.x] 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Use Node.js ${{ matrix.node-version }} 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | cache: 'npm' 43 | - run: npm ci 44 | - run: npm run lint 45 | 46 | build: 47 | name: "Build Spec" 48 | runs-on: ubuntu-latest 49 | 50 | strategy: 51 | matrix: 52 | node-version: [18.x] 53 | 54 | steps: 55 | - uses: actions/checkout@v3 56 | - name: Use Node.js ${{ matrix.node-version }} 57 | uses: actions/setup-node@v3 58 | with: 59 | node-version: ${{ matrix.node-version }} 60 | cache: 'npm' 61 | - run: npm ci 62 | - run: npm run build 63 | -------------------------------------------------------------------------------- /src/mapping.ts: -------------------------------------------------------------------------------- 1 | import type { Variable } from "./variable"; 2 | 3 | /** 4 | * Stores all Variable data, and tracks whether any snapshots have been 5 | * taken of the current data. 6 | */ 7 | export class Mapping { 8 | #data: Map, unknown>; 9 | 10 | /** 11 | * If a snapshot of this data is taken, then further modifications cannot be 12 | * made directly. Instead, set/delete will clone this Mapping and modify 13 | * _that_ instance. 14 | */ 15 | #frozen = false; 16 | 17 | constructor(data: Map, unknown>) { 18 | this.#data = data; 19 | } 20 | 21 | has(key: Variable): boolean { 22 | return this.#data.has(key) || false; 23 | } 24 | 25 | get(key: Variable): T | undefined { 26 | return this.#data.get(key) as T | undefined; 27 | } 28 | 29 | /** 30 | * Like the standard Map.p.set, except that we will allocate a new Mapping 31 | * instance if this instance is frozen. 32 | */ 33 | set(key: Variable, value: T): Mapping { 34 | const mapping = this.#fork(); 35 | mapping.#data.set(key, value); 36 | return mapping; 37 | } 38 | 39 | /** 40 | * Like the standard Map.p.delete, except that we will allocate a new Mapping 41 | * instance if this instance is frozen. 42 | */ 43 | delete(key: Variable): Mapping { 44 | const mapping = this.#fork(); 45 | mapping.#data.delete(key); 46 | return mapping; 47 | } 48 | 49 | /** 50 | * Prevents further modifications to this Mapping. 51 | */ 52 | freeze(): void { 53 | this.#frozen = true; 54 | } 55 | 56 | isFrozen(): boolean { 57 | return this.#frozen; 58 | } 59 | 60 | /** 61 | * We only need to fork if the Mapping is frozen (someone has a snapshot of 62 | * the current data), else we can just modify our data directly. 63 | */ 64 | #fork(): Mapping { 65 | if (this.#frozen) { 66 | return new Mapping(new Map(this.#data)); 67 | } 68 | return this; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/fork.ts: -------------------------------------------------------------------------------- 1 | import type { Mapping } from "./mapping"; 2 | import type { Variable } from "./variable"; 3 | 4 | /** 5 | * FrozenRevert holds a frozen Mapping that will be simply restored when the 6 | * revert is run. 7 | * 8 | * This is used when we already know that the mapping is frozen, so that 9 | * reverting will not attempt to mutate the Mapping (and allocate a new 10 | * mapping) as a Revert would. 11 | */ 12 | export class FrozenRevert { 13 | #mapping: Mapping; 14 | 15 | constructor(mapping: Mapping) { 16 | this.#mapping = mapping; 17 | } 18 | 19 | /** 20 | * The Storage container will call restore when it wants to revert its 21 | * current Mapping to the state at the start of the fork. 22 | * 23 | * For FrozenRevert, that's as simple as returning the known-frozen Mapping, 24 | * because we know it can't have been modified. 25 | */ 26 | restore(_current: Mapping): Mapping { 27 | return this.#mapping; 28 | } 29 | } 30 | 31 | /** 32 | * Revert holds the information on how to undo a modification to our Mappings, 33 | * and will attempt to modify the current state when we attempt to restore it 34 | * to its prior state. 35 | * 36 | * This is used when we know that the Mapping is unfrozen at start, because 37 | * it's possible that no one will snapshot this Mapping before we restore. In 38 | * that case, we can simply modify the Mapping without cloning. If someone did 39 | * snapshot it, then modifying will clone the current state and we restore the 40 | * clone to the prior state. 41 | */ 42 | export class Revert { 43 | #key: Variable; 44 | #has: boolean; 45 | #prev: T | undefined; 46 | 47 | constructor(mapping: Mapping, key: Variable) { 48 | this.#key = key; 49 | this.#has = mapping.has(key); 50 | this.#prev = mapping.get(key); 51 | } 52 | 53 | /** 54 | * The Storage container will call restore when it wants to revert its 55 | * current Mapping to the state at the start of the fork. 56 | * 57 | * For Revert, we mutate the known-unfrozen-at-start mapping (which may 58 | * reallocate if anyone has since taken a snapshot) in the hopes that we 59 | * won't need to reallocate. 60 | */ 61 | restore(current: Mapping): Mapping { 62 | if (this.#has) { 63 | return current.set(this.#key, this.#prev); 64 | } else { 65 | return current.delete(this.#key); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { Mapping } from "./mapping"; 2 | import { FrozenRevert, Revert } from "./fork"; 3 | 4 | import type { Variable } from "./variable"; 5 | 6 | /** 7 | * Storage is the (internal to the language) storage container of all 8 | * Variable data. 9 | * 10 | * None of the methods here are exposed to users, they're only exposed internally. 11 | */ 12 | export class Storage { 13 | static #current: Mapping = new Mapping(new Map()); 14 | 15 | /** 16 | * Has checks if the Variable has a value. 17 | */ 18 | static has(key: Variable): boolean { 19 | return this.#current.has(key); 20 | } 21 | 22 | /** 23 | * Get retrieves the current value assigned to the Variable. 24 | */ 25 | static get(key: Variable): T | undefined { 26 | return this.#current.get(key); 27 | } 28 | 29 | /** 30 | * Set assigns a new value to the Variable, returning a revert that can 31 | * undo the modification at a later time. 32 | */ 33 | static set(key: Variable, value: T): FrozenRevert | Revert { 34 | // If the Mappings are frozen (someone has snapshot it), then modifying the 35 | // mappings will return a clone containing the modification. 36 | const current = this.#current; 37 | const revert = current.isFrozen() 38 | ? new FrozenRevert(current) 39 | : new Revert(current, key); 40 | this.#current = this.#current.set(key, value); 41 | return revert; 42 | } 43 | 44 | /** 45 | * Restore will, well, restore the global storage state to state at the time 46 | * the revert was created. 47 | */ 48 | static restore(revert: FrozenRevert | Revert): void { 49 | this.#current = revert.restore(this.#current); 50 | } 51 | 52 | /** 53 | * Snapshot freezes the current storage state, and returns a new revert which 54 | * can restore the global storage state to the state at the time of the 55 | * snapshot. 56 | */ 57 | static snapshot(): FrozenRevert { 58 | this.#current.freeze(); 59 | return new FrozenRevert(this.#current); 60 | } 61 | 62 | /** 63 | * Switch swaps the global storage state to the state at the time of a 64 | * snapshot, completely replacing the current state (and making it impossible 65 | * for the current state to be modified until the snapshot is reverted). 66 | */ 67 | static switch(snapshot: FrozenRevert): FrozenRevert { 68 | const previous = this.#current; 69 | this.#current = snapshot.restore(previous); 70 | 71 | // Technically, previous may not be frozen. But we know its state cannot 72 | // change, because the only way to modify it is to restore it to the 73 | // Storage container, and the only way to do that is to have snapshot it. 74 | // So it's either snapshot (and frozen), or it's not and thus cannot be 75 | // modified. 76 | return new FrozenRevert(previous); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /SNAPSHOT.md: -------------------------------------------------------------------------------- 1 | # Requirements of `AsyncContext.Snapshot` 2 | 3 | `AsyncContext.Snapshot` presents two unique requirements: 4 | 5 | - It does not expose the value associated with any `Variable` instances. 6 | - It captures _all_ `Variable`s' current value and restores those values 7 | at a later time. 8 | 9 | The above requirements are essential to decouple a queueing implementation 10 | from the consumers of `Variable` instances. For example, a scheduler can queue 11 | an async task and take a snapshot of the current context: 12 | 13 | ```typescript 14 | // The scheduler doesn't access any AsyncContext.Variable. 15 | const scheduler = { 16 | queue: [], 17 | postTask(task) { 18 | // Each callback is stored with the context at which it was enqueued. 19 | const snapshot = new AsyncContext.Snapshot(); 20 | queue.push({ snapshot, task }); 21 | }, 22 | runWhenIdle() { 23 | const queue = this.queue; 24 | this.queue = []; 25 | for (const { snapshot, task } of queue) { 26 | // All tasks in the queue would be run with the current context if they 27 | // hadn't been wrapped with the snapshot. 28 | snapshot.run(task); 29 | } 30 | } 31 | }; 32 | ``` 33 | 34 | In this example, the scheduler can propagate values of `Variable`s but doesn't 35 | have access to any `Variable` instance. They are not coupled with a specific 36 | consumer of `Variable`. A consumer of `Variable` will not be coupled with a 37 | specific scheduler as well. 38 | 39 | A consumer like a tracer can use `Variable` without knowing how the scheduler 40 | is implemented: 41 | 42 | ```typescript 43 | // tracer.js 44 | const asyncVar = new AsyncContext.Variable(); 45 | export function run(cb) { 46 | // Create a new span and run the callback with it. 47 | const span = { 48 | startTime: Date.now(), 49 | traceId: randomUUID(), 50 | spanId: randomUUID(), 51 | }; 52 | asyncVar.run(span, cb); 53 | } 54 | 55 | export function end() { 56 | // Get the current span from the AsyncContext.Variable and end it. 57 | const span = asyncVar.get(); 58 | span?.endTime = Date.now(); 59 | } 60 | ``` 61 | 62 | The `Snapshot` API enables user-land queueing implementations to be cooperate 63 | with any consumers of `Variable`. For instances, a queueing implementation can 64 | be: 65 | 66 | - A user-land Promise-like implementation, 67 | - A user multiplexer that multiplexes an IO operation with a batch of async 68 | tasks. 69 | 70 | Without an API like `Snapshot`, a queueing implementation would have to be built 71 | on top of the built-in `Promise`, as it is the only way to capture the current 72 | `Variable` values and restore them later. This would limit the implementation 73 | of a user-land queueing. 74 | 75 | ```typescript 76 | const scheduler = { 77 | queue: [], 78 | postTask(task) { 79 | const { promise, resolve } = Promise.withResolvers(); 80 | // Captures the current context by `Promise.prototype.then`. 81 | promise.then(() => { 82 | task(); 83 | }); 84 | // Defers the task execution by resolving the promise. 85 | queue.push(resolve); 86 | }, 87 | runWhenIdle() { 88 | // LIMITATION: the tasks are not run synchronously. 89 | for (const cb of this.queue) { 90 | cb(); 91 | } 92 | this.queue = []; 93 | } 94 | }; 95 | ``` 96 | -------------------------------------------------------------------------------- /SCOPING.md: -------------------------------------------------------------------------------- 1 | # Scoping of AsyncContext.Variable 2 | 3 | The major concerns of `AsyncContext.Variable` advancing to Stage 1 of TC39 proposal 4 | process is that there are potential dynamic scoping of the semantics of 5 | `AsyncContext.Variable`. This document is about defining the scoping of 6 | `AsyncContext.Variable`. 7 | 8 | ### Dynamic Scoping 9 | 10 | A classic dynamic scoping issue is: the variable `x` inside a function `g` will 11 | be determined by the caller of `g`. If `g` is called at root scope, the name `x` 12 | refers to the one defined in the root scope. If `g` is called inside a function 13 | `f`, the name `x` could refer to the one defined in the scope of `f`. 14 | 15 | ```bash 16 | $ # bash language 17 | $ x=1 18 | $ function g () { echo $x ; x=2 ; } 19 | $ function f () { local x=3 ; g ; } 20 | $ f # does this print 1, or 3? 21 | 3 22 | $ echo $x # does this print 1, or 2? 23 | 1 24 | ``` 25 | 26 | However, the naming scope of an `AsyncContext.Variable` is identical to a regular variable 27 | in JavaScript. Since JavaScript variables are lexically scoped, the naming of 28 | `AsyncContext.Variable` instances are lexically scoped too. It is not possible to access a 29 | value inside an `AsyncContext.Variable` without explicit access to the `AsyncContext.Variable` instance 30 | itself. 31 | 32 | ```typescript 33 | const asyncVar = new AsyncContext.Variable(); 34 | 35 | asyncVar.run(1, f); 36 | console.log(asyncVar.get()); // => undefined 37 | 38 | function g() { 39 | console.log(asyncVar.get()); // => 1 40 | } 41 | 42 | function f() { 43 | // Intentionally named the same "asyncVar" 44 | const asyncVar = new AsyncContext.Variable(); 45 | asyncVar.run(2, g); 46 | } 47 | ``` 48 | 49 | Hence, knowing the name of an `AsyncContext.Variable` variable does not give you the 50 | ability to change the value of that variable. You must have direct access to it 51 | in order to affect it. 52 | 53 | ```typescript 54 | const asyncVar = new AsyncContext.Variable(); 55 | 56 | asyncVar.run(1, f); 57 | 58 | console.log(asyncVar.get()); // => undefined; 59 | 60 | function f() { 61 | const asyncVar = new AsyncContext.Variable(); 62 | asyncVar.run(2, g); 63 | 64 | function g() { 65 | console.log(asyncVar.get()); // => 2; 66 | } 67 | } 68 | ``` 69 | 70 | ### Dynamic Scoping: dependency on caller 71 | 72 | One argument on the dynamic scoping is that the values in `AsyncContext.Variable` can be 73 | changed depending on which the caller is. 74 | 75 | However, the definition of whether the value of an `AsyncContext.Variable` can be changed 76 | has the same meaning with a regular JavaScript variable: anyone with direct 77 | access to a variable has the ability to change the variable. 78 | 79 | ```typescript 80 | class SyncVariable { 81 | #current; 82 | 83 | get() { 84 | return this.#current; 85 | } 86 | 87 | run(value, cb) { 88 | const prev = this.#current; 89 | try { 90 | this.#current = value; 91 | return cb(); 92 | } finally { 93 | this.#current = prev; 94 | } 95 | } 96 | } 97 | 98 | const syncVar = new SyncVariable(); 99 | 100 | syncVar.run(1, f); 101 | 102 | console.log(syncVar.get()); // => undefined; 103 | 104 | function g() { 105 | console.log(syncVar.get()); // => 1 106 | } 107 | 108 | function f() { 109 | // Intentionally named the same "syncVar" 110 | const syncVar = new AsyncContext.Variable(); 111 | syncVar.run(2, g); 112 | } 113 | ``` 114 | 115 | If this userland `SyncVariable` is acceptable, than adding an `AsyncContext.Variable` 116 | that can operate across sync/async execution should be no different. 117 | 118 | ### Summary 119 | 120 | There are no differences regarding naming scope of `AsyncContext.Variable` compared to 121 | regular JavaScript variables. Only code with direct access to `AsyncContext.Variable` 122 | instances can modify the value, and only for code execution nested inside a new 123 | `asyncVar.run()`. Further, the capability to modify an AsyncVariable which you 124 | have direct access to is already possible in sync code execution. 125 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /MEMORY-MANAGEMENT.md: -------------------------------------------------------------------------------- 1 | # Memory management in AsyncContext 2 | 3 | A context (sometimes called a snapshot; or in the spec, "a List of Async Context 4 | Mapping Records") is an immutable map from `AsyncContext.Variable` instances to 5 | arbitrary JS values (or possibly also spec-internal values; see "Using 6 | AsyncContext from web specs" in the main web integration document). Each agent 7 | will have a `[[AsyncContextMapping]]` field which is a context, which we will 8 | sometimes refer to as "the current context". 9 | 10 | Given a variable `asyncVar`, which is an instance of `AsyncContext.Variable`, 11 | running `asyncVar.run(value, callback)` will: 12 | 1. Create a new context which is a copy of the current context, except that 13 | `asyncVar` maps to `value`. The reference to `value` in the context is 14 | strongly held (not a weak reference), but see the 15 | [weak map section](#the-context-as-a-weak-map) below. 16 | 2. Set that new context as the current context. 17 | 3. Run the callback. 18 | 4. Restore the current context to the value it had before step 2. 19 | 20 | By itself, this would only allow keeping memory alive implicitly within a call 21 | stack, which would be no different from local variables from a stack frame being 22 | kept alive while a function is running. 23 | 24 | However, the thing that makes AsyncContext AsyncContext is that the context can 25 | be propagated across asynchronous operations, which eventually cause tasks or 26 | microtasks to be enqueued. Some of these operations are defined in ECMA-262, 27 | such as `Promise.prototype.then` and `await`, but most of them are defined in 28 | web specs, such as `setTimeout` and the many other APIs listed above. 29 | 30 | For many of these async operations (such as `setTimeout` and `.then`), a 31 | callback is run once or multiple times in a task or microtask. In those cases, 32 | the operation can be seen as keeping a strong reference to the callback, and it 33 | will also keep a strong reference to the context that was current at the time 34 | that the API was called to start that operation. When the operation is finished, 35 | that reference will be removed. 36 | 37 | For events, we do not store the context in which `addEventListener` is 38 | called (though see the next paragraph on the fallback context). Instead, the 39 | context is propagated from the web API that caused it, if any. For APIs that 40 | cause events to fire asynchronously (e.g. XHR), this would involve storing a 41 | reference to the context when the API is called (e.g. `xhr.send()`), and keeping 42 | it alive until no events can be fired anymore from that asynchronous operation 43 | (e.g. until the XHR request finishes, errors out or is aborted). 44 | 45 | `addEventListener`, however, would need changes if we add the 46 | `EventTarget.captureFallbackContext` API[^1]. With it, the context in which the 47 | passed callback is called also stores the current values of the given 48 | `AsyncContext.Variable`s at the time that `captureFallbackContext` is called, 49 | and any calls to `addEventListener` in that context *will* store those values 50 | alongside the event listener. This will likely leak the values associated to 51 | those variables, and we will need outreach to web platform educators to make 52 | sure that authors understand this, but it's the best solution we've found to 53 | cover one of the goals of this proposal, since the other options we've 54 | considered would cause a lot more leaks. 55 | 56 | [^1]: This API isn't described in any depth in the main web integration document 57 | because the details are still being worked out. See 58 | . Note that this 59 | document describes the version described in 60 | [this comment](https://github.com/tc39/proposal-async-context/issues/107#issuecomment-2659298381), 61 | rather than the one in the OP, which would need storing the whole current 62 | context. 63 | 64 | The web integration document says that observers (such as MutationObserver, 65 | IntersectionObserver...) would use the registration context for their callbacks; 66 | which means when the observer is constructed, it would store a reference to the 67 | current context, which would never be released while the observer is alive. 68 | However, it seems like it might be possible to change this behavior so the 69 | context is not stored at all for observers; instead, the callbacks would be 70 | called with the empty context. 71 | 72 | Although this document and the web integration one describe the context 73 | propagations that must happen due to the browser and JS engine's involvement, 74 | it is also important to have in mind how authors might propagate contexts 75 | implicitly. For example, from the browser's perspective, `requestAnimationFrame` 76 | only keeps the context referenced until the rAF callback is called. However, if 77 | the callback recursively calls `requestAnimationFrame`, which is often the case, 78 | the context is propagated with the callback in the recursion. 79 | 80 | ## The context as a weak map 81 | 82 | Values associated to an `AsyncContext.Variable` must be strongly held (not weak 83 | references) because you can do `asyncVar.get()` inside that context and get the 84 | associated value, even if there are no other references to it. 85 | 86 | However, the AsyncContext proposal purposefully gives JS code no way to get a 87 | list of the entries stored in an async context map. This is 88 | done to maintain encapsulation, but it also has the side effect that it allows 89 | implementing the context as a weak map. 90 | 91 | If an `AsyncContext.Variable` used as a key in the context is otherwise unreachable, 92 | then there is no way for any JS code to read the corresponding async context entry 93 | at any future time. At that point, that whole entry in the context map (both the key 94 | and the value), could be deleted. This would be implementing the context as a weak 95 | map (see the JS [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) 96 | built-in). 97 | 98 | In most uses of AsyncContext, we don't expect that `AsyncContext.Variable`s 99 | could become unreachable (i.e. GC-able) while the async context maps that capture 100 | stay reachable. This is because most uses would store the `AsyncContext.Variable` 101 | object in a (JavaScript) variable at the top level of a script or module, so any 102 | exported functions in the script/module will have it in its scope, and will keep 103 | it alive. 104 | 105 | However, we do expect a weak map implementation to be useful in cases where a 106 | cross-realm interaction results in capturing `AsyncContext.Variable` keys from a different realm. This capturing happens implicitly through cross-realm function 107 | calls, and we wouldn't want to accidentally keep a whole realm alive. 108 | 109 | The proposed JS spec for AsyncContext does not explicitly mandate that the 110 | context must be implemented as a weak map, but that is a possible 111 | implementation. However, we are aware that garbage collecting weak maps comes at a 112 | performance cost. 113 | 114 | We expect that there will be two "kinds" of async context maps: 115 | - a lot of very short-lived ones, used by frameworks during the rendering process. 116 | - a few long-lived ones, used to trace long-running tasks 117 | 118 | We also expect that, while there can be a lot of async context maps, they will all contain a very limited number of entries (a single-digit amount in most cases). 119 | 120 | For this reason, after consulting with experts in the field, the champions' 121 | recommendation is to implement a hybrid approach. Async context maps should initially 122 | be considered to held their entries strongly, and then transition to be weak after a 123 | while. Deciding exactly what "after a while" means will need in-the-field 124 | experimentation, but some potential approaches are: 125 | - async context maps that survive one major CG cycle get marked as weak 126 | - or, async context maps that survive X major GC cycles 127 | - async context maps are marked as weak after a set amount of time 128 | - for platform that have "young objects" and "old objects" memory spaces, async context maps could become weak once they get moved to the old space. 129 | 130 | The goal of these approaches is that when creating async context maps that live 131 | shorter than a framework's rendering cycle, they would never transition to be 132 | weak before that the whole map is garbage collected. 133 | 134 | Given that weak context maps are not directly exposed to JavaScript, switching between 135 | weak and strong only requires flipping a bit somewhere that the garbage collector 136 | references graph traversal logic can check. 137 | -------------------------------------------------------------------------------- /USE-CASES.md: -------------------------------------------------------------------------------- 1 | > This document contains some abstract examples of `AsyncContext` use cases. See also [./FRAMEWORKS.md](./FRAMEWORKS.md) for concrete use cases that web frameworks have. 2 | 3 | Use cases for `AsyncContext` include: 4 | 5 | - Annotating logs with information related to an asynchronous callstack. 6 | 7 | - Collecting performance information across logical asynchronous threads of 8 | control. This includes timing measurements, as well as OpenTelemetry. For 9 | example, OpenTelemetry's 10 | [`ZoneContextManager`](https://open-telemetry.github.io/opentelemetry-js/classes/_opentelemetry_context_zone_peer_dep.ZoneContextManager.html) 11 | is only able to achieve this by using zone.js (see the [prior arts section](./README.md#prior-arts)). 12 | 13 | - Web APIs such as 14 | [Prioritized Task Scheduling](https://wicg.github.io/scheduling-apis) let 15 | users schedule a task in the event loop with a given priority. However, this 16 | only affects that task's priority, so users might need to propagate that 17 | priority, particularly into promise jobs and callbacks. 18 | 19 | Furthermore, having a way to keep track of async control flows in the JS 20 | engine would allow these APIs to make the priority of such a task transitive, 21 | so that it would automatically be used for any tasks/jobs originating from it. 22 | 23 | - There are a number of use cases for browsers to track the attribution of tasks 24 | in the event loop, even though an asynchronous callstack. They include: 25 | 26 | - Optimizing the loading of critical resources in web pages requires tracking 27 | whether a task is transitively depended on by a critical resource. 28 | 29 | - Tracking long tasks effectively with the 30 | [Long Tasks API](https://w3c.github.io/longtasks) requires being able to 31 | tell where a task was spawned from. 32 | 33 | - [Measuring the performance of SPA soft navigations](https://developer.chrome.com/blog/soft-navigations-experiment/) 34 | requires being able to tell which task initiated a particular soft 35 | navigation. 36 | 37 | Hosts are expected to use the infrastructure in this proposal to allow tracking 38 | not only asynchronous callstacks, but other ways to schedule jobs on the event 39 | loop (such as `setTimeout`) to maximize the value of these use cases. 40 | 41 | ## A use case in depth: logging 42 | 43 | It's easiest to explain this in terms of setting and reading a global variable 44 | in sync execution. Imagine we're a library which provides a simple `log` and 45 | `run` function. Users may pass their callbacks into our `run` function and an 46 | arbitrary "id". The `run` will then invoke their callback and while running, the 47 | developer may call our `log` function to annotate the logs with the id they 48 | passed to the run. 49 | 50 | ```typescript 51 | let currentId = undefined; 52 | 53 | export function log() { 54 | if (currentId === undefined) throw new Error('must be inside a run call stack'); 55 | console.log(`[${currentId}]`, ...arguments); 56 | } 57 | 58 | export function run(id: string, cb: () => T) { 59 | let prevId = currentId; 60 | try { 61 | currentId = id; 62 | return cb(); 63 | } finally { 64 | currentId = prevId; 65 | } 66 | } 67 | ``` 68 | 69 | The developer may then use our library like this: 70 | 71 | ```typescript 72 | import { run, log } from 'library'; 73 | import { helper } from 'some-random-npm-library'; 74 | 75 | document.body.addEventListener('click', () => { 76 | const id = new Uuid(); 77 | 78 | run(id, () => { 79 | log('starting'); 80 | 81 | // Assume helper will invoke doSomething. 82 | helper(doSomething); 83 | 84 | log('done'); 85 | }); 86 | }); 87 | 88 | function doSomething() { 89 | log("did something"); 90 | } 91 | ``` 92 | 93 | In this example, no matter how many times a user may click, we'll also see a 94 | perfect "[123] starting", "[123] did something" "[123] done" log. We've 95 | essentially implemented a synchronous context stack, able to propagate the `id` 96 | down through the developers call stack without them needing to manually pass or 97 | store the id themselves. This pattern is extremely useful. It is not always 98 | ergonomic (or even always possible) to pass a value through every function call 99 | (think of passing React props through several intermediate components vs passing 100 | through a React [Context](https://reactjs.org/docs/context.html)). 101 | 102 | However, this scenario breaks as soon as we introduce any async operation into 103 | our call stack. 104 | 105 | ```typescript 106 | document.body.addEventListener('click', () => { 107 | const id = new Uuid(); 108 | 109 | run(id, async () => { 110 | log('starting'); 111 | 112 | await helper(doSomething); 113 | 114 | // This will error! We've lost our id! 115 | log('done'); 116 | }); 117 | }); 118 | 119 | function doSomething() { 120 | // Will this error? Depends on if `helper` awaited before calling. 121 | log("did something"); 122 | } 123 | ``` 124 | 125 | `AsyncContext` solves this issue, allowing you to propagate the id through both 126 | sync and async execution by keeping track of the context in which we started the 127 | execution. 128 | 129 | ```typescript 130 | const context = new AsyncContext.Variable(); 131 | 132 | export function log() { 133 | const currentId = context.get(); 134 | if (currentId === undefined) throw new Error('must be inside a run call stack'); 135 | console.log(`[${currentId}]`, ...arguments); 136 | } 137 | 138 | export function run(id: string, cb: () => T) { 139 | context.run(id, cb); 140 | } 141 | ``` 142 | 143 | ## Use Case: Soft Navigation Heuristics 144 | 145 | When a user interacts with the page, it's critical that the app feels fast. 146 | But there's no way to determine what started this chain of interaction when 147 | the final result is ready to patch into the DOM tree. The problem becomes 148 | more prominent if the interaction involves with several asynchronous 149 | operations since their original call stack has gone. 150 | 151 | ```typescript 152 | // Framework listener 153 | doc.addEventListener('click', () => { 154 | context.run(Date.now(), async () => { 155 | // User code 156 | const f = await fetch(dataUrl); 157 | patch(doc, await f.json()); 158 | }); 159 | }); 160 | // Some framework code 161 | const context = new AsyncContext.Variable(); 162 | function patch(doc, data) { 163 | doLotsOfWork(doc, data, update); 164 | } 165 | function update(doc, html) { 166 | doc.innerHTML = html; 167 | // Calculate the duration of the user interaction from the value in the 168 | // AsyncContext instance. 169 | const duration = Date.now() - context.get(); 170 | } 171 | ``` 172 | 173 | ## Use Case: Transitive Task Attributes 174 | 175 | Browsers can schedule tasks with priorities attributes. However, the task 176 | priority attribution is not transitive at the moment. 177 | 178 | ```typescript 179 | async function task() { 180 | startWork(); 181 | await scheduler.yield(); 182 | doMoreWork(); 183 | // Task attributes are lost after awaiting. 184 | let response = await fetch(myUrl); 185 | let data = await response.json(); 186 | process(data); 187 | } 188 | 189 | scheduler.postTask(task, {priority: 'background'}); 190 | ``` 191 | 192 | The task may include the following attributes: 193 | - Execution priority, 194 | - Fetch priority, 195 | - Privacy protection attributes. 196 | 197 | With the mechanism of `AsyncContext` in the language, tasks attributes can be 198 | transitively propagated. 199 | 200 | ```typescript 201 | const res = await scheduler.postTask(task, { 202 | priority: 'background', 203 | }); 204 | console.log(res); 205 | 206 | async function task() { 207 | // Fetch remains background priority. 208 | const resp = await fetch('/hello'); 209 | const text = await resp.text(); 210 | 211 | // doStuffs should schedule background tasks by default. 212 | return doStuffs(text); 213 | } 214 | 215 | async function doStuffs(text) { 216 | // Some async calculation... 217 | return text; 218 | } 219 | ``` 220 | 221 | ## Use Case: Userspace telemetry 222 | 223 | Application performance monitoring libraries like [OpenTelemetry][] can save 224 | their tracing spans in an `AsyncContext` and retrieves the span when they determine 225 | what started this chain of interaction. 226 | 227 | It is a requirement that these libraries can not intrude the developer APIs 228 | for seamless monitoring. 229 | 230 | ```typescript 231 | doc.addEventListener('click', () => { 232 | // Create a span and records the performance attributes. 233 | const span = tracer.startSpan('click'); 234 | context.run(span, async () => { 235 | const f = await fetch(dataUrl); 236 | patch(dom, await f.json()); 237 | }); 238 | }); 239 | 240 | const context = new AsyncContext.Variable(); 241 | function patch(dom, data) { 242 | doLotsOfWork(dom, data, update); 243 | } 244 | function update(dom, html) { 245 | dom.innerHTML = html; 246 | // Mark the chain of interaction as ended with the span 247 | const span = context.get(); 248 | span?.end(); 249 | } 250 | ``` 251 | 252 | ### User Interaction 253 | 254 | OpenTelemetry instruments user interaction with document elements and connects 255 | subsequent network requests and history state changes with the user 256 | interaction. 257 | 258 | The propagation of spans can be achieved with `AsyncContext` and helps 259 | distinguishing the initiators (document load, or user interaction). 260 | 261 | ```typescript 262 | registerInstrumentations({ 263 | instrumentations: [new UserInteractionInstrumentation()], 264 | }); 265 | 266 | // Subsequent network requests are associated with the user-interaction. 267 | const btn = document.getElementById('my-btn'); 268 | btn.addEventListener('click', () => { 269 | fetch('https://httpbin.org/get') 270 | .then(() => { 271 | console.log('data downloaded 1'); 272 | return fetch('https://httpbin.org/get'); 273 | }); 274 | .then(() => { 275 | console.log('data downloaded 2'); 276 | }); 277 | }); 278 | ``` 279 | 280 | Read more at [opentelemetry/user-interaction](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-user-interaction). 281 | 282 | ### Long task initiator 283 | 284 | Tracking long tasks effectively with the [Long Tasks API](https://github.com/w3c/longtasks) 285 | requires being able to tell where a task was spawned from. 286 | 287 | However, OpenTelemetry is not able to associate the Long Task timing entry 288 | with their initiating trace spans. Capturing the `AsyncContext` can help here. 289 | 290 | Notably, this proposal doesn't solve the problem solely. It provides a path 291 | forward to the problem and can be integrated into the Long Tasks API. 292 | 293 | ```typescript 294 | registerInstrumentations({ 295 | instrumentations: [new LongTaskInstrumentation()], 296 | }); 297 | // Roughly equals to 298 | new PerformanceObserver(list => {...}) 299 | .observe({ entryTypes: ['longtask'] }); 300 | 301 | // Perform a 50ms long task 302 | function myTask() { 303 | const start = Date.now(); 304 | while (Date.now() - start <= 50) {} 305 | } 306 | 307 | myTask(); 308 | ``` 309 | 310 | Read more at [opentelemetry/long-task](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-long-task). 311 | 312 | ### Resource Timing Attributes 313 | 314 | OpenTelemetry instruments fetch API with network timings from [Resource Timing API](https://github.com/w3c/resource-timing/) 315 | associated to the initiator fetch span. 316 | 317 | Without resource timing initiator info, it is not an intuitive approach to 318 | associate the resource timing with the initiator spans. Capturing the 319 | `AsyncContext` can help here. 320 | 321 | Notably, this proposal doesn't solve the problem solely. It provides a path 322 | forward to the problem and can be integrated into the Long Tasks API. 323 | 324 | ```typescript 325 | registerInstrumentations({ 326 | instrumentations: [new FetchInstrumentation()], 327 | }); 328 | // Observes network events and associate them with spans. 329 | new PerformanceObserver(list => { 330 | const entries = list.getEntries(); 331 | spans.forEach(span => { 332 | const entry = entries.find(it => { 333 | return it.name === span.name && it.startTime >= span.startTime; 334 | }); 335 | span.recordNetworkEvent(entry); 336 | }); 337 | }).observe({ entryTypes: ['resource'] }); 338 | ``` 339 | 340 | Read more at [opentelemetry/fetch](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-fetch). 341 | 342 | [OpenTelemetry]: https://github.com/open-telemetry/opentelemetry-js 343 | 344 | ## Use Case: Running servers in service workers 345 | 346 | A typical web app has a server-side component and a client-side component. Many web frameworks (Next, Nuxt, SvelteKit etc) are designed in such a way that both halves can be built from a single codebase. 347 | 348 | One request that framework maintainers often receive is for the ability to run the server, or at least part of it, in a service worker. Barring any dependencies on sensitive information that should not be accessible to users, or packages that only run in a server environment, this can be a successful strategy for building low-latency apps that are resilient in the face of poor network conditions. 349 | 350 | Many of these frameworks, however, have embraced `AsyncLocalStorage` within their server code, since it unlocks a plethora of use cases and is well supported by various server environments. The lack of an equivalent API _outside_ server environments is thus preventing frameworks from embracing service workers. 351 | -------------------------------------------------------------------------------- /MUTATION-SCOPE.md: -------------------------------------------------------------------------------- 1 | # Mutation Scope 2 | 3 | The enforced mutation function scope APIs with `run` (as in 4 | `AsyncContext.Snapshot.prototype.run` and `AsyncContext.Variable.prototype.run`) 5 | requires any `Variable` value mutations or `Snapshot` restorations to be 6 | performed within a new function scope. 7 | 8 | Modifications to `Variable` values are propagated to its subtasks. This `.run` 9 | scope enforcement prevents any modifications to be visible to its caller 10 | function scope, consequently been propagated to tasks created in sibling 11 | function calls. 12 | 13 | For instance, given a global scheduler state and a piece of user code: 14 | 15 | ```js 16 | globalThis.scheduler = { 17 | #asyncVar: new AsyncContext.Variable(), 18 | postTask(task, { priority }) { 19 | asyncVar.run(priority, task); 20 | }, 21 | yield() { 22 | const priority = asyncVar.get(); 23 | return new Promise(resolve => { 24 | // resolve at a timing depending on the priority 25 | resolve(); 26 | }); 27 | }, 28 | }; 29 | 30 | async function f() { 31 | await scheduler.yield(); 32 | 33 | await someLibrary.doAsyncWork(); 34 | someLibrary.doSyncWork(); 35 | 36 | // this can not be affected by either `doAsyncWork` or `doSyncWork` call. 37 | await scheduler.yield(); 38 | } 39 | ``` 40 | 41 | In this case, the `scheduler.yield` calls in function `f` will never be affected by 42 | sibling library function calls. 43 | 44 | Notably, AsyncContext by itself is designed to be scoped by instance of 45 | `AsyncContext.Variable`s, and without sharing a reference to the instance, its 46 | value will not be affected in library calls. This example shows a design that 47 | modifications in `AsyncContext.Variable` are only visible to logical subtasks. 48 | 49 | ## Overview 50 | 51 | There are two types of mutation scopes in the above example: 52 | 53 | - "sync": mutations made in synchronous execution in `someLibrary.doSyncWork()` 54 | (or `someLibrary.doAsyncWork()` without `await`), 55 | - "async": mutations made in async flow in `await someLibrary.doAsyncWork()`. 56 | 57 | Type | Mutation not visible to parent scope | Mutation visible to parent scope 58 | --- | --- | --- 59 | Sync | `.run(value, fn)`, set semantic with scope enforcement | set semantic without scope enforcement 60 | Async | `AsyncContext.Variable` | `ContinuationVariable` 61 | 62 | ## Usages of run 63 | 64 | The `run` pattern can already handles many existing usage pattern well that 65 | involves function calls, like: 66 | 67 | - Event handlers, 68 | - Middleware. 69 | 70 | For example, an event handler can be easily refactored to use `.run(value, fn)` 71 | by wrapping: 72 | 73 | ```js 74 | function handler(event) { 75 | ... 76 | } 77 | 78 | button.addEventListener("click", handler); 79 | // ... replace it with ... 80 | button.addEventListener("click", event => { 81 | asyncVar.run(createSpan(), handler, event); 82 | }); 83 | ``` 84 | 85 | Or, on Node.js server applications, where middlewares are common to use: 86 | 87 | ```js 88 | const middlewares = []; 89 | function use(fn) { 90 | middlewares.push(fn) 91 | } 92 | 93 | async function runMiddlewares(req, res) { 94 | function next(i) { 95 | if (i === middlewares.length) { 96 | return; 97 | } 98 | return middlewares[i](req, res, next.bind(i++)); 99 | } 100 | 101 | return next(0); 102 | } 103 | ``` 104 | 105 | A tracing library like OpenTelemetry can instrument it with a simple 106 | middleware like: 107 | 108 | ```js 109 | async function otelMiddleware(req, res, next) { 110 | const w3cTraceHeaders = extractW3CHeaders(req); 111 | const span = createSpan(w3cTraceHeaders); 112 | req.setHeader('x-trace-id', span.traceId); 113 | try { 114 | await asyncVar.run(span, next); 115 | } catch (e) { 116 | span.setError(e); 117 | } finally { 118 | span.end(); 119 | } 120 | } 121 | ``` 122 | 123 | ### Limitation of run 124 | 125 | The enforcement of mutation scopes can reduce the chance that the mutation is 126 | exposed to the parent scope in unexpected way, but it also increases the bar to 127 | use the feature or migrate existing code to adopt the feature. 128 | 129 | For example, given a snippet of code: 130 | 131 | ```js 132 | function *gen() { 133 | yield computeResult(); 134 | yield computeResult2(); 135 | } 136 | ``` 137 | 138 | If we want to scope the `computeResult` and `computeResult2` calls with a new 139 | AsyncContext value, it needs non-trivial refactor: 140 | 141 | ```js 142 | const asyncVar = new AsyncContext.Context(); 143 | 144 | function *gen() { 145 | const span = createSpan(); 146 | yield asyncVar.run(span, () => computeResult()); 147 | yield asyncVar.run(span, () => computeResult2()); 148 | // ...or 149 | yield* asyncVar.run(span, function *() { 150 | yield computeResult(); 151 | yield computeResult2(); 152 | }); 153 | } 154 | ``` 155 | 156 | `.run(val, fn)` creates a new function body. The new function environment 157 | is not equivalent to the outer environment and can not trivially share code 158 | fragments between them. Additionally, `break`/`continue`/`return` can not be 159 | refactored naively. 160 | 161 | It will be more intuitive to be able to insert a new line and without refactor 162 | existing code snippet. 163 | 164 | ```diff 165 | const asyncVar = new AsyncContext.Variable(); 166 | 167 | function *gen() { 168 | + using _ = asyncVar.withValue(createSpan(i)); 169 | yield computeResult(i); 170 | yield computeResult2(i); 171 | } 172 | ``` 173 | 174 | ## The set semantic with scope enforcement 175 | 176 | With the name of `set`, this method actually doesn't modify existing async 177 | context snapshots, similar to consecutive `run` operations. For example, in 178 | the following case, `set` doesn't change the context variables in async tasks 179 | created just prior to the mutation: 180 | 181 | An alternative to exposing the `set` semantics directly is allowing mutation 182 | with well-known symbol interface [`@@dispose`][] by using declaration (and 183 | potentially enforcing the `using` declaration with [`@@enter`][]). 184 | 185 | ```js 186 | const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); 187 | 188 | { 189 | using _ = asyncVar.withValue("main"); 190 | new AsyncContext.Snapshot() // snapshot 0 191 | console.log(asyncVar.get()); // => "main" 192 | } 193 | 194 | { 195 | using _ = asyncVar.withValue("value-1"); 196 | new AsyncContext.Snapshot() // snapshot 1 197 | Promise.resolve() 198 | .then(() => { // continuation 1 199 | console.log(asyncVar.get()); // => 'value-1' 200 | }) 201 | } 202 | 203 | { 204 | using _ = asyncVar.withValue("value-2"); 205 | new AsyncContext.Snapshot() // snapshot 2 206 | Promise.resolve() 207 | .then(() => { // continuation 2 208 | console.log(asyncVar.get()); // => 'value-2' 209 | }) 210 | } 211 | ``` 212 | 213 | The value mapping is equivalent to: 214 | 215 | ``` 216 | ⌌-----------⌍ snapshot 0 217 | | 'main' | 218 | ⌎-----------⌏ 219 | | 220 | ⌌-----------⌍ snapshot 1 221 | | 'value-1' | <---- the continuation 1 222 | ⌎-----------⌏ 223 | | 224 | ⌌-----------⌍ snapshot 2 225 | | 'value-2' | <---- the continuation 2 226 | ⌎-----------⌏ 227 | ``` 228 | 229 | Each `@@enter` operation create a new value slot preventing any mutation to 230 | existing snapshots where the current `AsyncContext.Variable`'s value was 231 | captured. 232 | 233 | This trait is important with both `run` and `set` because mutations to 234 | `AsyncContext.Variable`s must not mutate prior `AsyncContext.Snapshot`s. 235 | 236 | > Note: this also applies to [`ContinuationVariable`][] 237 | 238 | However, the well-known symbol `@@dispose` and `@@enter` is not bound to the 239 | `using` declaration syntax, and they can be invoked manually. This can be a 240 | by-design feature allowing advanced userland extension, like OpenTelemetry's 241 | example in the next section. 242 | 243 | This can be an extension to the proposed `run` semantics. 244 | 245 | ### Use cases 246 | 247 | The set semantic allows instrumenting existing codes without nesting them in a 248 | new function scope and reducing the refactoring work: 249 | 250 | ```js 251 | async function doAnotherWork() { 252 | // defer work to next promise tick. 253 | await 0; 254 | using span = tracer.startAsCurrentSpan("anotherWork"); 255 | console.log("doing another work"); 256 | // the span is closed when it's out of scope 257 | } 258 | 259 | async function doWork() { 260 | using parent = tracer.startAsCurrentSpan("parent"); 261 | // do some work that 'parent' tracks 262 | console.log("doing some work..."); 263 | const anotherWorkPromise = doAnotherWork(); 264 | // Create a nested span to track nested work 265 | { 266 | using child = tracer.startAsCurrentSpan("child"); 267 | // do some work that 'child' tracks 268 | console.log("doing some nested work...") 269 | // the nested span is closed when it's out of scope 270 | } 271 | await anotherWorkPromise; 272 | // This parent span is also closed when it goes out of scope 273 | } 274 | ``` 275 | 276 | > This example is adapted from the OpenTelemetry Python example. 277 | > https://opentelemetry.io/docs/languages/python/instrumentation/#creating-spans 278 | 279 | Each `tracer.startAsCurrentSpan` invocation retrieves the parent span from its 280 | own `AsyncContext.Variable` instance and create span as a child, and set the 281 | child span as the current value of the `AsyncContext.Variable` instance: 282 | 283 | ```js 284 | class Tracer { 285 | #var = new AsyncContext.Variable(); 286 | 287 | startAsCurrentSpan(name) { 288 | let scope; 289 | const span = { 290 | name, 291 | parent: this.#var.get(), 292 | [Symbol.enter]: () => { 293 | scope = this.#var.withValue(span)[Symbol.enter](); 294 | return span; 295 | }, 296 | [Symbol.dispose]: () => { 297 | scope[Symbol.dispose](); 298 | }, 299 | }; 300 | return span; 301 | } 302 | } 303 | ``` 304 | 305 | The set semantic that doesn't mutate existing snapshots is crucial to the 306 | `startAsCurrentSpan` example here, as it allows deferred span created in 307 | `doAnotherWork` to be a child span of the `"parent"` instead of `"child"`, 308 | shown as graph below: 309 | 310 | ``` 311 | ⌌----------⌍ 312 | | 'parent' | 313 | ⌎----------⌏ 314 | | ⌌---------⌍ 315 | |---| 'child' | 316 | | ⌎---------⌏ 317 | | ⌌-----------------⌍ 318 | |---| 'doAnotherWork' | 319 | | ⌎-----------------⌏ 320 | ``` 321 | 322 | ### Alternative: Decouple mutation with scopes 323 | 324 | To preserve the strong scope guarantees provided by `run`, an additional 325 | constraint can also be put to `set` to declare explicit scopes of mutation. 326 | 327 | A dedicated `AsyncContext.contextScope` can be decoupled with `run` to open a 328 | mutable scope with a series of `set` operations. 329 | 330 | ```js 331 | const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); 332 | 333 | asyncVar.set("A"); // Throws ReferenceError: Not in a mutable context scope. 334 | 335 | // Executes the `main` function in a new mutable context scope. 336 | AsyncContext.contextScope(() => { 337 | asyncVar.set("main"); 338 | 339 | console.log(asyncVar.get()); // => "main" 340 | }); 341 | // Goes out of scope and all variables are restored in the current context. 342 | 343 | console.log(asyncVar.get()); // => "default" 344 | ``` 345 | 346 | `AsyncContext.contextScope` is basically a shortcut of 347 | `AsyncContext.Snapshot.run`: 348 | 349 | ```js 350 | const asyncVar = new AsyncContext.Variable({ defaultValue: "default" }); 351 | 352 | asyncVar.set("A"); // Throws ReferenceError: Not in a mutable context scope. 353 | 354 | // Executes the `main` function in a new mutable context scope. 355 | AsyncContext.Snapshot.wrap(() => { 356 | asyncVar.set("main"); 357 | 358 | console.log(asyncVar.get()); // => "main" 359 | })(); 360 | // Goes out of scope and all variables are restored in the current context. 361 | 362 | console.log(asyncVar.get()); // => "default" 363 | ``` 364 | 365 | #### Use cases 366 | 367 | One use case of `set` is that it allows more intuitive test framework 368 | integration, or similar frameworks that have prose style declarations. 369 | 370 | ```js 371 | describe("asynct context", () => { 372 | const ctx = new AsyncContext.Variable(); 373 | 374 | beforeEach((test) => { 375 | ctx.set(1); 376 | }); 377 | 378 | it('run in snapshot', () => { 379 | // This function is run as a second paragraph of the test sequence. 380 | assert.strictEqual(ctx.get(),1); 381 | }); 382 | }); 383 | 384 | function testDriver() { 385 | await AsyncContext.contextScope(async () => { 386 | runBeforeEach(); 387 | await runTest(); 388 | runAfterEach(); 389 | }); 390 | } 391 | ``` 392 | 393 | However, without proper test framework support, mutations in async `beforeEach` 394 | are still unintuitive, e.g. https://github.com/xunit/xunit/issues/1880. 395 | 396 | This can be addressed with a callback nesting API to continue the prose: 397 | 398 | ```js 399 | describe("asynct context", () => { 400 | const ctx = new AsyncContext.Variable(); 401 | 402 | beforeEach(async (test) => { 403 | await undefined; 404 | ctx.set(1); 405 | test.setSnapshot(new AsyncContext.Snapshot()); 406 | }); 407 | 408 | it('run in snapshot', () => { 409 | // This function is run in the snapshot saved in `test.setSnapshot`. 410 | assert.strictEqual(ctx.get(),1); 411 | }); 412 | }); 413 | 414 | function testDriver() { 415 | let snapshot = new AsyncContext.Snapshot(); 416 | await AsyncContext.contextScope(async () => { 417 | await runBeforeEach({ 418 | setSnapshot(it) { 419 | snapshot = it; 420 | } 421 | }); 422 | await snapshot.run(() => runTest()); 423 | await runAfterEach(); 424 | }); 425 | } 426 | ``` 427 | 428 | > ❓: A real world use case that facilitate the same component that uses 429 | > `AsyncContext.Variable` in both production and test environment. 430 | 431 | ## Summary 432 | 433 | The set semantic can be an extension to the existing proposal with `@@enter` 434 | and `@@dispose` well-known symbols allowing using declaration scope 435 | enforcement. 436 | 437 | [`@@dispose`]: https://github.com/tc39/proposal-explicit-resource-management?tab=readme-ov-file#using-declarations 438 | [`@@enter`]: https://github.com/tc39/proposal-using-enforcement?tab=readme-ov-file#proposed-solution 439 | [`ContinuationVariable`]: ./CONTINUATION.md 440 | -------------------------------------------------------------------------------- /CONTINUATION.md: -------------------------------------------------------------------------------- 1 | # Continuation flows 2 | 3 | The proposal as it currently stands defines propagating context variables to 4 | subtasks without feeding back any modifications in subtasks to the context of 5 | their parent async task. 6 | 7 | ```js 8 | const asyncVar = new AsyncContext.Variable() 9 | 10 | asyncVar.run('main', main) 11 | 12 | async function main() { 13 | asyncVar.get() // => 'main' 14 | 15 | await asyncVar.run('inner', async () => { 16 | asyncVar.get() // => 'inner' 17 | await task() 18 | asyncVar.get() // => 'inner' 19 | // value in this scope is not changed by subtasks 20 | }) 21 | 22 | asyncVar.get() // => 'main' 23 | // value in this scope is not changed by subtasks 24 | } 25 | 26 | let taskId = 0 27 | async function task() { 28 | asyncVar.get() // => 'inner' 29 | await asyncVar.run(`task-${taskId++}`, async () => { 30 | asyncVar.get() // => 'task-0' 31 | // ... async operations 32 | await 1 33 | asyncVar.get() // => 'task-0' 34 | }) 35 | } 36 | ``` 37 | 38 | In this model, any modifications are async task local scoped. A subtask 39 | snapshots the context when they are scheduled and never propagates the 40 | modification back to its parent async task scope. 41 | 42 | > Checkout [AsyncContext in other languages](./PRIOR-ARTS.md#asynccontext-in-other-languages)! 43 | 44 | Another semantic was proposed to improve traceability on determine the 45 | continuation "previous" task in a logical asynchronous execution. In this 46 | model, modifications made in an async subtask are propagated to its 47 | continuation tasks. 48 | 49 | It was initially proposed based on the callback style of async continuations. 50 | We'll use the term "ContinuationFlow" in the following example. 51 | 52 | ```js 53 | const cf = new ContinuationFlow() 54 | 55 | cf.run('main', () => { 56 | readFile(file, () => { 57 | // The continuation flow should be preserved here. 58 | cf.get() // => main 59 | }) 60 | }) 61 | 62 | function readFile(file, callback) { 63 | const snapshot = new AsyncContext.Snapshot() 64 | // readFile is composited with a series of sub-operations. 65 | fs.open(file, (err, fd) => { 66 | fs.read(fd, (err, text) => { 67 | fs.close(fd, (err) => { 68 | snapshot.run(callback. err, text) 69 | }) 70 | }) 71 | }) 72 | } 73 | ``` 74 | 75 | In the above example, callbacks can be composed naturally with the function 76 | argument of `run(value, fn)` signature, and making it possible to feedback 77 | the modification of the context of subtasks to the callbacks from outer scope. 78 | 79 | ```js 80 | cf.run('main', () => { 81 | readFile(file, () => { 82 | // The continuation flow is a continuation of the fs.close operation. 83 | cf.get() // => `main -> fs.close` 84 | }) 85 | }) 86 | 87 | function readFile(file, callback) { 88 | const snapshot = new AsyncContext.Snapshot() 89 | // ... 90 | // omit the other part 91 | fs.close(fd, (err) => { 92 | snapshot.run(() => { 93 | cf.run(`${cf.get()} -> fs.close`, () => { 94 | callback(err, text) 95 | }) 96 | }) 97 | }) 98 | } 99 | ``` 100 | 101 | Since promise handlers are continuation functions as well, it suggests the 102 | initial example to behave like: 103 | 104 | ```js 105 | const cf = new ContinuationFlow() 106 | 107 | cf.run('main', main) 108 | async function main() { 109 | cf.get() // => 'main' 110 | 111 | await cf.run('inner', async () => { 112 | cf.get() // => 'inner' 113 | await task() 114 | cf.get() // => 'task-0' 115 | }) 116 | 117 | cf.get() // => 'task-0' 118 | } 119 | 120 | let taskId = 0 121 | async function task() { 122 | cf.get() // => 'inner' 123 | await cf.run(`task-${taskId++}`, async () => { 124 | const id = cf.get() // => 'task-0' 125 | // ... async operations 126 | await 1 127 | cf.get() // => can be anything from above async operations 128 | 129 | // Forcefully reset the ctx 130 | return cf.run(id, () => Promise.resolve()) 131 | }) 132 | } 133 | ``` 134 | 135 | In this model, any modifications are passed along with the continuation flow. 136 | An async subtask snapshots the context when they are continued from (either 137 | fulfilled or rejected) and restores the continuation snapshot when invoking the 138 | continuation callbacks. 139 | 140 | ## Comparison 141 | 142 | There are several properties that both semantics persist yet with different 143 | semantics: 144 | 145 | - Implicit-propagation: both semantics would propagate contexts based on 146 | language built-in structures, allowing implicit propagation across call 147 | boundaries without explicit parameter-passing. 148 | - If there are no modifications in any subtasks, the two would be 149 | practically identical. 150 | - Causality: both semantics don't use the MOST relevant cause as its parent 151 | context because the MOST relevant causes can be un-deterministic and lead to 152 | confusions in real world API compositions (as in 153 | [`unhandledrejection`](#unhandled-rejection) and 154 | [Fulfilled promises](#fulfilled-promises)). 155 | 156 | However, new issues arose for the continuation flow semantic: 157 | 158 | - Merge-points: as feeding back the modifications to the parent scope, there 159 | must be a strategy to handle merge conflicts. 160 | - Cutting Leaf-node: it is proposed that the continuation flow is specifically 161 | addressing the issue that modifications made in leaf-node of an async graph are 162 | discarded, yet the current semantic doesn't not handle the leaf-node cutting 163 | problem in all edge cases. 164 | 165 | ## Promise operations 166 | 167 | Promise is the basic control-flow building block in async codes. Since promises 168 | are first-class values, they can be passed around, aggregated, and so on. 169 | Promise handlers can be attached to a promise in a different context than the 170 | creation context of the promise. 171 | 172 | The following promise aggregation APIs may be a merge point where multiple 173 | promises are merged into one single promise, or short-circuit in conditions 174 | picking one single winner and discarding all other states. 175 | 176 | | Name | Description | On-resolve | On-reject | 177 | | -------------------- | ----------------------------------------------- | ------------- | ------------- | 178 | | `Promise.allSettled` | does not short-circuit | Merge | Merge | 179 | | `Promise.all` | short-circuits when an input value is rejected | Merge | Short-circuit | 180 | | `Promise.race` | short-circuits when an input value is settled | Short-circuit | Short-circuit | 181 | | `Promise.any` | short-circuits when an input value is fulfilled | Short-circuit | Merge | 182 | 183 | To expand on the behaviors, given a task with the following definition, with 184 | `valueStore` being the corresponding context variable (`AsyncContext.Variable` and 185 | `ContinuationFlow`) in each semantic: 186 | 187 | ```js 188 | let taskId = 0 189 | function randomTask() { 190 | return valueStore.run(`task-${taskId++}`, async () => { 191 | await scheduler.wait(Math.random * 10) 192 | if (Math.random() >= 0.5) { 193 | throw new Error() 194 | } 195 | }) 196 | } 197 | ``` 198 | 199 | ### Example 200 | 201 | We'll take `Promise.all` as an example since it merges when all promises 202 | fulfills and take a short-circuit when any of the promises is rejected. 203 | 204 | ```js 205 | valueStore.run('main', async () => { 206 | try { 207 | await Promise.all(_.times(5).map((it) => randomTask())) 208 | valueStore.get() // => site (1) 209 | } catch (e) { 210 | valueStore.get() // => site (2) 211 | } 212 | }) 213 | ``` 214 | 215 | From different observation perspectives, the value at site (1) would be 216 | different on the above semantics. 217 | 218 | For `AsyncContext.Variable`, it is `main`. 219 | 220 | For `ContinuationFlow`, it needs a mechanism to resolve the merge 221 | conflicts, and pick a winner to be the default current context: 222 | 223 | ```js 224 | await Promise.all([ 225 | task({ id: 1, duration: 100 }), // (1) 226 | task({ id: 2, duration: 1000 }), // (2) 227 | task({ id: 3, duration: 100 }), // (3) 228 | ]) 229 | valueStore.get() // site (4) 230 | valueStore.getAggregated() // site (5) 231 | ``` 232 | 233 | The return value at site (4) could be either the first one in the iterator, as 234 | the context of task (1), or the last finished one in the iterator, as the 235 | context of task (2). And the return value at site (5) could be an aggregated 236 | array of all the values [(1), (2), (3)]. 237 | 238 | The value at site (2): 239 | 240 | - For `AsyncContext.Variable`, it is `main`, 241 | - For `ContinuationFlow`, it is the one caused the rejection, and discarding 242 | leaf contexts of promises that may have been fulfilled. 243 | 244 | ### Graft promises from outer scope 245 | 246 | The state of a resolved or rejected promise never changes, as in primitives or 247 | object identities. `Promise.prototype.then` creates a new promise from an 248 | existing one and automatically bubbles the return value and the exception 249 | to the new promise. 250 | 251 | `await` operations are meant to be practically similar to a 252 | `Promise.prototype.then`. 253 | 254 | By awaiting a promise created outside of the current context, the two 255 | executions are grafted together and there is a merge point on `await`. 256 | 257 | ```js 258 | const gPromise = valueStore.run('global', () => { // (1) 259 | return Promise.resolve(1) 260 | }) 261 | 262 | valueStore.run('main', main) // (2) 263 | async function main() { 264 | await gPromise 265 | // -> global --- 266 | // \ 267 | // -> main ----> (3) 268 | valueStore.get() // site (3) 269 | } 270 | ``` 271 | 272 | The value at site (3): 273 | 274 | - For `AsyncContext.Variable`, it is `main`, 275 | - For `ContinuationFlow`, it is `global`, discarding the leaf context before 276 | `await` operation. 277 | 278 | ### Unhandled rejection 279 | 280 | Unhandled rejection events are tasks that is scheduled when 281 | [HostPromiseRejectionTracker](https://tc39.es/ecma262/#sec-host-promise-rejection-tracker) 282 | is invoked. 283 | 284 | The `PromiseRejectionEvent` instances of unhandled rejection events are emitted 285 | with the following properties: 286 | 287 | - `promise`: the promise which has no handler, 288 | - `reason`: the exception value. 289 | 290 | Intuitively, the context of the event should be relevant to the promise 291 | instance attached to the `PromiseRejectionEvent` instance. 292 | 293 | Or alternatively, a new `PromiseRejectionEvent` property can be defined as: 294 | 295 | - `asyncSnapshot`: the context relevant to the promise that being rejected. 296 | 297 | For a promise created with the `PromiseConstructor` or `Promise.withResolvers`, 298 | the promise can be rejected in a different context: 299 | 300 | ```js 301 | let reject 302 | let p1 303 | valueStore.run('init', () => { // (1) 304 | ;({ p1, reject } = Promise.withResolvers()) 305 | }) 306 | 307 | valueStore.run('reject', () => { // (2) 308 | reject('error message') 309 | }) 310 | 311 | addEventListener('unhandledrejection', (event) => { 312 | event.asyncSnapshot.run(() => { 313 | valueStore.get() // site (3) 314 | }) 315 | }) 316 | ``` 317 | 318 | In this case, the `unhandledrejection` event will be dispatched with the promise 319 | instance `p1` and a string of `'error message'`, and the event is scheduled in 320 | the context of `'reject'`. 321 | 322 | For the two type of context variables with `unhandledrejection` event of `p1` 323 | at site (3): 324 | 325 | - For `AsyncContext.Variable`, the context is `'reject'`, where `p1` was rejected. 326 | - For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. 327 | 328 | --- 329 | 330 | However, if this promise was handled, and the new promise didn't have a proper 331 | handler: 332 | 333 | ```js 334 | let reject 335 | let p1 336 | valueStore.run('init', () => { // (1) 337 | ;({ p1, reject } = Promise.withResolvers()) 338 | const p2 = p1 // p1 is not settled yet 339 | .then(undefined, undefined) 340 | }) 341 | 342 | valueStore.run('reject', () => { // (2) 343 | reject('error message') 344 | }) 345 | 346 | addEventListener('unhandledrejection', (event) => { 347 | event.asyncSnapshot.run(() => { 348 | valueStore.get() // site (3) 349 | }) 350 | }) 351 | ``` 352 | 353 | The `unhandledrejection` event will be dispatched with the promise 354 | instance `p2` with a string of `'error message'`. In this case, the event is 355 | scheduled in a new microtask which was scheduled in the context of `'reject'`. 356 | 357 | For the two type of context variables, the value at site (3) with 358 | `unhandledrejection` event of `p2`: 359 | 360 | - For `AsyncContext.Variable`, the context is `'init'`, where `p2` 361 | attaches the promise handlers to `p1`. 362 | - For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. 363 | 364 | --- 365 | 366 | If handlers were attached to an already rejected promise: 367 | 368 | ```js 369 | let p1 370 | valueStore.run('reject', () => { // (1) 371 | p1 = Promise.reject('error message') 372 | }) 373 | 374 | valueStore.run('init', () => { // (2) 375 | const p2 = p1 // p1 is already rejected 376 | .then(undefined, undefined) 377 | }) 378 | 379 | addEventListener('unhandledrejection', (event) => { 380 | event.asyncSnapshot.run(() => { 381 | valueStore.get() // site (3) 382 | }) 383 | }) 384 | ``` 385 | 386 | In this case, the `unhandledrejection` event is scheduled in the context where 387 | `.then` is called, that is `'init'`. 388 | 389 | > By saying "the `unhandledrejection` event is scheduled" above, it is 390 | > referring to [HostPromiseRejectionTracker](https://html.spec.whatwg.org/#the-hostpromiserejectiontracker-implementation). 391 | > This host hook didn't actually "schedule" the event dispatching, rather putting the 392 | > promise in a queue and the event is actually scheduled from [event loop](https://html.spec.whatwg.org/#notify-about-rejected-promises). 393 | 394 | For the two type of context variables, the value at site (3) with 395 | `unhandledrejection` event of `p2`: 396 | 397 | - For `AsyncContext.Variable`, the context is `'init'`, where `p2` 398 | attaches the promise handlers to `p1`. 399 | - For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. 400 | 401 | ### Fulfilled promises 402 | 403 | The two proposed semantics are not always following the most relevant cause's 404 | context to reduce undeterministic behavior. Similar to the rejected promise 405 | issue, the MOST relevant cause's context when scheduling a microtask for newly 406 | created promise handlers is unobservable from JavaScript: 407 | 408 | ```js 409 | let p1 410 | valueStore.run('resolve', () => { // (1) 411 | p1 = Promise.resolve('yay') 412 | }) 413 | 414 | valueStore.run('init', () => { // (2) 415 | const p2 = p1 // p1 is already resolved 416 | .then(() => { 417 | valueStore.get() // site (3) 418 | }) 419 | }) 420 | ``` 421 | 422 | [`PerformPromiseThen`](https://tc39.es/ecma262/#sec-performpromisethen) calls 423 | [`HostEnqueuePromiseJob`](https://html.spec.whatwg.org/#hostenqueuepromisejob), 424 | which immediately queues a new microtask to call the promise fulfill reaction. 425 | 426 | Explaining in the callback continuation form, this would be: 427 | 428 | ```js 429 | // `Promise.prototype.then` in plain JS. 430 | const then = (p, onFulfill) => { 431 | if (p[PromiseState] === 'FULFILLED') { 432 | let { resolve, reject, promise } = Promise.withResolvers() 433 | queueMicrotask(() => { 434 | resolve(onFulfill(promise[PromiseResult])) 435 | }) 436 | return promise 437 | } 438 | // ... 439 | } 440 | let p1 441 | valueStore.run('resolve', () => { // (1) 442 | p1 = Promise.resolve('yay') 443 | }) 444 | 445 | valueStore.run('init', () => { // (2) 446 | const p2 = then(p1, undefined) // p1 is already resolved 447 | .then(() => { 448 | valueStore.get() // site (3) 449 | }) 450 | }) 451 | ``` 452 | 453 | The context at site (3) represents the context triggered the logical execution 454 | of the promise fulfillment handler. In this case, the most relevant cause's 455 | context at site (2) would be `'init'` since in this context, the microtask is 456 | scheduled. 457 | 458 | However, since if a promise is settled or not is not observable from JS, a data 459 | flow that always following the MOST relevant cause's context would be 460 | undeterministic and exposes a new approach to inspect the promise internal 461 | state. 462 | 463 | The two proposed semantics are not always following the most relevant cause's 464 | context to reduce undeterministic behavior. And the values at site (2) are 465 | regardless of whether the promise was fulfilled or not: 466 | 467 | - For `AsyncContext.Variable`, the context is constantly `'init'`. 468 | - For `ContinuationFlow`, the context is constantly `'resolve'`. 469 | 470 | ## Follow-up 471 | 472 | It has been generally agreed that the two type of context variables have their 473 | own unique use cases and may co-exist. 474 | 475 | Given that the flow of `AsyncContext.Variable` is widely available in [other languages](./PRIOR-ARTS.md#asynccontext-in-other-languages), 476 | this proposal will focus on this single type of context variable. With this 477 | proposal advancing, `ContinuationFlow` could be reified in a follow-up 478 | proposal. 479 | -------------------------------------------------------------------------------- /FRAMEWORKS.md: -------------------------------------------------------------------------------- 1 | # Frameworks & AsyncContext 2 | 3 | Many popular web frameworks are eagerly waiting on the `AsyncContext` proposal, either to improve experience for their users (application developers) or to reduce footguns. 4 | 5 | This document lists the concrete reasons that different frameworks have for using `AsyncContext`. 6 | 7 | Note: **[client]** and **[server]** markers in messages have been added by the proposal's champions. 8 | 9 | --- 10 | 11 | ## [React](https://react.dev/) 12 | 13 | > **[client]** In React, **transitions** are a feature that allows gracefully coordinating the...transition...of the UI from a start through to and end state while avoiding undesired intermediate states. For example, when navigating to a new route in an application, transitions can be used to temporarily continue to display the previous page with a loading indicator, asynchronously prepare the new page (including loading async resources such as data, images, fonts, etc), and show a crafted sequence of loading screens - while also reverting the loading states at the right times automatically. 14 | > 15 | > **[client+server]** React also supports **actions** which allow submitting data or performing other async writes that also trigger a transition of the UI to a new state. For example, submitting a form and seamlessly transitioning to the page for the entity you just created, without undesirable intermediate states being displayed. 16 | > 17 | > Both of these APIs require React to understand that a series of state changes, executed across asynchronous yield points, should be coordinated together into a single UI transition. The key challenge today is that while React is responsible for the coordination of a transition, the asynchronous code being executed is not only used-defined, but also not necessarily local (ie the transition might be calling into multiple levels of helper functions that actually await or trigger a state change). There is no way for React to automatically thread the right context through user-defined code in today's JavaScript. 18 | > 19 | > For example with a transition: 20 | > 21 | > ```js 22 | > startTransition(async () => { 23 | > await someAsyncFunction(); 24 | > // ❌ Not using startTransition after await 25 | > // React has no way to know this code was originally part of a transition 26 | > // w/o AsyncContext 27 | > setPage('/about'); 28 | > }); 29 | > ``` 30 | > 31 | > Or an action: 32 | > 33 | > ```js 34 | >
{ 35 | > const result = await getResult(); 36 | > // ❌ Not using startTransition after await 37 | > // React has no way to know this code was originally part of a transition 38 | > // w/o AsyncContext 39 | > someFunction(result); // internally calls setState 40 | > }> 41 | > ... 42 | >
43 | > ``` 44 | > 45 | > For completeness sake, the main theoretical alternatives would be for React to: 46 | > 47 | > 1. Require developers to explicitly pass through a context object through all of their async code consumed by React. It would be difficult or impractical to lint against proper usage of this approach, making it easy for developers to forget to pass this value. 48 | > 49 | > 2. Attempt to compile _all_ async/await code that might appear in a React application to automatically pass through the context. Compiling React code for React is one thing, compiling all user code for React is invasive and a non-starter. 50 | > 51 | > That leaves us with needing some built-in way to associate a context across async yield points. Crucially, this is not just a framework concern but something that impacts how users write asynchronous code, since the workarounds are for them to write code differently. We understand that the specific solution here may have performance and/or complexity concerns and are happy to collaborate on alternative implementations if they can provide a similar capability. 52 | > 53 | > — Joseph Savona, React team 54 | 55 | The following is a quote [from the React docs](https://react.dev/reference/react/useTransition#troubleshooting) showing a developer error that is common enough to be included in their documentation, and that would be solved by browsers providing `AsyncContext` support. 56 | 57 | > ### Troubleshooting 58 | > #### React doesn’t treat my state update after `await` as a Transition 59 | > 60 | > **[client]** When you use await inside a startTransition function, the state updates that happen after the await are not marked as Transitions. You must wrap state updates after each await in a startTransition call: 61 | > 62 | > ```javascript 63 | > startTransition(async () => { 64 | > await someAsyncFunction(); 65 | > // ❌ Not using startTransition after await 66 | > setPage('/about'); 67 | > }); 68 | > ``` 69 | > 70 | > However, this works instead: 71 | > 72 | > ```javascript 73 | > startTransition(async () => { 74 | > await someAsyncFunction(); 75 | > // ✅ Using startTransition *after* await 76 | > startTransition(() => { 77 | > setPage('/about'); 78 | > }); 79 | > }); 80 | > ``` 81 | > 82 | > This is a JavaScript limitation due to React losing the scope of the async context. In the future, when AsyncContext is available, this limitation will be removed. 83 | 84 | ## [Solid](https://www.solidjs.com/) 85 | 86 | > We're pretty excited about having standard AsyncContext API. 87 | > 88 | > ### Server 89 | > 90 | > Currently in Solid we use AsyncLocalStorage on the server in SolidStart our metaframework as a means of RequestEvent and ResponseEvent injection. We find this so important it is built into the core of Solid. And leveraged in a few important ways. 91 | > 92 | > First, Our Server Functions (compiled RPCs denoted by `"use server"`) have an isomorphic type safe interface intended to swap into any existing client side API. This means getting the Request in isn't part of the function signature and needs to be injected. 93 | > 94 | > Secondly, we need a mechanism for ecosystem libraries to tap into the request so provide important metadata. For example the Solid Router exporting matches and having a place to put the Response(say to handle redirects on the server) or registering of assets. Its important enough for us to handle this core so we don't fracture the metaframework ecosystem and all libraries can work regardless of which you choose (SolidStart, Tanstack or whatever) 95 | > 96 | > We've manufactured mechanisms for this in the past but they required special wrappers because you can't resume our context on the other side of an `await` when inside user code. 97 | > 98 | > While we have been fortunate that most platforms have polyfilled AsyncLocalStorage it remains difficult for platforms like Stackblitz which is built on webcontainers and is relying on AsyncContext to bring these features to the browser environments. To this day while we run most examples on Stackblitz their capability is greatly reduced impacting it's ability to act as good place to reproduce and build SolidStart projects. 99 | > 100 | > ### Client 101 | > 102 | > Modern JS frameworks work off context/synchronous scope. This is even more pronounced in Signals based frameworks because there is often both the tracking scope (ie collecting dependencies) and the ownership context which collects nested Signals to handle automatic disposal. 103 | > 104 | > Once you go async you lose both contexts. For the most part this is OK. From Solid's perspective tracking is synchronous by design. Some Signals libraries will want to continue tracking after: 105 | > ```javascript 106 | > createEffect(async () => { 107 | > const value1 = inputSignal() // track dep 108 | > const asyncValue = await fetch(value1); 109 | > const value2 = inputSignal2(); // can we track here?? 110 | > const asyncValue2 = await fetch(asyncValue, value2); 111 | > doEffect(asyncValue2) 112 | > }) 113 | > ``` 114 | > 115 | > But ownership would definitely benefit from being able to re-inject our context back in. The potential applications honestly are numerous. `await` in user code without special wrappers, resuming async sequences like in our Transaction or Transition API, pausing and resuming hydration during streaming. 116 | > 117 | > There is a world where we'd just use this mechanism as our core context mechanism. I have performance concerns there which I wouldn't take lightly, but mechanically we are just remaking this in every JavaScript framework and given where things are going I only expect to see more of this. 118 | > 119 | > — Ryan Carniato, Solid maintainer 120 | 121 | ## [Svelte](https://svelte.dev/) 122 | 123 | 129 | 130 | > The Svelte team are eagerly awaiting the day we can use `AsyncContext`. The widespread adoption of `AsyncLocalStorage` across different packages (and runtimes, despite its non-standard status) is clear evidence that real use cases exist; there is no a priori reason to assume that those use cases are restricted to server runtimes, and indeed there are two concrete examples where our hands are currently tied by the lack of this capability in the browser: **[server]** we're introducing a `getRequestEvent` function in SvelteKit that allows functions on the server to read information about the current request context (including things like the requested URL, cookies, headers etc), even if the function isn't called synchronously (which is necessary for it to be generally useful). 131 | > 132 | > **[client]** Ideally we would have a similar function, `getNavigationEvent`, which would apply similarly to client-side navigations; this is currently impossible as reactivity in Svelte is signal-based. The dependencies of a given reaction are determined by noting which signals are read when the reaction executes. We are working on a new asynchronous reactivity model, which requires that dependencies can be tracked even if they are read after the initial execution (for example `

{await a + await b}

` should depend on both `a` and `b`). As a compiler-based framework, we can fudge this by transforming the `await` expressions, but we can only do this in certain contexts, leading to confusing discrepancies. Other frameworks don't even have this option, and must resort to an inferior developer experience instead. 133 | > Given these, and other use cases that we anticipate will emerge, we fully support the AsyncContext proposal. 134 | > 135 | > — Rich Harris, Svelte maintainer 136 | 137 | There are [good examples on Reddit](https://www.reddit.com/r/sveltejs/comments/1gyqf27/svelte_5_runes_async_a_match_made_in_hell/) of Svelte users frustrated because it's not able to preserve context through async operations. 138 | 139 | The missing async support is also explicitly called out [in their documentation](https://svelte.dev/docs/svelte/$effect#Understanding-dependencies): 140 | 141 | > `$effect` automatically picks up any reactive values (`$state`, `$derived`, `$props`) that are synchronously read inside its function body (including indirectly, via function calls) and registers them as dependencies. When those dependencies change, the `$effect` schedules a re-run. 142 | > 143 | > Values that are read _asynchronously_ — after an `await` or inside a `setTimeout`, for example — will not be tracked. Here, the canvas will be repainted when color changes, but not when size changes: 144 | > 145 | > ```javascript 146 | > $effect(() => { 147 | > const context = canvas.getContext('2d'); 148 | > context.clearRect(0, 0, canvas.width, canvas.height); 149 | > 150 | > // this will re-run whenever `color` changes... 151 | > context.fillStyle = color; 152 | > 153 | > setTimeout(() => { 154 | > // ...but not when `size` changes 155 | > context.fillRect(0, 0, size, size); 156 | > }, 0); 157 | > }); 158 | > ``` 159 | 160 | ## [Vue](https://vuejs.org/) 161 | 162 | > AsyncContext is an important feature to have in JavaScript ecosystem. Patterns like singleton is a very common practice in many languages and frameworks. Things that `getCurrentComponent()` relying on a global singleton state work fine by introducing a stack in sync operations, but is becoming very challenging in async flows, there concurrent access to the global state will lead to race conditions. It currently has no workaround in JavaScript without a compiler magic (with a lot of false negatives). 163 | > 164 | > **[client]** Frameworks like Vue provides lifecycle hooks that requires such information, consider Vue 3 support mounting multiple apps at the same time, and some components can be async, the async context race conditions become a headache to us. So that we have to introduce the compiler magic to make it less mental burden to the users. **[server]** Similar stories happen in Nuxt and Nitro on the server side, where the server need to handle concurrent inbound requests, without a proper AsyncContext support, we are also having the risk to leaking information across different requests. 165 | > 166 | > **[client]** As "hooks" is also becoming a very popular API design for many frameworks, including React, Solid, Vue and so on. All these usage would more or less limited by the lack of AsyncContext. Specially since there is no easy runtime workaround/polyfill, I believe it's an essential feature that JavaScript is currently lacking 167 | > 168 | > — Anthony Fu, Vue maintainer 169 | 170 | Vue currently has a transpiler that, at least for async/await, allows [emulating AsyncContext-like behavior](https://github.com/vuejs/core/blob/d48937fb9550ad04b1b28c805ecf68c665112412/packages/runtime-core/src/apiSetupHelpers.ts#L480-L498): 171 | 172 | > We've actually been keeping an eye on that proposal for a while now. We have two very suitable use cases for it: 173 | > 1. **[client]** _Restoring component context in an async setup flow._ 174 | > In Vue, components can have an async `setup()` function that returns a `Promise`. But this creates a trap when using composables (equivalent of React hooks) that require an active component context: 175 | > ```javascript 176 | > useFoo() 177 | > await 1 178 | > useBar() // context lost 179 | > ``` 180 | > Right now we can work around this by doing compiler transforms like this: 181 | > ```javascript 182 | > let __temp, __restore 183 | > 184 | > useFoo() 185 | > // transformed 186 | > ;( 187 | > ([__temp,__restore] = _withAsyncContext(() => 1)), 188 | > await __temp, 189 | > __restore() 190 | > ) 191 | > useBar() 192 | > ``` 193 | > But this only works when there is a build step with Vue single-file components, and does not work in plain JS. `AsyncContext` would allow us to use a native mechanism that works consistently in all cases. 194 | > 2. **[client / devtools]** _Tracking state mutation during async actions in state management._ 195 | > Our official state management lib Pinia (https://pinia.vuejs.org/) has a devtools integration that is able to trace action invocations, but currently there is no way to associate async state mutations to the owner action. A single action may trigger multiple state mutations at different times: 196 | > ```javascript 197 | > actions: { 198 | > async doSomething() { 199 | > // mutation 1 200 | > this.data = await api.post(...) 201 | > // mutation 2 202 | > this.user = await api.get(this.data.id) 203 | > } 204 | > } 205 | > ``` 206 | > We want to be able to link each mutation triggered by `doSomething` to it and visualize it in the devtools. Again currently the only way to do it is compiler-based code instrumentations, but we don't want to add extra overhead to plain JS files. `AsyncContext` would make this easier without relying on compilers. 207 | > 208 | > 209 | > — Evan You, Vue maintainer 210 | 211 | ## Wiz 212 | 213 | > [!WARNING] 214 | > Wiz is a Google-internal framework, not open source. 215 | 216 | > Wiz is a Google-internal web application framework designed to meet the requirements of Google-scale applications. It focuses on performance, supporting lazy code loading for fast user response times and server-side rendering for fast initial page loads. Wiz offers high performance across the widest range of browsers, devices, and connection speeds. 217 | > 218 | > The Wiz team anticipates AsyncContext to be a critical component to instrumenting tracing in our signals-based framework. Tracing has been a top request to help users gain insight into the performance characteristics of specific user interactions. This work is currently underway and it's already evident that AsyncContext allows propagating important contextual information to async APIs that run user-provided callbacks. This is a very common design pattern in the framework and without the ability to propagate data across async boundaries, tracing would not be possible. 219 | 220 | ## [Pruvious](https://pruvious.com/) 221 | 222 | > Pruvious is a CMS built on top of Nuxt. It can operate in a classic Node.js environment as well as in Cloudflare Workers, which run on the V8 engine with a limited subset of Node.js. 223 | > **[server]** I believe that async context is crucial for providing an excellent developer experience in a CMS. Developers need consistent access to the currently logged-in user, the context language, and the request itself. Without this, it would be overwhelmingly complicated for developers to pass the current request event to each function provided by the CMS. This is why async context is heavily utilized in Pruvious. 224 | > **[client]** While this works well on the server side, async context is unfortunately not universal. For instance, Pruvious users (developers) cannot reproduce issues in StackBlitz. If async context were supported in browsers, Pruvious could run in the browser just like Nuxt does. In addition to issue reproduction, I believe that running the CMS in the browser would greatly simplify the learning-by-example process for new users. 225 | > 226 | > — Muris Ceman, Pruvious maintainer 227 | -------------------------------------------------------------------------------- /tests/async-context.test.ts: -------------------------------------------------------------------------------- 1 | import { AsyncContext } from "../src/index"; 2 | import { strict as assert } from "assert"; 3 | 4 | type Value = { id: number }; 5 | 6 | const _it = it; 7 | it = (() => { 8 | throw new Error("use `test` function"); 9 | }) as any; 10 | 11 | // Test both from the initial state, and from a run state. 12 | // This is because the initial state might be "frozen", and 13 | // that can cause different code paths. 14 | function test(name: string, fn: () => void) { 15 | _it(name, () => { 16 | fn(); 17 | 18 | // Ensure we're running from a new state, which won't be frozen. 19 | const throwaway = new AsyncContext.Variable(); 20 | throwaway.run(null, fn); 21 | 22 | throwaway.run(null, () => { 23 | AsyncContext.Snapshot.wrap(() => {}); 24 | 25 | // Ensure we're running from a new state, which is frozen. 26 | fn(); 27 | }); 28 | }); 29 | } 30 | 31 | describe("sync", () => { 32 | describe("run and get", () => { 33 | test("has initial undefined state", () => { 34 | const ctx = new AsyncContext.Variable(); 35 | 36 | const actual = ctx.get(); 37 | 38 | assert.equal(actual, undefined); 39 | }); 40 | 41 | test("return value", () => { 42 | const ctx = new AsyncContext.Variable(); 43 | const expected = { id: 1 }; 44 | 45 | const actual = ctx.run({ id: 2 }, () => expected); 46 | 47 | assert.equal(actual, expected); 48 | }); 49 | 50 | test("get returns current context value", () => { 51 | const ctx = new AsyncContext.Variable(); 52 | const expected = { id: 1 }; 53 | 54 | ctx.run(expected, () => { 55 | assert.equal(ctx.get(), expected); 56 | }); 57 | }); 58 | 59 | test("get within nesting contexts", () => { 60 | const ctx = new AsyncContext.Variable(); 61 | const first = { id: 1 }; 62 | const second = { id: 2 }; 63 | 64 | ctx.run(first, () => { 65 | assert.equal(ctx.get(), first); 66 | ctx.run(second, () => { 67 | assert.equal(ctx.get(), second); 68 | }); 69 | assert.equal(ctx.get(), first); 70 | }); 71 | assert.equal(ctx.get(), undefined); 72 | }); 73 | 74 | test("get within nesting different contexts", () => { 75 | const a = new AsyncContext.Variable(); 76 | const b = new AsyncContext.Variable(); 77 | const first = { id: 1 }; 78 | const second = { id: 2 }; 79 | 80 | a.run(first, () => { 81 | assert.equal(a.get(), first); 82 | assert.equal(b.get(), undefined); 83 | b.run(second, () => { 84 | assert.equal(a.get(), first); 85 | assert.equal(b.get(), second); 86 | }); 87 | assert.equal(a.get(), first); 88 | assert.equal(b.get(), undefined); 89 | }); 90 | assert.equal(a.get(), undefined); 91 | assert.equal(b.get(), undefined); 92 | }); 93 | }); 94 | 95 | describe("wrap", () => { 96 | test("stores initial undefined state", () => { 97 | const ctx = new AsyncContext.Variable(); 98 | const wrapped = AsyncContext.Snapshot.wrap(() => ctx.get()); 99 | 100 | ctx.run({ id: 1 }, () => { 101 | assert.equal(wrapped(), undefined); 102 | }); 103 | }); 104 | 105 | test("stores current state", () => { 106 | const ctx = new AsyncContext.Variable(); 107 | const expected = { id: 1 }; 108 | 109 | const wrap = ctx.run(expected, () => { 110 | const wrap = AsyncContext.Snapshot.wrap(() => ctx.get()); 111 | assert.equal(wrap(), expected); 112 | assert.equal(ctx.get(), expected); 113 | return wrap; 114 | }); 115 | 116 | assert.equal(wrap(), expected); 117 | assert.equal(ctx.get(), undefined); 118 | }); 119 | 120 | test("runs within wrap", () => { 121 | const ctx = new AsyncContext.Variable(); 122 | const first = { id: 1 }; 123 | const second = { id: 2 }; 124 | 125 | const [wrap1, wrap2] = ctx.run(first, () => { 126 | const wrap1 = AsyncContext.Snapshot.wrap(() => { 127 | assert.equal(ctx.get(), first); 128 | 129 | ctx.run(second, () => { 130 | assert.equal(ctx.get(), second); 131 | }); 132 | 133 | assert.equal(ctx.get(), first); 134 | }); 135 | assert.equal(ctx.get(), first); 136 | 137 | ctx.run(second, () => { 138 | assert.equal(ctx.get(), second); 139 | }); 140 | 141 | const wrap2 = AsyncContext.Snapshot.wrap(() => { 142 | assert.equal(ctx.get(), first); 143 | 144 | ctx.run(second, () => { 145 | assert.equal(ctx.get(), second); 146 | }); 147 | 148 | assert.equal(ctx.get(), first); 149 | }); 150 | assert.equal(ctx.get(), first); 151 | return [wrap1, wrap2]; 152 | }); 153 | 154 | wrap1(); 155 | wrap2(); 156 | assert.equal(ctx.get(), undefined); 157 | }); 158 | 159 | test("runs within wrap", () => { 160 | const ctx = new AsyncContext.Variable(); 161 | const first = { id: 1 }; 162 | const second = { id: 2 }; 163 | 164 | const [wrap1, wrap2] = ctx.run(first, () => { 165 | const wrap1 = AsyncContext.Snapshot.wrap(() => { 166 | assert.equal(ctx.get(), first); 167 | 168 | ctx.run(second, () => { 169 | assert.equal(ctx.get(), second); 170 | }); 171 | 172 | assert.equal(ctx.get(), first); 173 | }); 174 | assert.equal(ctx.get(), first); 175 | 176 | ctx.run(second, () => { 177 | assert.equal(ctx.get(), second); 178 | }); 179 | 180 | const wrap2 = AsyncContext.Snapshot.wrap(() => { 181 | assert.equal(ctx.get(), first); 182 | 183 | ctx.run(second, () => { 184 | assert.equal(ctx.get(), second); 185 | }); 186 | 187 | assert.equal(ctx.get(), first); 188 | }); 189 | assert.equal(ctx.get(), first); 190 | return [wrap1, wrap2]; 191 | }); 192 | 193 | wrap1(); 194 | wrap2(); 195 | assert.equal(ctx.get(), undefined); 196 | }); 197 | 198 | test("runs different context within wrap", () => { 199 | const a = new AsyncContext.Variable(); 200 | const b = new AsyncContext.Variable(); 201 | const first = { id: 1 }; 202 | const second = { id: 2 }; 203 | 204 | const [wrap1, wrap2] = a.run(first, () => { 205 | const wrap1 = AsyncContext.Snapshot.wrap(() => { 206 | assert.equal(a.get(), first); 207 | assert.equal(b.get(), undefined); 208 | 209 | b.run(second, () => { 210 | assert.equal(a.get(), first); 211 | assert.equal(b.get(), second); 212 | }); 213 | 214 | assert.equal(a.get(), first); 215 | assert.equal(b.get(), undefined); 216 | }); 217 | 218 | a.run(second, () => {}); 219 | 220 | const wrap2 = AsyncContext.Snapshot.wrap(() => { 221 | assert.equal(a.get(), first); 222 | assert.equal(b.get(), undefined); 223 | 224 | b.run(second, () => { 225 | assert.equal(a.get(), first); 226 | assert.equal(b.get(), second); 227 | }); 228 | 229 | assert.equal(a.get(), first); 230 | assert.equal(b.get(), undefined); 231 | }); 232 | 233 | assert.equal(a.get(), first); 234 | assert.equal(b.get(), undefined); 235 | return [wrap1, wrap2]; 236 | }); 237 | 238 | wrap1(); 239 | wrap2(); 240 | assert.equal(a.get(), undefined); 241 | assert.equal(b.get(), undefined); 242 | }); 243 | 244 | test("runs different context within wrap, 2", () => { 245 | const a = new AsyncContext.Variable(); 246 | const b = new AsyncContext.Variable(); 247 | const first = { id: 1 }; 248 | const second = { id: 2 }; 249 | 250 | const [wrap1, wrap2] = a.run(first, () => { 251 | const wrap1 = AsyncContext.Snapshot.wrap(() => { 252 | assert.equal(a.get(), first); 253 | assert.equal(b.get(), undefined); 254 | 255 | b.run(second, () => { 256 | assert.equal(a.get(), first); 257 | assert.equal(b.get(), second); 258 | }); 259 | 260 | assert.equal(a.get(), first); 261 | assert.equal(b.get(), undefined); 262 | }); 263 | 264 | b.run(second, () => {}); 265 | 266 | const wrap2 = AsyncContext.Snapshot.wrap(() => { 267 | assert.equal(a.get(), first); 268 | assert.equal(b.get(), undefined); 269 | 270 | b.run(second, () => { 271 | assert.equal(a.get(), first); 272 | assert.equal(b.get(), second); 273 | }); 274 | 275 | assert.equal(a.get(), first); 276 | assert.equal(b.get(), undefined); 277 | }); 278 | 279 | assert.equal(a.get(), first); 280 | assert.equal(b.get(), undefined); 281 | return [wrap1, wrap2]; 282 | }); 283 | 284 | wrap1(); 285 | wrap2(); 286 | assert.equal(a.get(), undefined); 287 | assert.equal(b.get(), undefined); 288 | }); 289 | 290 | test("wrap within nesting contexts", () => { 291 | const ctx = new AsyncContext.Variable(); 292 | const first = { id: 1 }; 293 | const second = { id: 2 }; 294 | 295 | const [firstWrap, secondWrap] = ctx.run(first, () => { 296 | const firstWrap = AsyncContext.Snapshot.wrap(() => { 297 | assert.equal(ctx.get(), first); 298 | }); 299 | firstWrap(); 300 | 301 | const secondWrap = ctx.run(second, () => { 302 | const secondWrap = AsyncContext.Snapshot.wrap(() => { 303 | firstWrap(); 304 | assert.equal(ctx.get(), second); 305 | }); 306 | firstWrap(); 307 | secondWrap(); 308 | assert.equal(ctx.get(), second); 309 | 310 | return secondWrap; 311 | }); 312 | 313 | firstWrap(); 314 | secondWrap(); 315 | assert.equal(ctx.get(), first); 316 | 317 | return [firstWrap, secondWrap]; 318 | }); 319 | 320 | firstWrap(); 321 | secondWrap(); 322 | assert.equal(ctx.get(), undefined); 323 | }); 324 | 325 | test("wrap within nesting different contexts", () => { 326 | const a = new AsyncContext.Variable(); 327 | const b = new AsyncContext.Variable(); 328 | const first = { id: 1 }; 329 | const second = { id: 2 }; 330 | 331 | const [firstWrap, secondWrap] = a.run(first, () => { 332 | const firstWrap = AsyncContext.Snapshot.wrap(() => { 333 | assert.equal(a.get(), first); 334 | assert.equal(b.get(), undefined); 335 | }); 336 | firstWrap(); 337 | 338 | const secondWrap = b.run(second, () => { 339 | const secondWrap = AsyncContext.Snapshot.wrap(() => { 340 | firstWrap(); 341 | assert.equal(a.get(), first); 342 | assert.equal(b.get(), second); 343 | }); 344 | 345 | firstWrap(); 346 | secondWrap(); 347 | assert.equal(a.get(), first); 348 | assert.equal(b.get(), second); 349 | 350 | return secondWrap; 351 | }); 352 | 353 | firstWrap(); 354 | secondWrap(); 355 | assert.equal(a.get(), first); 356 | assert.equal(b.get(), undefined); 357 | 358 | return [firstWrap, secondWrap]; 359 | }); 360 | 361 | firstWrap(); 362 | secondWrap(); 363 | assert.equal(a.get(), undefined); 364 | assert.equal(b.get(), undefined); 365 | }); 366 | 367 | test("wrap within nesting different contexts, 2", () => { 368 | const a = new AsyncContext.Variable(); 369 | const b = new AsyncContext.Variable(); 370 | const c = new AsyncContext.Variable(); 371 | const first = { id: 1 }; 372 | const second = { id: 2 }; 373 | const third = { id: 3 }; 374 | 375 | const wrap = a.run(first, () => { 376 | const wrap = b.run(second, () => { 377 | const wrap = c.run(third, () => { 378 | return AsyncContext.Snapshot.wrap(() => { 379 | assert.equal(a.get(), first); 380 | assert.equal(b.get(), second); 381 | assert.equal(c.get(), third); 382 | }); 383 | }); 384 | assert.equal(a.get(), first); 385 | assert.equal(b.get(), second); 386 | assert.equal(c.get(), undefined); 387 | return wrap; 388 | }); 389 | assert.equal(a.get(), first); 390 | assert.equal(b.get(), undefined); 391 | assert.equal(c.get(), undefined); 392 | 393 | return wrap; 394 | }); 395 | 396 | assert.equal(a.get(), undefined); 397 | assert.equal(b.get(), undefined); 398 | assert.equal(c.get(), undefined); 399 | wrap(); 400 | assert.equal(a.get(), undefined); 401 | assert.equal(b.get(), undefined); 402 | assert.equal(c.get(), undefined); 403 | }); 404 | 405 | test("wrap within nesting different contexts, 3", () => { 406 | const a = new AsyncContext.Variable(); 407 | const b = new AsyncContext.Variable(); 408 | const c = new AsyncContext.Variable(); 409 | const first = { id: 1 }; 410 | const second = { id: 2 }; 411 | const third = { id: 3 }; 412 | 413 | const wrap = a.run(first, () => { 414 | const wrap = b.run(second, () => { 415 | AsyncContext.Snapshot.wrap(() => {}); 416 | 417 | const wrap = c.run(third, () => { 418 | return AsyncContext.Snapshot.wrap(() => { 419 | assert.equal(a.get(), first); 420 | assert.equal(b.get(), second); 421 | assert.equal(c.get(), third); 422 | }); 423 | }); 424 | assert.equal(a.get(), first); 425 | assert.equal(b.get(), second); 426 | assert.equal(c.get(), undefined); 427 | return wrap; 428 | }); 429 | assert.equal(a.get(), first); 430 | assert.equal(b.get(), undefined); 431 | assert.equal(c.get(), undefined); 432 | 433 | return wrap; 434 | }); 435 | 436 | assert.equal(a.get(), undefined); 437 | assert.equal(b.get(), undefined); 438 | assert.equal(c.get(), undefined); 439 | wrap(); 440 | assert.equal(a.get(), undefined); 441 | assert.equal(b.get(), undefined); 442 | assert.equal(c.get(), undefined); 443 | }); 444 | 445 | test("wrap within nesting different contexts, 4", () => { 446 | const a = new AsyncContext.Variable(); 447 | const b = new AsyncContext.Variable(); 448 | const c = new AsyncContext.Variable(); 449 | const first = { id: 1 }; 450 | const second = { id: 2 }; 451 | const third = { id: 3 }; 452 | 453 | const wrap = a.run(first, () => { 454 | AsyncContext.Snapshot.wrap(() => {}); 455 | 456 | const wrap = b.run(second, () => { 457 | const wrap = c.run(third, () => { 458 | return AsyncContext.Snapshot.wrap(() => { 459 | assert.equal(a.get(), first); 460 | assert.equal(b.get(), second); 461 | assert.equal(c.get(), third); 462 | }); 463 | }); 464 | assert.equal(a.get(), first); 465 | assert.equal(b.get(), second); 466 | assert.equal(c.get(), undefined); 467 | return wrap; 468 | }); 469 | assert.equal(a.get(), first); 470 | assert.equal(b.get(), undefined); 471 | assert.equal(c.get(), undefined); 472 | 473 | return wrap; 474 | }); 475 | 476 | assert.equal(a.get(), undefined); 477 | assert.equal(b.get(), undefined); 478 | assert.equal(c.get(), undefined); 479 | wrap(); 480 | assert.equal(a.get(), undefined); 481 | assert.equal(b.get(), undefined); 482 | assert.equal(c.get(), undefined); 483 | }); 484 | 485 | test("wrap within nesting different contexts, 5", () => { 486 | const a = new AsyncContext.Variable(); 487 | const b = new AsyncContext.Variable(); 488 | const c = new AsyncContext.Variable(); 489 | const first = { id: 1 }; 490 | const second = { id: 2 }; 491 | const third = { id: 3 }; 492 | 493 | const wrap = a.run(first, () => { 494 | const wrap = b.run(second, () => { 495 | const wrap = c.run(third, () => { 496 | return AsyncContext.Snapshot.wrap(() => { 497 | assert.equal(a.get(), first); 498 | assert.equal(b.get(), second); 499 | assert.equal(c.get(), third); 500 | }); 501 | }); 502 | 503 | AsyncContext.Snapshot.wrap(() => {}); 504 | 505 | assert.equal(a.get(), first); 506 | assert.equal(b.get(), second); 507 | assert.equal(c.get(), undefined); 508 | return wrap; 509 | }); 510 | assert.equal(a.get(), first); 511 | assert.equal(b.get(), undefined); 512 | assert.equal(c.get(), undefined); 513 | 514 | return wrap; 515 | }); 516 | 517 | assert.equal(a.get(), undefined); 518 | assert.equal(b.get(), undefined); 519 | assert.equal(c.get(), undefined); 520 | wrap(); 521 | assert.equal(a.get(), undefined); 522 | assert.equal(b.get(), undefined); 523 | assert.equal(c.get(), undefined); 524 | }); 525 | 526 | test("wrap within nesting different contexts, 6", () => { 527 | const a = new AsyncContext.Variable(); 528 | const b = new AsyncContext.Variable(); 529 | const c = new AsyncContext.Variable(); 530 | const first = { id: 1 }; 531 | const second = { id: 2 }; 532 | const third = { id: 3 }; 533 | 534 | const wrap = a.run(first, () => { 535 | const wrap = b.run(second, () => { 536 | const wrap = c.run(third, () => { 537 | return AsyncContext.Snapshot.wrap(() => { 538 | assert.equal(a.get(), first); 539 | assert.equal(b.get(), second); 540 | assert.equal(c.get(), third); 541 | }); 542 | }); 543 | assert.equal(a.get(), first); 544 | assert.equal(b.get(), second); 545 | assert.equal(c.get(), undefined); 546 | return wrap; 547 | }); 548 | 549 | AsyncContext.Snapshot.wrap(() => {}); 550 | 551 | assert.equal(a.get(), first); 552 | assert.equal(b.get(), undefined); 553 | assert.equal(c.get(), undefined); 554 | 555 | return wrap; 556 | }); 557 | 558 | assert.equal(a.get(), undefined); 559 | assert.equal(b.get(), undefined); 560 | assert.equal(c.get(), undefined); 561 | wrap(); 562 | assert.equal(a.get(), undefined); 563 | assert.equal(b.get(), undefined); 564 | assert.equal(c.get(), undefined); 565 | }); 566 | 567 | test("wrap out of order", () => { 568 | const ctx = new AsyncContext.Variable(); 569 | const first = { id: 1 }; 570 | const second = { id: 2 }; 571 | 572 | const firstWrap = ctx.run(first, () => { 573 | return AsyncContext.Snapshot.wrap(() => { 574 | assert.equal(ctx.get(), first); 575 | }); 576 | }); 577 | const secondWrap = ctx.run(second, () => { 578 | return AsyncContext.Snapshot.wrap(() => { 579 | assert.equal(ctx.get(), second); 580 | }); 581 | }); 582 | 583 | firstWrap(); 584 | secondWrap(); 585 | firstWrap(); 586 | secondWrap(); 587 | }); 588 | }); 589 | }); 590 | -------------------------------------------------------------------------------- /PRIOR-ARTS.md: -------------------------------------------------------------------------------- 1 | # Prior Arts 2 | 3 | AsyncContext-like API exists in languages/runtimes that support `await` syntax or coroutines. 4 | 5 | The following table shows a general landscape of how the API behaves in these languages/runtimes. 6 | 7 | | Language / API | Continuation feedback | Mutation Scope | 8 | | ---------------------------- | ----------------------- | --------------------- | 9 | | dotnet `AsyncLocal` | No implicit feedback | In scope mutation | 10 | | dotnet `CallContext` | No implicit feedback | In scope mutation | 11 | | Go `context` | No implicit feedback | In scope mutation | 12 | | Python `ContextVar` | Both available | In scope mutation | 13 | | Ruby `Fiber` | No implicit feedback | In scope mutation | 14 | | Rust `tokio::task_local` | No implicit feedback | New scope mutation | 15 | | Dart `Zone` | No implicit feedback | New scope mutation | 16 | | JS `Zone` | No implicit feedback | New scope mutation | 17 | | Node.js `AsyncLocalStorage` | No implicit feedback | Both available | 18 | 19 | Explanation: 20 | * [Continuation feedback](./CONTINUATION.md) 21 | * No implicit feedback: `await`, or passing context to subtasks, does not feedback mutations to the caller continuation. 22 | * Both available: `await` may and may not feedback mutations to the caller continuation. 23 | * [Mutation scope](./MUTATION-SCOPE.md) 24 | * In scope mutation: `set` does not require a new function scope, and can modify in scope. 25 | * `async function`-like syntax in these languages usually implies a scope. 26 | * New scope mutation: `set` requires a new function scope. 27 | * Both available. 28 | * Node.js has an experimental `AsyncLocalStorage.enterWith` that mutates in scope. `async function` in JavaScript does not imply a mutation scope. 29 | 30 | ## AsyncContext in other languages 31 | 32 | ### dotnet 33 | 34 | C# on .Net runtime provides syntax support of `async`/`await`, with [`AsyncLocal`][] 35 | and [`CallContext`][] to propagate context variables. 36 | 37 | Additional to `AsyncLocal`'s in-process propagation, `CallContext` also supports propagating 38 | context variables via remote procedure calls. So `CallContext` API requires extra 39 | [security grants](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.remoting.messaging.callcontext?view=netframework-4.8.1#remarks). 40 | 41 | > Test it yourself: [dotnet fiddle](https://dotnetfiddle.net/Sx9ukw). 42 | 43 | ```csharp 44 | using System; 45 | using System.Threading; 46 | using System.Threading.Tasks; 47 | using System.Collections.Generic; 48 | 49 | public class Program 50 | { 51 | static AsyncLocal _asyncLocal = new AsyncLocal(); 52 | static async Task AsyncMain() 53 | { 54 | _asyncLocal.Value = "main"; 55 | var t1 = AsyncTask("task 1", 200); 56 | Console.WriteLine("Called AsyncTask 1."); 57 | Console.WriteLine(" AsyncLocal value is '{0}'", _asyncLocal.Value); 58 | var t2 = AsyncTask("task 2", 100); 59 | Console.WriteLine("Called AsyncTask 2."); 60 | Console.WriteLine(" AsyncLocal value is '{0}'", _asyncLocal.Value); 61 | 62 | await Task.WhenAll(new List{ t1, t2 }); 63 | Console.WriteLine("Awaited tasks."); 64 | Console.WriteLine(" AsyncLocal value is '{0}'", _asyncLocal.Value); 65 | } 66 | 67 | static async Task AsyncTask(string expectedValue, Int32 delay) 68 | { 69 | _asyncLocal.Value = expectedValue; 70 | await Task.Delay(delay); 71 | Console.WriteLine("In AsyncTask, expect '{0}'", expectedValue); 72 | Console.WriteLine(" AsyncLocal value is '{0}'", _asyncLocal.Value); 73 | } 74 | 75 | public static void Main() 76 | { 77 | AsyncMain().Wait(); 78 | } 79 | } 80 | ``` 81 | 82 | 83 | This prints: 84 | 85 | ```console 86 | Called AsyncTask 1. 87 | AsyncLocal value is 'main' 88 | Called AsyncTask 2. 89 | AsyncLocal value is 'main' 90 | In AsyncTask, expect 'task 2' 91 | AsyncLocal value is 'task 2' 92 | In AsyncTask, expect 'task 1' 93 | AsyncLocal value is 'task 1' 94 | Awaited tasks. 95 | AsyncLocal value is 'main' 96 | ``` 97 | 98 | From the result, we can tell that: 99 | - `AsyncLocal` can be modified with assignment, without an extra scope. 100 | - Modification in a child task does not propagate to its sibling tasks. 101 | - Modification to an `AsyncLocal` does not propagate to the caller continuation, i.e. `await` in caller. 102 | 103 | ### Go 104 | 105 | Go is famous for its deep coroutine integration in the language. As such, is has a conventional 106 | context propagation mechanism: by always manual passing the context as the first argument of a 107 | function. 108 | 109 | Go provides a package [`context`](https://pkg.go.dev/context) for combining arbitrary values into 110 | a single `Context` opaque bag, so that multiple values can be passed as the first argument of a 111 | function. 112 | 113 | > Test it yourself: [Go Playground](https://go.dev/play/p/F-CvnEBZy2Z). 114 | 115 | ```go 116 | package main 117 | 118 | import ( 119 | "context" 120 | "fmt" 121 | ) 122 | 123 | func inner_fn(ctx context.Context) context.Context { 124 | // Context is immutable. Modifying a context creates a new context. 125 | ctx = context.WithValue(ctx, "FooKey", "inner") 126 | // Return it explicitly so that modification can be observable from parent scope. 127 | return ctx 128 | } 129 | 130 | func main() { 131 | ctx := context.WithValue(context.Background(), "FooKey", "main") 132 | inner := inner_fn(ctx) 133 | 134 | fmt.Println("main:", ctx.Value("FooKey")) 135 | fmt.Println("inner:", inner.Value("FooKey")) 136 | } 137 | ``` 138 | 139 | This prints: 140 | 141 | ```console 142 | main: main 143 | inner: inner 144 | ``` 145 | 146 | From go's `context` API, we can tell that: 147 | - `Context` is immutable, and modification creates a new `Context`. 148 | - Modification in a child task does not propagate to its sibling tasks implicitly. 149 | - Modification to a `Context` does not propagate to the caller continuation, i.e. caller's context. 150 | 151 | ### Python 152 | 153 | Python's [`contextvars.ContextVar`](https://docs.python.org/3/library/contextvars.html#context-variables) 154 | provides the ability to propagate context variables. 155 | 156 | ```python 157 | import asyncio 158 | from contextvars import ContextVar 159 | 160 | current_task = ContextVar('current_task') 161 | 162 | async def foo(): 163 | print("foo task parent:", current_task.get()) 164 | current_task.set("foo") 165 | await asyncio.sleep(2) 166 | print("foo task:", current_task.get()) 167 | 168 | async def bar(): 169 | print("bar task parent:", current_task.get()) 170 | current_task.set("bar") 171 | await asyncio.sleep(1) 172 | print("bar task:", current_task.get()) 173 | 174 | async def main(): 175 | current_task.set("main") 176 | 177 | await asyncio.gather( 178 | foo(), 179 | bar(), 180 | ) 181 | print("after gather:", current_task.get()) 182 | 183 | loop = asyncio.get_event_loop() 184 | loop.run_until_complete(main()) 185 | ``` 186 | 187 | This prints: 188 | 189 | ```console 190 | foo task parent: main 191 | bar task parent: main 192 | bar task: bar 193 | foo task: foo 194 | after gather: main 195 | ``` 196 | 197 | From the result, we can tell that: 198 | - `ContextVar` can be modified with `set` method, without an extra scope. 199 | - Modification in a child task does not propagate to its sibling tasks. 200 | - Modification to an `ContextVar` does not propagate to the caller continuation, i.e. `await` in caller. 201 | 202 | This is the default `asyncio` scheduling behavior. Additional to `ContextVar`, 203 | the `contextvars` package even allow manual context management in Python. This allows userland 204 | scheduler to customize the propagation behavior around `await` with `context.copy` 205 | and `context.run`. So, if a user run `context.run` without `asyncio` on an awaitable object, 206 | it can achieve the following behavior: 207 | 208 | ```python 209 | import asyncio 210 | import contextvars 211 | from contextvars import ContextVar 212 | 213 | current_task = ContextVar('current_task') 214 | 215 | async def foo(): 216 | print("foo task parent:", current_task.get()) 217 | current_task.set("foo") 218 | await asyncio.sleep(1) 219 | print("foo task:", current_task.get()) 220 | 221 | async def main(): 222 | current_task.set("main") 223 | 224 | ctx = contextvars.copy_context() 225 | await ctx.run(foo) 226 | print("after await:", current_task.get()) 227 | 228 | loop = asyncio.get_event_loop() 229 | loop.run_until_complete(main()) 230 | ``` 231 | 232 | This prints: 233 | 234 | ```console 235 | foo task parent: main 236 | foo task: foo 237 | after await: foo 238 | ``` 239 | 240 | This allows userland schedulers to implement different context propagation than the 241 | `asyncio`'s default one. 242 | 243 | ### Ruby 244 | 245 | Although Ruby's [Fiber](https://docs.ruby-lang.org/en/3.4/Fiber.html) does not provide a default 246 | scheduler, it provides a bracket accessor to get/set context variables, like 247 | `AsyncContext.Variable` does. 248 | 249 | > Test it yourself: [Ruby Playground](https://try.ruby-lang.org/playground/#code=def+main%0A++%23+Fiber+coroutine%0A++Fiber%5B%3Afoo%5D+%3D+%22main%22%0A++f1+%3D+Fiber.new+do%0A++++puts+%22inner+1+parent%3A+%23%7BFiber%5B%3Afoo%5D%7D%22%0A++++Fiber%5B%3Afoo%5D+%3D+%221%22%0A++++Fiber.current.storage%0A++end%0A%09f2+%3D+Fiber.new+do%0A++++puts+%22inner+2+parent%3A+%23%7BFiber%5B%3Afoo%5D%7D%22%0A++++Fiber%5B%3Afoo%5D+%3D+%222%22%0A++++Fiber.current.storage%0A++end%0A++inner_ctx1+%3D+f1.resume%0A++inner_ctx2+%3D+f2.resume%0A++puts+%22main+%23%7BFiber%5B%3Afoo%5D%7D%22%0A++puts+%22inner+1+%23%7Binner_ctx1%5B%3Afoo%5D%7D%22%0A++puts+%22inner+2+%23%7Binner_ctx2%5B%3Afoo%5D%7D%22%0Aend%0A%0AFiber.new+do%0A++main%0Aend.resume&engine=cruby-3.3.0). 250 | 251 | ```ruby 252 | def main 253 | # Fiber coroutine 254 | Fiber[:foo] = "main" 255 | f1 = Fiber.new do 256 | puts "inner 1 parent: #{Fiber[:foo]}" 257 | Fiber[:foo] = "1" 258 | Fiber.current.storage 259 | end 260 | f2 = Fiber.new do 261 | puts "inner 2 parent: #{Fiber[:foo]}" 262 | Fiber[:foo] = "2" 263 | Fiber.current.storage 264 | end 265 | inner_ctx1 = f1.resume 266 | inner_ctx2 = f2.resume 267 | puts "main #{Fiber[:foo]}" 268 | puts "inner 1 #{inner_ctx1[:foo]}" 269 | puts "inner 2 #{inner_ctx2[:foo]}" 270 | end 271 | 272 | Fiber.new do 273 | main 274 | end.resume 275 | ``` 276 | 277 | This prints: 278 | 279 | ```console 280 | inner 1 parent: main 281 | inner 2 parent: main 282 | main main 283 | inner 1 1 284 | inner 2 2 285 | ``` 286 | 287 | From the result, we can tell that: 288 | - `Fiber` context variables can be modified with bracket assignment, without an extra scope. 289 | - Modification in a child task does not propagates to its sibling tasks. 290 | - Modification to a `Fiber` does not propagate to the caller continuation, i.e. `Fiber.resume` in caller. 291 | 292 | ### Rust 293 | 294 | Rust only provides [`thread_local`](https://doc.rust-lang.org/std/macro.thread_local.html) in 295 | the `std` crate. [`tokio.rs`](https://tokio.rs/) is a popular Rust asynchronous applications 296 | runtime that provides a [`task_local`](https://tikv.github.io/doc/tokio/macro.task_local.html), 297 | which is similar to `AsyncContext.Variable`. 298 | 299 | > Test it yourself: [Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=4fe346a17d62c20e2574a76cb5f99cc0). 300 | 301 | ```rust 302 | use tokio::time::{sleep, Duration}; 303 | 304 | tokio::task_local! { 305 | static FOO: &'static str; 306 | } 307 | 308 | #[tokio::main] 309 | async fn main() { 310 | FOO.scope("foo", async move { 311 | println!("main {}", FOO.get()); 312 | 313 | let t1 = FOO.scope("inner1", async move { 314 | sleep(Duration::from_millis(200)).await; 315 | println!("inner1: {}", FOO.get()); 316 | }); 317 | let t2 = FOO.scope("inner2", async move { 318 | sleep(Duration::from_millis(100)).await; 319 | println!("inner2: {}", FOO.get()); 320 | }); 321 | futures::join!(t1, t2); 322 | println!("main {}", FOO.get()); 323 | }).await; 324 | } 325 | ``` 326 | 327 | This prints: 328 | ```console 329 | main foo 330 | inner2: inner2 331 | inner1: inner1 332 | main foo 333 | ``` 334 | 335 | From the tokio API, and the result, we can tell that: 336 | - `task_local` can be only be modified with a `sync_scope` or a `scope`. 337 | - Modification in a child task does not propagates to its sibling tasks. 338 | - Modification to a `task_local` does not propagate to the caller continuation, i.e. `await` in caller. 339 | 340 | ### Dart 341 | 342 | Dart's [Zone](https://api.dart.dev/dart-async/Zone-class.html) provides much more functionality 343 | than the `AsyncContext.Variable` in this proposal. `Zone` covers the necessary propagation of 344 | values that `AsyncContext.Variable` provides. 345 | 346 | > Test it yourself: [DartPad](https://dartpad.dev/?id=76faca6b45df2a05f1bbc7ae7cbbf4c6). 347 | 348 | ```dart 349 | import 'dart:async'; 350 | 351 | void main() async { 352 | await runZoned(() async { 353 | var task1 = runZoned(() async { 354 | await Future.delayed(Duration(seconds: 2)); 355 | print("Task 1: ${Zone.current[#task]}"); 356 | }, zoneValues: { #task: 'task1' }); 357 | 358 | var task2 = runZoned(() async { 359 | await Future.delayed(Duration(seconds: 1)); 360 | print("Task 2: ${Zone.current[#task]}"); 361 | }, zoneValues: { #task: 'task2' }); 362 | 363 | await Future.wait({ task1, task2 }); 364 | print("main : ${Zone.current[#task]}"); 365 | }, zoneValues: { #task: 'main' }); 366 | } 367 | ``` 368 | 369 | This prints: 370 | 371 | ```console 372 | Task 2: task2 373 | Task 1: task1 374 | main : main 375 | ``` 376 | 377 | From the Dart Zone API, and the result, we can tell that: 378 | - `Zone` can be only be modified with a new function scope. 379 | - Modification in a child task does not propagates to its sibling tasks. 380 | - Modification to an `Zone` does not propagate to the caller continuation, i.e. `await` in caller. 381 | 382 | ## AsyncContext in real world 383 | 384 | ### OpenTelemetry 385 | 386 | > Test it yourself: [OpenTelemetry Demo](https://opentelemetry.io/docs/demo/docker-deployment/). 387 | > This demo includes more than 10+ services and covers most popular programming languages. 388 | 389 | Even though each language or runtime provides different shapes of async context variable 390 | API, OpenTelemetry standardized how the tracing context should be like in OpenTelemetry 391 | implementations. 392 | 393 | The [OpenTelemetry Context Specification](https://github.com/open-telemetry/opentelemetry-specification/tree/main/specification/context) 394 | requires that each write operation to a `Context` must result in the creation of a new `Context`. 395 | This eliminates the confusion could be caused by language context APIs that if a mutation 396 | happens after an async operation, if the mutation can be observed by prior async operations. 397 | 398 | This requirement asserts that mutation in a child scope can not be propagated to its immutable 399 | caller continuation as well. 400 | 401 | The following list shows the underlying language constructs of each OpenTelemetry language SDK: 402 | 403 | - JavaScript: OpenTelemetry JS provides both web (`zone.js` based) and Node.js context implementations: 404 | - [AsyncLocalStorageContextManager](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-context-async-hooks). 405 | - [ZoneContextManager](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-context-zone-peer-dep). 406 | - dotnet: OpenTelemetry dotnet provides both [`AsyncLocal`][] based and [`CallContext`][] based context implementations. 407 | - [OpenTelemetry.Context.AsyncLocalRuntimeContextSlot](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/src/OpenTelemetry.Api/Context/AsyncLocalRuntimeContextSlot.cs). 408 | - [OpenTelemetry.Context.RemotingRuntimeContextSlot](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/src/OpenTelemetry.Api/Context/RemotingRuntimeContextSlot.cs). 409 | - Go: uses [go.context](https://pkg.go.dev/context) directly. 410 | - Python [ContextVarsRuntimeContext](https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-api/src/opentelemetry/context/contextvars_context.py). 411 | - Ruby [Context](https://github.com/open-telemetry/opentelemetry-ruby/blob/main/api/lib/opentelemetry/context.rb), based on Ruby's [Fiber](#ruby). 412 | - Rust [Context](https://github.com/open-telemetry/opentelemetry-rust/blob/main/opentelemetry/src/context.rs), does not support tokio yet. 413 | - Swift: 414 | - [ActivityContextManager](https://github.com/open-telemetry/opentelemetry-swift/blob/main/Sources/OpenTelemetryApi/Context/ActivityContextManager.swift). 415 | - [TaskLocalContextManager](https://github.com/open-telemetry/opentelemetry-swift/blob/main/Sources/OpenTelemetryApi/Context/TaskLocalContextManager.swift). 416 | 417 | ## JavaScript prior arts 418 | 419 | ### Node.js AsyncLocalStorage 420 | 421 | Node.js provides a stable API [`AsyncLocalStorage`][] that supports implicit context propagation 422 | across `await` and runtime APIs. 423 | 424 | ```typescript 425 | class AsyncLocalStorage { 426 | static bind(fn: T): T; 427 | static snapshot(): () => void; 428 | 429 | constructor(); 430 | 431 | getStore(): ValueType; 432 | 433 | run>(store: ValueType, callback: T, ...args: never[]): ReturnType; 434 | 435 | /** @experimental */ 436 | enterWith(store: ValueType); 437 | } 438 | ``` 439 | 440 | The `AsyncContext.Variable` is significantly inspired by `AsyncLocalStorage`. However, 441 | `AsyncContext.Variable` only provides an essential subset of `AsyncLocalStorage`, 442 | with a follow-up extension for set semantic with scope enforcement 443 | like `using _ = asyncVar.withValue(val)`, as described in 444 | [mutation-scope.md](./MUTATION-SCOPE.md#the-set-semantic-with-scope-enforcement). 445 | 446 | Additionally, as `AsyncContext.Variable` is built in the language, it also 447 | support language constructs like (async) generators. 448 | 449 | ### zones.js 450 | 451 | [`zone.js`][] provides a `Zone` object, which has the following API: 452 | 453 | ```typescript 454 | class Zone { 455 | constructor({ name, parent }); 456 | 457 | name; 458 | get parent(); 459 | 460 | fork({ name }); 461 | run(callback); 462 | wrap(callback); 463 | 464 | static get current(); 465 | } 466 | ``` 467 | 468 | The concept of the _current zone_, reified as `Zone.current`, is crucial. Both 469 | `run` and `wrap` are designed to manage running the current zone: 470 | 471 | - `z.run(callback)` will set the current zone to `z` for the duration of 472 | `callback`, resetting it to its previous value afterward. This is how you 473 | "enter" a zone. 474 | - `z.wrap(callback)` produces a new function that essentially performs 475 | `z.run(callback)` (passing along arguments and this, of course). 476 | 477 | The _current zone_ is the async context that propagates with all our operations. 478 | In our above example, sites `(1)` through `(6)` would all have the same value of 479 | `Zone.current`. If a developer had done something like: 480 | 481 | ```typescript 482 | const loadZone = Zone.current.fork({ name: "loading zone" }); 483 | window.onload = loadZone.wrap(e => { ... }); 484 | ``` 485 | 486 | then at all those sites, `Zone.current` would be equal to `loadZone`. 487 | 488 | Notably, zone.js features like monitoring or intercepting async tasks scheduled in 489 | a zone are not in the scope of this proposal. 490 | 491 | ## Other JavaScript APIs on async tasks 492 | 493 | ### Node.js `domain` module 494 | 495 | Domain's global central active domain can be consumed by multiple endpoints and 496 | be exchanged in any time with synchronous operation (`domain.enter()`). Since it 497 | is possible that some third party module changed active domain on the fly and 498 | application owner may unaware of such change, this can introduce unexpected 499 | implicit behavior and made domain diagnosis hard. 500 | 501 | Check out [Domain Module Postmortem][] for more details. 502 | 503 | ### Node.js `async_hooks` 504 | 505 | This is what the proposal evolved from. `async_hooks` in Node.js enabled async 506 | resources tracking for APM vendors. On which Node.js also implemented 507 | `AsyncLocalStorage`. 508 | 509 | ### Chrome Async Stack Tagging API 510 | 511 | Frameworks can schedule tasks with their own userland queues. In such case, the 512 | stack trace originated from the framework scheduling logic tells only part of 513 | the story. 514 | 515 | ```console 516 | Error: Call stack 517 | at someTask (example.js) 518 | at loop (framework.js) 519 | ``` 520 | 521 | The Chrome [Async Stack Tagging API][] introduces a new console method named 522 | `console.createTask()`. The API signature is as follows: 523 | 524 | ```typescript 525 | interface Console { 526 | createTask(name: string): Task; 527 | } 528 | 529 | interface Task { 530 | run(f: () => T): T; 531 | } 532 | ``` 533 | 534 | `console.createTask()` snapshots the call stack into a `Task` record. And each 535 | `Task.run()` restores the saved call stack and append it to newly generated call 536 | stacks. 537 | 538 | ```console 539 | Error: Call stack 540 | at someTask (example.js) 541 | at loop (framework.js) // <- Task.run 542 | at async someTask // <- Async stack appended 543 | at schedule (framework.js) // <- console.createTask 544 | at businessLogic (example.js) 545 | ``` 546 | 547 | 548 | [async stack traces]: https://v8.dev/docs/stack-trace-api#async-stack-traces 549 | [async stack tagging api]: 550 | https://developer.chrome.com/blog/devtools-modern-web-debugging/#linked-stack-traces 551 | [domain module postmortem]: https://nodejs.org/en/docs/guides/domain-postmortem/ 552 | [`AsyncLocalStorage`]: https://nodejs.org/docs/latest/api/async_context.html#class-asynclocalstorage 553 | [`AsyncLocal`]: https://learn.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1?view=net-9.0 554 | [`CallContext`]: https://learn.microsoft.com/en-us/dotnet/api/system.runtime.remoting.messaging.callcontext?view=netframework-4.8.1 555 | [`zone.js`]: https://github.com/angular/angular/tree/main/packages/zone.js 556 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Async Context for JavaScript 2 | 3 | Status: Stage 2 4 | 5 | Champions: 6 | 7 | - Andreu Botella ([@andreubotella](https://github.com/andreubotella)) 8 | - Chengzhong Wu ([@legendecas](https://github.com/legendecas)) 9 | - Justin Ridgewell ([@jridgewell](https://github.com/jridgewell)) 10 | 11 | Discuss with the group and join the bi-weekly via [#tc39-async-context][] 12 | matrix room ([Matrix Guide][]). 13 | 14 | # Motivation 15 | 16 | When writing synchronous JavaScript code, a reasonable expectation from 17 | developers is that values are consistently available over the life of the 18 | synchronous execution. These values may be passed explicitly (i.e., as 19 | parameters to the function or some nested function, or as a closed over 20 | variable), or implicitly (extracted from the call stack, e.g., outside the scope 21 | as a external object that the function or nested function has access to). 22 | 23 | ```javascript 24 | function program() { 25 | const value = { key: 123 }; 26 | 27 | // Explicitly pass the value to function via parameters. 28 | // The value is available for the full execution of the function. 29 | explicit(value); 30 | 31 | // Explicitly captured by the closure. 32 | // The value is available for as long as the closure exists. 33 | const closure = () => { 34 | assert.equal(value.key, 123); 35 | }; 36 | 37 | // Implicitly propagated via shared reference to an external variable. 38 | // The value is available as long as the shared reference is set. 39 | // In this case, for as long as the synchronous execution of the 40 | // try-finally code. 41 | try { 42 | shared = value; 43 | implicit(); 44 | } finally { 45 | shared = undefined; 46 | } 47 | } 48 | 49 | function explicit(value) { 50 | assert.equal(value.key, 123); 51 | } 52 | 53 | let shared; 54 | function implicit() { 55 | assert.equal(shared.key, 123); 56 | } 57 | 58 | program(); 59 | ``` 60 | 61 | Async/await syntax improved the ergonomics of writing asynchronous JS. It allows 62 | developers to think of asynchronous code in terms of synchronous code. The 63 | behavior of the event loop executing the code remains the same as in a promise 64 | chain. However, passing code through the event loop loses _implicit_ information 65 | from the call site because we end up replacing the call stack. In the case of 66 | async/await syntax, the loss of implicit call site information becomes invisible 67 | due to the visual similarity to synchronous code -- the only indicator of a 68 | barrier is the `await` keyword. As a result, code that "just works" in 69 | synchronous JS has unexpected behavior in asynchronous JS while appearing almost 70 | exactly the same. 71 | 72 | ```javascript 73 | function program() { 74 | const value = { key: 123 }; 75 | 76 | // Implicitly propagated via shared reference to an external variable. 77 | // The value is only available only for the _synchronous execution_ of 78 | // the try-finally code. 79 | try { 80 | shared = value; 81 | implicit(); 82 | } finally { 83 | shared = undefined; 84 | } 85 | } 86 | 87 | let shared; 88 | async function implicit() { 89 | // The shared reference is still set to the correct value. 90 | assert.equal(shared.key, 123); 91 | 92 | await 1; 93 | 94 | // After awaiting, the shared reference has been reset to `undefined`. 95 | // We've lost access to our original value. 96 | assert.throws(() => { 97 | assert.equal(shared.key, 123); 98 | }); 99 | } 100 | 101 | program(); 102 | ``` 103 | 104 | The above problem existed already in promise callback-style code, but the 105 | introduction of async/await syntax has aggravated it by making the stack 106 | replacement almost undetectable. This problem is not generally solvable with 107 | user land code alone. For instance, if the call stack has already been replaced 108 | by the time the function is called, that function will never have a chance to 109 | capture the shared reference. 110 | 111 | ```javascript 112 | function program() { 113 | const value = { key: 123 }; 114 | 115 | // Implicitly propagated via shared reference to an external variable. 116 | // The value is only available only for the _synchronous execution_ of 117 | // the try-finally code. 118 | try { 119 | shared = value; 120 | setTimeout(implicit, 0); 121 | } finally { 122 | shared = undefined; 123 | } 124 | } 125 | 126 | let shared; 127 | function implicit() { 128 | // By the time this code is executed, the shared reference has already 129 | // been reset. There is no way for `implicit` to solve this because 130 | // because the bug is caused (accidentally) by the `program` function. 131 | assert.throws(() => { 132 | assert.equal(shared.key, 123); 133 | }); 134 | } 135 | 136 | program(); 137 | ``` 138 | 139 | Furthermore, the async/await syntax bypasses the userland Promises and 140 | makes it impossible for existing tools like [Zone.js](#zonesjs) that 141 | [instruments](https://github.com/angular/angular/blob/main/packages/zone.js/STANDARD-APIS.md) 142 | the `Promise` to work with it without transpilation. 143 | 144 | This proposal introduces a general mechanism by which lost implicit call site 145 | information can be captured and used across transitions through the event loop, 146 | while allowing the developer to write async code largely as they do in cases 147 | without implicit information. The goal is to reduce the mental burden currently 148 | required for special handling async code in such cases. 149 | 150 | This proposal aims to lower the barrier for developers to instrument production applications to be able to debug real user problems, enabling the use of tracing tools to collect additional performance metrics and information about code flows. This is a widespread approach used in other programming languages, which has so far not been possible on the web due to the asynchronous nature of many web APIs. Front-end frameworks will use this proposal to reduce and automate boilerplate around attributing async code to specific components, which is currently a source of hard to debug issues. 151 | 152 | ## Summary 153 | 154 | This proposal introduces APIs to propagate a value through asynchronous code, 155 | such as a promise continuation or async callbacks. 156 | 157 | Compared to the [Prior Arts][prior-arts.md], this proposal identifies the 158 | following features as non-goals: 159 | 160 | 1. Async tasks scheduling and interception. 161 | 1. Error handling & bubbling through async stacks. 162 | 163 | # Proposed Solution 164 | 165 | `AsyncContext` is designed as a value store for context propagation across 166 | logically-connected sync/async code execution. 167 | 168 | ```typescript 169 | namespace AsyncContext { 170 | class Variable { 171 | constructor(options?: AsyncVariableOptions); 172 | get name(): string; 173 | get(): T | undefined; 174 | run(value: T, fn: (...args: any[])=> R, ...args: any[]): R; 175 | } 176 | interface AsyncVariableOptions { 177 | name?: string; 178 | defaultValue?: T; 179 | } 180 | 181 | class Snapshot { 182 | constructor(); 183 | run(fn: (...args: any[]) => R, ...args: any[]): R; 184 | static wrap(fn: (this: T, ...args: any[]) => R): (this: T, ...args: any[]) => R; 185 | } 186 | } 187 | ``` 188 | 189 | ## `AsyncContext.Variable` 190 | 191 | `Variable` is a container for a value that is associated with the current 192 | execution flow. The value is propagated through async execution flows, and 193 | can be snapshot and restored with `Snapshot`. 194 | 195 | `Variable.prototype.run()` and `Variable.prototype.get()` sets and gets 196 | the current value of an async execution flow. 197 | 198 | ```typescript 199 | const asyncVar = new AsyncContext.Variable(); 200 | 201 | // Sets the current value to 'top', and executes the `main` function. 202 | asyncVar.run("top", main); 203 | 204 | async function main() { 205 | // AsyncContext.Variable is propagated on async/await. 206 | await Promise.resolve(); 207 | console.log(asyncVar.get()); // => 'top' 208 | 209 | // AsyncContext.Variable is propagated through platform tasks. 210 | setTimeout(() => { 211 | console.log(asyncVar.get()); // => 'top' 212 | 213 | asyncVar.run("A", () => { 214 | console.log(asyncVar.get()); // => 'A' 215 | 216 | setTimeout(() => { 217 | console.log(asyncVar.get()); // => 'A' 218 | }, randomTimeout()); 219 | }); 220 | }, randomTimeout()); 221 | 222 | // AsyncContext.Variable runs can be nested. 223 | asyncVar.run("B", () => { 224 | console.log(asyncVar.get()); // => 'B' 225 | 226 | setTimeout(() => { 227 | console.log(asyncVar.get()); // => 'B' 228 | }, randomTimeout()); 229 | }); 230 | 231 | // AsyncContext.Variable was restored after the previous run. 232 | console.log(asyncVar.get()); // => 'top' 233 | } 234 | 235 | function randomTimeout() { 236 | return Math.random() * 1000; 237 | } 238 | ``` 239 | 240 | > [!TIP] 241 | > There have been long detailed discussions on the dynamic scoping of 242 | > `AsyncContext.Variable`. Checkout [SCOPING.md][] for more details. 243 | 244 | Hosts are expected to use the infrastructure in this proposal to allow tracking 245 | not only asynchronous callstacks, but other ways to schedule jobs on the event 246 | loop (such as `setTimeout`) to maximize the value of these use cases. We 247 | describe the needed integration with web platform APIs in the [web integration 248 | document](./WEB-INTEGRATION.md). 249 | 250 | A detailed example of use cases can be found in the 251 | [Use Cases](./USE-CASES.md) and [Frameworks](./FRAMEWORKS.md) documents. 252 | 253 | ## `AsyncContext.Snapshot` 254 | 255 | `AsyncContext.Snapshot` is an advanced API that allows opaquely capturing 256 | the current values of all `Variable`s, and execute a function at a later 257 | time as if those values were still the current values. 258 | 259 | `Snapshot` is useful for implementing APIs that logically "schedule" a 260 | callback, so the callback will be called with the context that it logically 261 | belongs to, regardless of the context under which it actually runs: 262 | 263 | ```typescript 264 | let queue = []; 265 | 266 | export function enqueueCallback(cb: () => void) { 267 | // Each callback is stored with the context at which it was enqueued. 268 | const snapshot = new AsyncContext.Snapshot(); 269 | queue.push(() => snapshot.run(cb)); 270 | } 271 | 272 | runWhenIdle(() => { 273 | // All callbacks in the queue would be run with the current context if they 274 | // hadn't been wrapped. 275 | for (const cb of queue) { 276 | cb(); 277 | } 278 | queue = []; 279 | }); 280 | ``` 281 | 282 | Most web developers, even those interacting with `AsyncContext.Variable` directly, 283 | are not expected to ever need to reach out to `AsyncContext.Snapshot`. 284 | 285 | > [!TIP] 286 | > A detailed explanation of why `AsyncContext.Snapshot` is a requirement can be 287 | > found in [SNAPSHOT.md](./SNAPSHOT.md). 288 | 289 | Note that even with `AsyncContext.Snapshot`, you can only access the value associated with 290 | a `AsyncContext.Variable` instance if you have access to that instance. There is no way to 291 | iterate through the entries or the `AsyncContext.Variable`s in the snapshot. 292 | 293 | ```typescript 294 | const asyncVar = new AsyncContext.Variable(); 295 | 296 | let snapshot 297 | asyncVar.run("A", () => { 298 | // Captures the state of all AsyncContext.Variable's at this moment. 299 | snapshot = new AsyncContext.Snapshot(); 300 | }); 301 | 302 | asyncVar.run("B", () => { 303 | console.log(asyncVar.get()); // => 'B' 304 | 305 | // The snapshot will restore all AsyncContext.Variable to their snapshot 306 | // state and invoke the wrapped function. We pass a function which it will 307 | // invoke. 308 | snapshot.run(() => { 309 | // Despite being lexically nested inside 'B', the snapshot restored us to 310 | // to the snapshot 'A' state. 311 | console.log(asyncVar.get()); // => 'A' 312 | }); 313 | }); 314 | ``` 315 | 316 | ### `AsyncContext.Snapshot.wrap` 317 | 318 | `AsyncContext.Snapshot.wrap` is a helper which captures the current values of all 319 | `Variable`s and returns a wrapped function. When invoked, this wrapped function 320 | restores the state of all `Variable`s and executes the inner function. 321 | 322 | ```typescript 323 | const asyncVar = new AsyncContext.Variable(); 324 | 325 | function fn() { 326 | return asyncVar.get(); 327 | } 328 | 329 | let wrappedFn; 330 | asyncVar.run("A", () => { 331 | // Captures the state of all AsyncContext.Variable's at this moment, returning 332 | // wrapped closure that restores that state. 333 | wrappedFn = AsyncContext.Snapshot.wrap(fn) 334 | }); 335 | 336 | 337 | console.log(fn()); // => undefined 338 | console.log(wrappedFn()); // => 'A' 339 | ``` 340 | 341 | You can think of this as a more convenient version of `Snapshot`, where only a 342 | single function needs to be wrapped. It also serves as a convenient way for 343 | consumers of libraries that don't support `AsyncContext` to ensure that function 344 | is executed in the correct execution context. 345 | 346 | ```typescript 347 | // User code that uses a legacy library 348 | const asyncVar = new AsyncContext.Variable(); 349 | 350 | function fn() { 351 | return asyncVar.get(); 352 | } 353 | 354 | asyncVar.run("A", () => { 355 | defer(fn); // setTimeout schedules during "A" context. 356 | }) 357 | asyncVar.run("B", () => { 358 | defer(fn); // setTimeout is not called, fn will still see "A" context. 359 | }) 360 | asyncVar.run("C", () => { 361 | const wrapped = AsyncContext.Snapshot.wrap(fn); 362 | defer(wrapped); // wrapped callback captures "C" context. 363 | }) 364 | 365 | 366 | // Some legacy library that queues multiple callbacks per macrotick 367 | // Because the setTimeout is called a single time per queue batch, 368 | // all callbacks will be invoked with _that_ context regardless of 369 | // whatever context is active during the call to `defer`. 370 | const queue = []; 371 | function defer(callback) { 372 | if (queue.length === 0) setTimeout(processQueue, 1); 373 | queue.push(callback); 374 | } 375 | function processQueue() { 376 | for (const cb of queue) { 377 | cb(); 378 | } 379 | queue.length = 0; 380 | } 381 | ``` 382 | 383 | 384 | # Examples 385 | 386 | ## Determine the initiator of a task 387 | 388 | Application monitoring tools like OpenTelemetry save their tracing spans in the 389 | `AsyncContext.Variable` and retrieve the span when they need to determine what started 390 | this chain of interaction. 391 | 392 | These libraries can not intrude the developer APIs for seamless monitoring. The 393 | tracing span doesn't need to be manually passing around by usercodes. 394 | 395 | ```typescript 396 | // tracer.js 397 | 398 | const asyncVar = new AsyncContext.Variable(); 399 | export function run(cb) { 400 | // (a) 401 | const span = { 402 | startTime: Date.now(), 403 | traceId: randomUUID(), 404 | spanId: randomUUID(), 405 | }; 406 | asyncVar.run(span, cb); 407 | } 408 | 409 | export function end() { 410 | // (b) 411 | const span = asyncVar.get(); 412 | span?.endTime = Date.now(); 413 | } 414 | ``` 415 | 416 | ```typescript 417 | // my-app.js 418 | import * as tracer from "./tracer.js"; 419 | 420 | button.onclick = (e) => { 421 | // (1) 422 | tracer.run(() => { 423 | fetch("https://example.com").then((res) => { 424 | // (2) 425 | 426 | return processBody(res.body).then((data) => { 427 | // (3) 428 | 429 | const dialog = html` 430 | Here's some cool data: ${data} 431 | `; 432 | dialog.show(); 433 | 434 | tracer.end(); 435 | }); 436 | }); 437 | }); 438 | }; 439 | ``` 440 | 441 | In the example above, `run` and `end` don't share same lexical scope with actual 442 | code functions, and they are capable of async reentrance thus capable of 443 | concurrent multi-tracking. 444 | 445 | ## Transitive task attribution 446 | 447 | User tasks can be scheduled with attributions. With `AsyncContext.Variable`, task 448 | attributions are propagated in the async task flow and sub-tasks can be 449 | scheduled with the same priority. 450 | 451 | ```typescript 452 | const scheduler = { 453 | asyncVar: new AsyncContext.Variable(), 454 | postTask(task, options) { 455 | // In practice, the task execution may be deferred. 456 | // Here we simply run the task immediately. 457 | return this.asyncVar.run({ priority: options.priority }, task); 458 | }, 459 | currentTask() { 460 | return this.asyncVar.get() ?? { priority: "default" }; 461 | }, 462 | }; 463 | 464 | const res = await scheduler.postTask(task, { priority: "background" }); 465 | console.log(res); 466 | 467 | async function task() { 468 | // Fetch remains background priority by referring to scheduler.currentTask(). 469 | const resp = await fetch("/hello"); 470 | const text = await resp.text(); 471 | 472 | scheduler.currentTask(); // => { priority: 'background' } 473 | return doStuffs(text); 474 | } 475 | 476 | async function doStuffs(text) { 477 | // Some async calculation... 478 | return text; 479 | } 480 | ``` 481 | 482 | ## User-land queues 483 | 484 | User-land queues can be implemented with `AsyncContext.Snapshot` to propagate 485 | the values of all `AsyncContext.Variable`s without access to any of them. This 486 | allows the user-land queue to be implemented in a way that is decoupled from 487 | consumers of `AsyncContext.Variable`. 488 | 489 | ```typescript 490 | // The scheduler doesn't access to any AsyncContext.Variable. 491 | const scheduler = { 492 | queue: [], 493 | postTask(task) { 494 | // Each callback is stored with the context at which it was enqueued. 495 | const snapshot = new AsyncContext.Snapshot(); 496 | queue.push(() => snapshot.run(task)); 497 | }, 498 | runWhenIdle() { 499 | // All callbacks in the queue would be run with the current context if they 500 | // hadn't been wrapped. 501 | for (const cb of this.queue) { 502 | cb(); 503 | } 504 | this.queue = []; 505 | } 506 | }; 507 | 508 | function userAction() { 509 | scheduler.postTask(function userTask() { 510 | console.log(traceContext.get()); 511 | }); 512 | } 513 | 514 | // Tracing libraries can use AsyncContext.Variable to store tracing contexts. 515 | const traceContext = new AsyncContext.Variable(); 516 | traceContext.run("trace-id-a", userAction); 517 | traceContext.run("trace-id-b", userAction); 518 | 519 | scheduler.runWhenIdle(); 520 | // The userTask will be run with the trace context it was enqueued with. 521 | // => 'trace-id-a' 522 | // => 'trace-id-b' 523 | ``` 524 | 525 | # FAQ 526 | 527 | ## Are there any prior arts? 528 | 529 | Please checkout [prior-arts.md][] for more details. 530 | 531 | ## Why take a function in `run`? 532 | 533 | The `Variable.prototype.run` and `Snapshot.prototype.run` methods take a 534 | function to execute because it ensures async context variables 535 | will always contain consistent values in a given execution flow. Any modification 536 | must be taken in a sub-graph of an async execution flow, and can not affect 537 | their parent or sibling scopes. 538 | 539 | ```typescript 540 | const asyncVar = new AsyncContext.Variable(); 541 | asyncVar.run("A", async () => { 542 | asyncVar.get(); // => 'A' 543 | 544 | // ...arbitrary synchronous codes. 545 | // ...or await-ed asynchronous calls. 546 | 547 | // The value can not be modified at this point. 548 | asyncVar.get(); // => 'A' 549 | }); 550 | ``` 551 | 552 | This increases the integrity of async context variables, and makes them 553 | easier to reason about where a value of an async variable comes from. 554 | 555 | ## How does `AsyncContext` interact with built-in schedulers? 556 | 557 | Any time a scheduler (such as `setTimeout`, `addEventListener`, or 558 | `Promise.prototype.then`) runs a user-provided callback, it must choose which 559 | snapshot to run it in. While userland schedulers are free to make any choice 560 | here, this proposal adopts a convention that built-in schedulers will always run 561 | callbacks in the snapshot that was active when the callback was passed to the 562 | built-in (i.e. at "registration time"). This is equivalent to what would happen 563 | if the user explicitly called `AsyncContext.Snapshot.wrap` on all callbacks 564 | before passing them. 565 | 566 | This choice is the most consistent with the function-scoped structure that 567 | results from `run` taking a function, and is also the most clearly-defined 568 | option among the possible alternatives. For instance, many event listeners 569 | may be initiated either programmatically or through user interaction; in the 570 | former case there may be a more recently relevant snapshot available, but it's 571 | inconsistent across different types of events or even different instances of the 572 | same type of event. On the other hand, passing a callback to a built-in function 573 | happens at a very clearly defined time. 574 | 575 | Another advantage of registration-time snapshotting is that it is expected to 576 | reduce the amount of intervention required to opt out of the default snapshot. 577 | Because `AsyncContext` is a subtle feature, it's not reasonable to expect every 578 | web developer to build a complete understanding of its nuances. Moreover, it's 579 | important that library users should not need to be aware of the nature of the 580 | variables that library implementations are implicitly passing around. It would 581 | be harmful if common practices emerged that developers felt they needed to wrap 582 | their callbacks before passing them anywhere. The primary means to have a 583 | function run in a different snapshot is to call `Snapshot.wrap`, but this 584 | will be idempotent when passing callbacks to built-ins, making it both less 585 | likely for this common practice to begin in the first place, and also less 586 | harmful when it does happen unnecessarily. 587 | 588 | ## What if I need access to the snapshot from a more recent cause? 589 | 590 | The downside to registration-time snapshotting is that it's impossible to opt 591 | _out_ of the snapshot restoration to access whatever the snapshot would have 592 | been _before_ it was restored. Use cases where this snapshot is more relevant 593 | include 594 | 595 | - programmatically-dispatched events whose handlers are installed at application 596 | initialization time 597 | - unhandled rejection handlers are a specific example of the above 598 | - tracing execution flow, where one task "follows from" a sibling task 599 | 600 | As explained above, the alternative snapshot choices are much more specific to 601 | the individual use case, but they can be made available through side channels. 602 | For instance, web specifications could include that certain event types will 603 | expose an `originSnapshot` property (actual name to be determined) on the event 604 | object containing the active `AsyncContext.Snapshot` from a specific point in 605 | time that initiated the event. 606 | 607 | Providing these additional snapshots through side channels has several benefits 608 | over switching to them by default, or via a generalized "previous snapshot" 609 | mechanism: 610 | 611 | - different types of schedulers may have a variety of potential origination 612 | points, whose scope can be matched precisely with a well-specified side 613 | channel 614 | - access via a known side channel avoids loss of idempotency when callbacks are 615 | wrapped multiple times (whereas a "previous snapshot" would becomes much less 616 | clear) 617 | - no single wrapper method for developers to build bad habits around 618 | 619 | [`asyncresource.runinasyncscope`]: 620 | https://nodejs.org/dist/latest-v14.x/docs/api/async_hooks.html#async_hooks_asyncresource_runinasyncscope_fn_thisarg_args 621 | [#tc39-async-context]: https://matrix.to/#/#tc39-async-context:matrix.org 622 | [Matrix Guide]: https://github.com/tc39/how-we-work/blob/main/matrix-guide.md 623 | [solution.md]: ./SOLUTION.md 624 | [scoping.md]: ./SCOPING.md 625 | [prior-arts.md]: ./PRIOR-ARTS.md 626 | -------------------------------------------------------------------------------- /WEB-INTEGRATION.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | The purpose of this document is to explain the integration of AsyncContext with 4 | the web platform. In particular, when a callback is run, what values do 5 | `AsyncContext.Variable`s have? In other words, which `AsyncContext.Snapshot` is 6 | restored? 7 | 8 | This document focuses on the web platform, and on web APIs, it is also 9 | expected to be relevant to other JavaScript environments and runtimes. This will 10 | necessarily be the case for [WinterTC](https://wintertc.org)-style runtimes, 11 | since they will implement web APIs. 12 | 13 | For details on the memory management aspects of this proposal, see [this 14 | companion document](./MEMORY-MANAGEMENT.md). 15 | 16 | ## Background 17 | 18 | The [AsyncContext proposal](./README.md) introduces the APIs to preserve context 19 | values across promise handlers, and `async`/`await` boundaries. However, to make 20 | the proposal successful, the web platform should also integrate with the async 21 | context propagation at the boundaries of async tasks, so that 22 | `AsyncContext.Variable`s can be used to track context across all asynchronous 23 | operations on a web page. 24 | 25 | The AsyncContext API is primarily designed to be used by certain libraries 26 | to provide good DX to web developers. AsyncContext makes it so users 27 | of those libraries don't need to explicitly passing context around. Instead, the 28 | AsyncContext mechanism handles implicitly passing contextual data around. 29 | 30 | To propagate this context without requiring further JavaScript developer 31 | intervention, web platform APIs which will later run JavaScript callbacks should 32 | propagate the context from the point where the API was invoked to where the 33 | callback is run (i.e. save the current `AsyncContext.Snapshot` and restore it 34 | later). 35 | 36 | Without built-in web platform integration, web developers may need to 37 | "monkey-patch" many web APIs in order to save and restore snapshots, which adds 38 | startup cost and scales poorly as new web APIs been added. 39 | 40 | ## General approach to web API semantics with AsyncContext 41 | 42 | For web APIs that take callbacks, the context of the callback is determined by 43 | where the callback is effectively caused from. This is usually the point where 44 | the API was invoked. 45 | 46 | ```js 47 | { 48 | /* context 1 */ 49 | callAPIWithCallback(() => { 50 | // context 1 51 | }); 52 | } 53 | ``` 54 | 55 | There are various kinds of web platform APIs that accept callbacks and at a later 56 | point run them. And in some cases there is more than one incoming data flow, and 57 | therefore multiple possible `AsyncContext.Snapshot`s that could be restored: 58 | 59 | ```javascript 60 | { 61 | /* context 1 */ 62 | giveCallbackToAPI(() => { 63 | // What context here? 64 | }); 65 | } 66 | { 67 | /* context 2 */ 68 | callCallbackGivenToAPI(); 69 | } 70 | ``` 71 | 72 | APIs should call callbacks using the context from where the API is effectively scheduled 73 | the task (`context 2` in the above code snippet). This matches the behavior you'd get 74 | if web APIs were implemented in JavaScript internally using only promises and 75 | callbacks. This will thus match how most userland libraries behave. 76 | 77 | Some callbacks can be _sometimes_ triggered by some JavaScript code that we can propagate 78 | the context from, but not always. An example is `.addEventListener`: some events can only 79 | be triggered by JavaScript code, some only by external causes (e.g. user interactions), 80 | and some by either (e.g. user clicking on a button or the `.click()` method). In these 81 | cases, when the action is not triggered by some JavaScript code, the callback will run 82 | in the **empty context** instead (where every `AsyncContext.Variable` is set to its default 83 | value). This matches the behavior of JavaScript code running as a top-level operation (like 84 | JavaScript code that runs when a page is just loaded). 85 | 86 | In the rest of this document, we look at various kinds of web platform APIs 87 | which accept callbacks or otherwise need integration with AsyncContext, and 88 | examine which context should be propagated. 89 | 90 | # Individual analysis of web APIs and AsyncContext 91 | 92 | For web APIs that take callbacks, the context in which the callback is run would 93 | depend on the kind of API: 94 | 95 | ## Schedulers 96 | 97 | These are web APIs whose sole purpose is to take a callback and schedule it in 98 | the event loop in some way. The callback will run asynchronously at some point, 99 | when there is no other JS code in the call stack. 100 | 101 | For these APIs, there is only one possible context to propagate: the one that 102 | was active when the API was called. After all, that API call starts a background 103 | user-agent-internal operation that results in the callback being called. 104 | 105 | Examples of scheduler web APIs: 106 | - [`setTimeout()`](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout) 107 | [\[HTML\]](https://html.spec.whatwg.org/multipage/) 108 | - [`setInterval()`](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-setinterval) 109 | [\[HTML\]](https://html.spec.whatwg.org/multipage/) 110 | - [`queueMicrotask()`](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-queuemicrotask) 111 | [\[HTML\]](https://html.spec.whatwg.org/multipage/) 112 | - [`requestAnimationFrame()`](https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#dom-animationframeprovider-requestanimationframe) 113 | [\[HTML\]](https://html.spec.whatwg.org/multipage/) 114 | - [`requestIdleCallback()`](https://w3c.github.io/requestidlecallback/#dom-window-requestidlecallback) 115 | [\[REQUESTIDLECALLBACK\]](https://w3c.github.io/requestidlecallback/) 116 | - [`scheduler.postTask()`](https://wicg.github.io/scheduling-apis/#dom-scheduler-posttask) 117 | [\[SCHEDULING-APIS\]](https://wicg.github.io/scheduling-apis/) 118 | - [`HTMLVideoElement`](https://html.spec.whatwg.org/multipage/media.html#htmlvideoelement): 119 | [`requestVideoFrameCallback()`](https://wicg.github.io/video-rvfc/#dom-htmlvideoelement-requestvideoframecallback) 120 | method [\[VIDEO-RVFC\]](https://wicg.github.io/video-rvfc/) 121 | 122 | ## Async completion callbacks 123 | 124 | These web APIs start an asynchronous operation, and take callbacks to indicate 125 | that the operation has completed. These are usually legacy APIs, since modern 126 | APIs would return a promise instead. 127 | 128 | These APIs propagate the context from where the web API is called, which is the point that 129 | starts the async operation. This would also make these callbacks behave the same as they would 130 | when passed to the `.then()` method of a promise. 131 | 132 | - [`HTMLCanvasElement`](https://html.spec.whatwg.org/multipage/canvas.html#htmlcanvaselement): 133 | [`toBlob()`](https://html.spec.whatwg.org/multipage/canvas.html#dom-canvas-toblob) 134 | method [\[HTML\]](https://html.spec.whatwg.org/multipage/) 135 | - [`DataTransferItem`](https://html.spec.whatwg.org/multipage/dnd.html#datatransferitem): 136 | [`getAsString()`](https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransferitem-getasstring) 137 | method [\[HTML\]](https://html.spec.whatwg.org/multipage/) 138 | - [`Notification.requestPermission()`](https://notifications.spec.whatwg.org/#dom-notification-requestpermission) 139 | [\[NOTIFICATIONS\]](https://notifications.spec.whatwg.org/) 140 | - [`BaseAudioContext`](https://webaudio.github.io/web-audio-api/#BaseAudioContext): 141 | [`decodeAudioData()`](https://webaudio.github.io/web-audio-api/#dom-baseaudiocontext-decodeaudiodata) 142 | method [\[WEBAUDIO\]](https://webaudio.github.io/web-audio-api/) 143 | - [`navigator.geolocation.getCurrentPosition()`](https://w3c.github.io/geolocation/#dom-geolocation-getcurrentposition) 144 | method [\[GEOLOCATION\]](https://w3c.github.io/geolocation/) 145 | - A number of async methods in 146 | [\[ENTRIES-API\]](https://wicg.github.io/entries-api/) 147 | 148 | Some of these APIs started out as legacy APIs that took completion callbacks, 149 | and then they were changed to return a promise – e.g. `BaseAudioContext`'s 150 | `decodeAudioData()` method. For those APIs, the callback's context would behave 151 | similarly to other async completion callbacks, and the promise rejection context 152 | would behave similarly to other promise-returning web APIs (see below). 153 | 154 | ### Callbacks run as part of an async algorithm 155 | 156 | These APIs always invoke the callback to run user code as part of an 157 | asynchronous operation that they start, and which affects the behavior of the 158 | operation. These callbacks are also caused by the original call to the web API, 159 | and thus run in the context that was active at that moment. 160 | 161 | This context also matches the way these APIs could be implemented in JS: 162 | ```js 163 | async function api(callback) { 164 | await doSomething(); 165 | await callback(); 166 | await doSomethingElse(); 167 | } 168 | ``` 169 | 170 | - [`Document`](https://dom.spec.whatwg.org/#document): 171 | [`startViewTransition()`](https://drafts.csswg.org/css-view-transitions-2/#dom-document-startviewtransition) 172 | method [\[CSS-VIEW-TRANSITIONS-1\]](https://drafts.csswg.org/css-view-transitions-1/) 173 | - [`LockManager`](https://w3c.github.io/web-locks/#lockmanager): 174 | [`request()`](https://w3c.github.io/web-locks/#dom-lockmanager-request) method 175 | [\[WEB-LOCKS\]](https://w3c.github.io/web-locks/) 176 | 177 | > [!TIP] 178 | > In all these cases actually propagating the context through the internal asynchronous 179 | > steps of the algorithms gives the same result as capturing the context when the API 180 | > is called and storing it together with the callback. This applies both to "completion 181 | > callbacks" and to "progress callbacks". 182 | 183 | 184 | ## Events 185 | 186 | Events are a single API that is used for a great number of things, including 187 | cases which have a clear JavaScript-originating cause, and cases which the 188 | callback is almost always triggered as a consequence of user interaction. 189 | 190 | For consistency, event listener callbacks should be called with the dispatch 191 | context. If that does not exist, the empty context should be used, where all 192 | `AsyncContext.Variable`s are set to their initial values. 193 | 194 | Event dispatches can be one of the following: 195 | - **Synchronous dispatches**, where the event dispatch happens synchronously 196 | when a web API is called. Examples are `el.click()` which synchronously fires 197 | a `click` event, setting `location.hash` which synchronously fires a 198 | `popstate` event, or calling an `EventTarget`'s `dispatchEvent()` method. For 199 | these dispatches, the TC39 proposal's machinery is enough to track the 200 | context from the API that will trigger the event, with no help from web specs 201 | or browser engines. 202 | - **Browser-originated dispatches**, where the event is triggered by browser or 203 | user actions, or by cross-agent JS, with no involvement from JS code in the 204 | same agent. Such dispatches can't have propagated any context from some non-existing 205 | JS code that triggered them, so the listener is called with the empty context. 206 | - **Asynchronous dispatches**, where the event originates from JS calling into 207 | some web API, but the dispatch happens at a later point. In these cases, the 208 | context should be tracked along the data flow of the operation, even across 209 | code running in parallel (but not through tasks enqueued on other agents' 210 | event loops). 211 | 212 | For events triggered by JavaScript code (either synchronously or asynchronously), 213 | the goal is to follow the same principle state above: they should propagate the 214 | context as if they were implemented by a JavaScript developer that is not explicitly 215 | thinking about AsyncContext propagation: listeners for events dispatched either 216 | **synchronously** or **asynchronously** from JS or from a web API would use the context 217 | that API is called with. 218 | 219 |
220 | Expand this section for examples of the equivalent JS-authored code 221 | 222 | Let's consider a simple approximation of the `EventTarget` interface, authored in JavaScript: 223 | ```javascript 224 | class EventTarget { 225 | #listeners = []; 226 | 227 | addEventListener(type, listener) { 228 | this.#listeners.push({ type, listener }); 229 | } 230 | 231 | dispatchEvent(event) { 232 | for (const { type, listener } of this.#listeners) { 233 | if (type === event.type) { 234 | listener.call(this, event); 235 | } 236 | } 237 | } 238 | } 239 | ``` 240 | 241 | An example _synchronous_ event is `AbortSignal`'s `abort` event. A naive approximation 242 | in JavaScript would look like the following: 243 | 244 | ```javascript 245 | class AbortController { 246 | constructor() { 247 | this.signal = new AbortSignal(); 248 | } 249 | 250 | abort() { 251 | this.signal.aborted = true; 252 | this.signal.dispatchEvent(new Event("abort")); 253 | } 254 | } 255 | ``` 256 | 257 | When calling `abortController.abort()`, there is a current async context active in the agent. All operations that lead to the `abort` event being dispatched are synchronous and do not manually change the current async context: the active async context will remain the same through the whole `.abort()` process, 258 | including in the event listener callbacks: 259 | 260 | ```javascript 261 | const abortController = new AbortController(); 262 | const asyncVar = new AsyncContext.Variable(); 263 | abortController.signal.addEventListener("abort", () => { 264 | console.log(asyncVar.get()); // "foo" 265 | }); 266 | asyncVar.run("foo", () => { 267 | abortController.abort(); 268 | }); 269 | ``` 270 | 271 | Let's consider now a more complex case: the asynchronous `"load"` event of `XMLHttpRequest`. Let's try 272 | to implement `XMLHttpRequest` in JavaScript, on top of fetch: 273 | 274 | ```javascript 275 | class XMLHttpRequest extends EventTarget { 276 | #method; 277 | #url; 278 | open(method, url) { 279 | this.#method = method; 280 | this.#url = url; 281 | } 282 | send() { 283 | (async () => { 284 | try { 285 | const response = await fetch(this.#url, { method: this.#method }); 286 | const reader = response.body.getReader(); 287 | let done; 288 | while (!done) { 289 | const { done: d, value } = await reader.read(); 290 | done = d; 291 | this.dispatchEvent(new ProgressEvent("progress", { /* ... */ })); 292 | } 293 | this.dispatchEvent(new Event("load")); 294 | } catch (e) { 295 | this.dispatchEvent(new Event("error")); 296 | } 297 | })(); 298 | } 299 | } 300 | ``` 301 | 302 | And lets trace how the context propagates from `.send()` in the following case: 303 | ```javascript 304 | const asyncVar = new AsyncContext.Variable(); 305 | const xhr = new XMLHttpRequest(); 306 | xhr.open("GET", "https://example.com"); 307 | xhr.addEventListener("load", () => { 308 | console.log(asyncVar.get()); // "foo" 309 | }); 310 | asyncVar.run("foo", () => { 311 | xhr.send(); 312 | }); 313 | ``` 314 | - when `.send()` is called, the value of `asyncVar` is `"foo"`. 315 | - it is synchronously propagated up to the `fetch()` call in `.send()` 316 | - the `await` snapshots the context before pausing, and restores it (to `asyncVar: "foo"`) when the `fetch` completes 317 | - the `await`s in the reader loop propagate the context as well 318 | - when `this.dispatchEvent(new Event("load"))`, is called, the current active async context is thus 319 | the same one as when `.send()` was called 320 | - the `"load"` callback thus runs with `asyncVar` set to `"foo"`. 321 | 322 | Note that this example uses `await`, but due to the proposed semantics for `.then` and `setTimeout` 323 | (and similar APIs), the same would happen when using other asynchronicity primitives. Note that most APIs 324 | dealing with I/O are not actually polyfillable in JavaScript, but you can still emulate/mock them with 325 | testing data. 326 | 327 |
328 | 329 | Event listeners for events dispatched **from the browser** rather than as a consequence of some JS action (e.g. a user clicking on a button) will by default run in the root (empty) context. This is the same 330 | context that the browser uses, for example, for the top-level execution of scripts. 331 | 332 | > [!WARNING] 333 | > To keep agents isolated, events dispatched from different agents (e.g. from a worker, or from a cross-origin iframe) will behave like events dispatched by user interaction. This also applies to events dispatched from cross-origin iframes in the same agent, to avoid exposing the fact that they're in the same agent. 334 | 335 | ## Status change listener callbacks 336 | 337 | These APIs register a callback or constructor to be invoked when some action 338 | runs. They're also commonly used as a way to associate a newly created class 339 | instance with some action, such as in worklets or with custom elements. 340 | 341 | In cases where the action always originates due to something happening outside of 342 | the web page (such as some user action), there is never some JS code that triggers 343 | the callback. These would behave like async-completion/progress APIs, 344 | that propagate the context from the point where the API is called (making, for 345 | example, `navigator.geolocation.watchPosition(cb)` propagate the same way as 346 | `navigator.geolocation.getCurrentPosition(cb)`). 347 | 348 | - [`navigator.mediaSession.setActionHandler()`](https://w3c.github.io/mediasession/#dom-mediasession-setactionhandler) 349 | method [\[MEDIASESSION\]](https://w3c.github.io/mediasession/) 350 | - [`navigator.geolocation.watchPosition()`](https://w3c.github.io/geolocation/#dom-geolocation-watchposition) 351 | method [\[GEOLOCATION\]](https://w3c.github.io/geolocation/) 352 | - [`RemotePlayback`](https://w3c.github.io/remote-playback/#dom-remoteplayback): 353 | [`watchAvailability()`](https://w3c.github.io/remote-playback/#dom-remoteplayback-watchavailability) 354 | method [\[REMOTE-PLAYBACK\]](https://w3c.github.io/remote-playback/) 355 | 356 | ### Worklets 357 | 358 | Worklets work similarly: you provide a class to an API that is called 359 | _always from outside of the worklet thread_ when there is some work to be done. 360 | 361 | - [`registerProcessor()`](https://webaudio.github.io/web-audio-api/#dom-audioworkletglobalscope-registerprocessor) 362 | - [`registerPaint()`](https://drafts.css-houdini.org/css-paint-api-1/#dom-paintworkletglobalscope-registerpaint) 363 | 364 | While in theory there always is only one possible context to propagate to the class methods, 365 | that is the one when `.register*()` was called (because there is never in-thread JS code actually 366 | calling those methods), in practice that context will always match the root context of the 367 | worklet scope (because `register*()` is always called at the top-level). Hence, to simplify 368 | implementations we propose that Worklet methods always run in the root context. 369 | 370 | According to the HTML spec, creating a worklet global scope always creates a new agent, and 371 | therefore there can't be any propagation from other context into the worklet and vice versa, even 372 | if its event loop runs in the same thread as other agents. This isn't always implemented this way – 373 | in Chromium, for example, the equivalent of an agent is shared among worklets and other agents 374 | running in the same thread; but since this agent sharing is unobservable, we should not add a 375 | dependency on it. 376 | 377 | ### Custom elements 378 | 379 | Custom elements are also registered by passing a class to a web API, and this class 380 | has some methods that are called at different points of the custom element's lifecycle. 381 | 382 | However, differently from worklets, lifecycle callbacks are almost always triggered 383 | synchronously by a call from userland JS to an API annotated with 384 | [`[CEReactions]`](https://html.spec.whatwg.org/multipage/custom-elements.html#cereactions). 385 | We thus propose that they behave similarly to events, running in the same context that was 386 | active when the API that triggers the callback was called. 387 | 388 | There are cases where lifecycle callbacks are triggered by user interaction, so there is no 389 | context to propagate: 390 | 391 | - If a custom element is contained inside a `
`, the user 392 | could remove the element from the tree as part of editing, which would queue a 393 | microtask to call its `disconnectedCallback` hook. 394 | - A user clicking a form reset when a form-associated custom element is in the 395 | form would queue a microtask to call its `formResetCallback` lifecycle hook, 396 | and there would not be a causal context. 397 | 398 | Similarly to events, in this case lifecycle callbacks would run in the empty context. 399 | 400 | ## Observers 401 | 402 | Observers are a kind of web API pattern where the constructor for a class takes 403 | a callback, the instance's `observe()` method is called to register things that 404 | should be observed, and then the callback is called when those observations have 405 | been made. 406 | 407 | Observer callbacks are not called once per observation. Instead, multiple observations 408 | can be batched into one single call. This means that there is not always a single JS action 409 | that causes some work that eventually triggers the observer callback; rather, there might be many. 410 | 411 | Given this, observer callbacks should always run with the empty context. This can be explained 412 | by saying that, e.g. layout changes are always considered to be a browser-internal trigger, even if 413 | they were caused by changes injected into the DOM or styles through JavaScript. 414 | 415 | - [`MutationObserver`](https://dom.spec.whatwg.org/#mutationobserver) 416 | [\[DOM\]](https://dom.spec.whatwg.org/) 417 | - [`ResizeObserver`](https://drafts.csswg.org/resize-observer-1/#resizeobserver) 418 | [\[RESIZE-OBSERVER\]](https://wicg.github.io/ResizeObserver/) 419 | - [`IntersectionObserver`](https://w3c.github.io/IntersectionObserver/#intersectionobserver) 420 | [\[INTERSECTION-OBSERVER\]](https://w3c.github.io/IntersectionObserver/) 421 | - [`PerformanceObserver`](https://w3c.github.io/performance-timeline/#dom-performanceobserver) 422 | [\[PERFORMANCE-TIMELINE\]](https://w3c.github.io/performance-timeline/) 423 | - [`ReportingObserver`](https://w3c.github.io/reporting/#reportingobserver) 424 | [\[REPORTING\]](https://w3c.github.io/reporting/) 425 | 426 | > [!NOTE] 427 | > An older version of this proposal suggested to capture the context at the time the observer 428 | > is created, and use it to run the callback. This has been removed due to memory leak concerns. 429 | 430 | In some cases it might be useful to expose the causal context for individual 431 | observations, by exposing an `AsyncContext.Snapshot` property on the observation 432 | record. This should be the case for `PerformanceObserver`, where 433 | `PerformanceEntry` would expose the snapshot as a `resourceContext` property. This 434 | is not included as part of this initial proposed version, as new properties can 435 | easily be added as follow-ups in the future. 436 | 437 | ## Stream underlying APIs 438 | 439 | The underlying [source](https://streams.spec.whatwg.org/#underlying-source-api), 440 | [sink](https://streams.spec.whatwg.org/#underlying-sink-api) and 441 | [transform](https://streams.spec.whatwg.org/#transformer-api) APIs for streams 442 | are callbacks/methods passed during stream construction. 443 | 444 | The `start` method runs as a direct consequence of the stream being constructed, 445 | thus it propagates the context from there. For other methods there would be a 446 | different causal context, depending on what causes the call to that method. For example: 447 | 448 | - If `ReadableStreamDefaultReader`'s `read()` method is called and that causes a 449 | call to the `pull` method, then that would be its causal context. This would 450 | be the case even if the queue is not empty and the call to `pull` is deferred 451 | until previous invocations resolve. 452 | - If a `Request` is constructed from a `ReadableStream` body, and that is passed 453 | to `fetch`, the causal context for the `pull` method invocations should be the 454 | context active at the time that `fetch` was called. Similarly, if a response 455 | body `ReadableStream` obtained from `fetch` is piped to a `WritableStream`, 456 | its `write` method's causal context is the call to `fetch`. 457 | 458 | In general, the context that should be used is the one that matches the data 459 | flow through the algorithms ([see the section on implicit propagation 460 | below](#implicit-context-propagation)). 461 | 462 | > TODO: Piping is largely implementation-defined. We will need to explicitly 463 | > define how propagation works there, rather than relying on the streams 464 | > usage of promises, to ensure interoperability. 465 | 466 | > TODO: If a stream gets transferred to a different agent, any cross-agent 467 | > interactions will have to use the empty context. What if you round-trip a 468 | > stream through another agent? 469 | 470 | ## Script errors and unhandled rejections 471 | 472 | The `error` event on a window or worker global object is fired whenever a script 473 | execution throws an uncaught exception. The context in which this exception was 474 | thrown is the causal context where the exception is not handled. Likewise, the 475 | `unhandledrejection` is fired whenever a promise is rejected without a rejection 476 | handler, and the causal context is the context where the promise was created. 477 | 478 | Having access to the contexts which these errors are not handled is useful to 479 | determine which of multiple independent streams of async execution did not handle 480 | the errors properly, and therefore how to clean up after it. For example: 481 | 482 | ```js 483 | async function doOperation(i: number, signal: AbortSignal) { 484 | // ... 485 | } 486 | 487 | const operationNum = new AsyncContext.Variable(); 488 | const controllers: AbortController[] = []; 489 | 490 | for (let i = 0; i < 20; i++) { 491 | controllers[i] = new AbortController(); 492 | operationNum.run(i, () => setTimeout(() => doOperation(i, controllers[i].signal), 0)); 493 | } 494 | 495 | window.onerror = window.onunhandledrejection = () => { 496 | const idx = operationNum.get(); 497 | controllers[idx].abort(); 498 | }; 499 | ``` 500 | 501 | ### Unhandled rejection details 502 | 503 | In the following example, an `unhandledrejection` event would be fired due to the 504 | promise returned by `b()` rejecting without a handler. The context propagated to 505 | the `unhandledrejection` handler would be the one active when `b()` was called, 506 | which is the outer `asyncVar.run("foo", ...)` call, and thus `asyncVar` would 507 | map to `"foo"`, rather than `"bar"` where the throw happens. 508 | 509 | ```js 510 | async function a() { 511 | console.log(asyncVar.get()); // "bar" 512 | throw new Error(); 513 | } 514 | 515 | async function b() { 516 | console.log(asyncVar.get()); // "foo" 517 | await asyncVar.run("bar", async () => { 518 | const p1 = a(); 519 | await p1; 520 | }); 521 | } 522 | 523 | asyncVar.run("foo", () => { 524 | const p2 = b(); 525 | }); 526 | ``` 527 | 528 | If a promise created by a web API rejects, the `unhandledrejection` event 529 | handlers context would be tracked following the normal tracking mechanism. According to the 530 | categories in the ["Writing Promise-Using Specifications"](https://w3ctag.github.io/promises-guide/) guide: 531 | - For one-and-done operations, the rejection-time context of the returned 532 | promise should be the context when the web API that returns it was called. 533 | - For one-time "events", the rejection context would be the context in which the 534 | promise is caused to reject. In many cases, the promise is created at the same 535 | time as an async operation is started which will eventually resolve it, and so 536 | the context would flow from creation to rejection (e.g. for the 537 | [`loaded`](https://drafts.csswg.org/css-font-loading-3/#dom-fontface-loaded) 538 | property of a [`FontFace`](https://drafts.csswg.org/css-font-loading-3/#fontface) 539 | instance, creating the `FontFace` instance causes both the promise creation 540 | and the loading of the font). But this is not always the case, as for the 541 | [`ready`](https://streams.spec.whatwg.org/#default-writer-ready) property of a 542 | [`WritableStreamDefaultWriter`](https://streams.spec.whatwg.org/#writablestreamdefaultwriter), 543 | which could be caused to reject by a different context. In such cases, the 544 | context should be [propagated implicitly](#implicit-context-propagation). 545 | - More general state transitions are similar to one-time "events" which can be 546 | reset, and so they should behave in the same way. 547 | 548 | ## Module evaluation 549 | 550 | When you import a JS module multiple times, it will only be fetched and 551 | evaluated once. Since module evaluation should not be racy (i.e. it should not 552 | depend on the order of various imports), the context should be reset so that 553 | module evaluation always runs with the empty AsyncContext snapshot. 554 | 555 | ## Security Considerations 556 | 557 | The goal of the AsyncContext web integration is to propagate context inside 558 | a same-origin web page, and not to leak information across origins or agents. 559 | 560 | The propagation must not implicitly serialize and deserialize context values 561 | across agents, and no round-trip propagation. The propagation must not involve 562 | code execution in other agents. 563 | 564 | ### Cross-document navigation 565 | 566 | When a cross-document navigation happens, even if it is same-origin, the context 567 | will be reset such that document load and tasks that directly flow from it 568 | (including execution of classic scripts found during parsing) run with the 569 | empty AsyncContext snapshot, which will be an empty mapping (i.e. every 570 | `AsyncContext.Variable` will be set to its initial value). 571 | 572 | ### Cross-origin iframes 573 | 574 | Cross-origin API calls do not propagate the context from one origin to the other, 575 | as if they were happening in different agents/threads. This is also true for APIs 576 | that synchronously run cross-origin code, such as calling `.focus()` on a 577 | cross-origin iframe's window: the context is explicitly reset to the top-level one. 578 | 579 | See [whatwg/html#3506](https://github.com/whatwg/html/issues/3506) for related 580 | discussion about `focus()`'s behavior on cross-origin iframes. 581 | 582 | # Editorial aspects of AsyncContext integration in web specifications 583 | 584 | An agent always has an associated AsyncContext mapping, in its 585 | `[[AsyncContextMapping]]` field[^1]. When the agent is created, this mapping will be 586 | set to an HTML-provided initial state, but JS user code can change it in a 587 | strictly scoped way. 588 | 589 | [^1]: The reason this field is agent-wide rather than per-realm is so calling a 590 | function from a different realm which calls back into you doesn't lose the 591 | context, even if the functions are async. 592 | 593 | In the current proposal, the only way JS code can modify the current mapping is 594 | through `AsyncContext.Variable` and `AsyncContext.Snapshot`'s `run()` methods, 595 | which switch the context before calling a callback and switch it back after it 596 | synchronously returns or throws. This ensures that for purely synchronous 597 | execution, the context is automatically propagated along the data flow. It is 598 | when tasks and microtasks are queued that the data flow must be tracked through 599 | web specs. 600 | 601 | The TC39 proposal spec text includes two abstract operations that web specs can 602 | use to store and switch the context: 603 | - [`AsyncContextSnapshot()`](https://tc39.es/proposal-async-context/#sec-asynccontextsnapshot) 604 | returns the current AsyncContext mapping. 605 | - [`AsyncContextSwap(context)`](https://tc39.es/proposal-async-context/#sec-asynccontextswap) 606 | sets the current AsyncContext mapping to `context`, and returns the previous 607 | one. `context` must only be a value returned by one of these two operations. 608 | 609 | We propose adding a web spec algorithm "run the AsyncContext Snapshot", that could be used like this: 610 | 611 | > 1. Let _context_ be 612 | > [AsyncContextSnapshot](https://tc39.es/proposal-async-context/#sec-asynccontextsnapshot)(). 613 | > 1. [Queue a global task](https://html.spec.whatwg.org/multipage/webappapis.html#queue-a-global-task) 614 | > to run the following steps: 615 | > 1. Run the AsyncContext Snapshot context while performing the following 616 | > steps: 617 | > 1. Perform some algorithm, which might call into JS. 618 | 619 | This algorithm, when called with an AsyncContext mapping _context_ and a set of 620 | steps _steps_, would do the following: 621 | 622 | > 1. Let _previousContext_ be 623 | > [AsyncContextSwap](https://tc39.es/proposal-async-context/#sec-asynccontextswap)(_context_). 624 | > 1. Run _steps_. If this throws an exception _e_, then: 625 | > 1. [AsyncContextSwap](https://tc39.es/proposal-async-context/#sec-asynccontextswap)(_previousContext_). 626 | > 1. Throw _e_. 627 | > 1. [AsyncContextSwap](https://tc39.es/proposal-async-context/#sec-asynccontextswap)(_previousContext_). 628 | 629 | For web APIs that take a callback and eventually call it with the same context as when 630 | the web API was called, this should be handled in WebIDL by storing the result of `AsyncContextSnapshot()` 631 | alongside the callback function, and swapping it when the function is called. Since this should not happen 632 | for every callback, there should be a WebIDL extended attribute applied to callback types to control this. 633 | 634 | ## Using AsyncContext from web specs 635 | 636 | There are use cases in the web platform that would benefit from using 637 | AsyncContext variables built into the platform, since there are often relevant 638 | pieces of contextual information which would be impractical to pass explicitly 639 | as parameters. Some of these use cases are: 640 | 641 | - **Task attribution**. The soft navigations API 642 | [\[SOFT-NAVIGATIONS\]](https://wicg.github.io/soft-navigations/) needs to be 643 | able to track which tasks in the event loop are caused by other tasks, in 644 | order to measure the time between the user interaction that caused the soft 645 | navigation, and the end of the navigation. Currently this is handled by 646 | modifying a number of event loop-related algorithms from the HTML spec, but 647 | basing it on AsyncContext might be easier. It seems like this would also be 648 | useful [to identify scripts that enqueued long 649 | tasks](https://github.com/w3c/longtasks/issues/89), or to [build dependency 650 | trees for the loading of 651 | resources](https://github.com/w3c/resource-timing/issues/263). See 652 | https://github.com/WICG/soft-navigations/issues/44. 653 | 654 | - **`scheduler.yield` priority and signal**. In order to provide a more 655 | ergonomic API, if 656 | [`scheduler.yield()`](https://wicg.github.io/scheduling-apis/#dom-scheduler-yield) 657 | is called inside a task enqueued by 658 | [`scheduler.postTask()`](https://wicg.github.io/scheduling-apis/#dom-scheduler-posttask) 659 | [\[SCHEDULING-APIS\]](https://wicg.github.io/scheduling-apis/), its `priority` 660 | and `signal` arguments will be "inherited" from the call to `postTask`. This 661 | inheritance should propagate across awaits. See 662 | https://github.com/WICG/scheduling-apis/issues/94. 663 | 664 | - **Future possibility: ambient `AbortSignal`**. This would allow using an 665 | `AbortSignal` without needing to pass it down across the call stack until the 666 | leaf async operations. See 667 | https://gist.github.com/littledan/47b4fe9cf9196abdcd53abee940e92df 668 | 669 | - **Possible refactoring: backup incumbent realm**. The HTML spec infrastructure 670 | for the [incumbent realm](https://html.spec.whatwg.org/multipage/webappapis.html#concept-incumbent-everything) 671 | uses a stack of backup incumbent realms synchronized with the JS execution 672 | stack, and explicitly propagates the incumbent realm through `await`s using JS 673 | host hooks. This might be refactored to build on top of AsyncContext, which 674 | might help fix some long-standing disagreements between certain browsers and 675 | the spec. 676 | 677 | For each of these use cases, there would need to be an `AsyncContext.Variable` 678 | instance backing it, which should not be exposed to JS code. We expect that 679 | algorithms will be added to the TC39 proposed spec text, so that web specs don't 680 | need to create JS objects. 681 | --------------------------------------------------------------------------------