From b4052acad859f64294c739c1097fa16e6fb00c59 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Mon, 24 Jun 2024 11:35:50 +0100 Subject: [PATCH 1/2] Add continuation flow doc --- CONTINUATION.md | 511 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 CONTINUATION.md diff --git a/CONTINUATION.md b/CONTINUATION.md new file mode 100644 index 0000000..dda5a11 --- /dev/null +++ b/CONTINUATION.md @@ -0,0 +1,511 @@ +# Continuation flows + +The proposal as it currently stands defines that a context variable by +propagating values to subtasks without feeding back any modifications in +subtasks to the context of their parent async task. + +```js +const asyncVar = new AsyncContext.Variable() + +asyncVar.run('main', main) + +async function main() { + asyncVar.get() // => 'main' + + await asyncVar.run('inner', async () => { + asyncVar.get() // => 'inner' + await task() + asyncVar.get() // => 'inner' + // value in this scope is not changed by subtasks + }) + + asyncVar.get() // => 'main' + // value in this scope is not changed by subtasks +} + +let taskId = 0 +async function task() { + asyncVar.get() // => 'inner' + await asyncVar.run(`task-${taskId++}`, async () => { + asyncVar.get() // => 'task-0' + // ... async operations + await 1 + asyncVar.get() // => 'task-0' + }) +} +``` + +In this model, any modifications are async task local scoped. A subtask +snapshots the context when they are scheduled and never propagates the +modification back to its parent async task scope. + +> Checkout this example in other languages [gist](https://gist.github.com/jridgewell/ca75e91f78c6fa429af10271451a437d?permalink_comment_id=5077079). + +Another semantic was proposed to improve traceability on determine the +continuation "previous" task in a logical asynchronous execution. In this +model, modifications made in an async subtask are propagated to its +continuation tasks. + +It was initially proposed based on the callback style of async continuations. +We'll use the term "ContinuationFlow" in the following example. + +```js +const cf = new ContinuationFlow() + +cf.run('main', () => { + readFile(file, () => { + cf.get() // => main + }) +}) + +function readFile(file, callback) { + // snapshot context at fs.open + fs.open(file, (err, fd) => { + // restore context before callback + + // snapshot context at fs.read + fs.read(fd, (err, text) => { + // restore context before callback + + // snapshot context at fs.close + fs.close(fd, (err) => { + // restore context before callback + + callback(err, text) + }) + }) + }) +} +``` + +In the above example, callbacks can be composed naturally with the function +argument of `run(value, fn)` signature, and makes it possible to feedback +the modification of the context of subtasks to the callbacks from outer scope. + +```js +cf.run('main', () => { + readFile(file, () => { + cf.get() // => last operation: fs.close + }) +}) + +function readFile(file, callback) { + // ... + // omit the other part + fs.close(fd, (err) => { + cf.run('last operation: fs.close', () => { + callback(err, text) + }) + }) +} +``` + +Since promise handlers are continuation functions as well, it suggests the +initial example to behave like: + +```js +const cf = new ContinuationFlow() + +cf.run('main', main) +async function main() { + cf.get() // => 'main' + + await ctx.run('inner', async () => { + cf.get() // => 'inner' + await task() + cf.get() // => 'task-0' + }) + + cf.get() // => 'task-0' +} + +let taskId = 0 +async function task() { + cf.get() // => 'inner' + await ctx.run(`task-${taskId++}`, async () => { + const id = cf.get() // => 'task-0' + // ... async operations + await 1 + cf.get() // => can be anything from above async operations + + // Forcefully reset the ctx + return cf.run(id, () => Promise.resolve()) + }) +} +``` + +In this model, any modifications are passed along with the continuation flow. +An async subtask snapshots the context when they are continued from (either +fulfilled or rejected) and restores the continuation snapshot when invoking the +continuation callbacks. + +## Comparison + +There are several properties that both semantics persist yet with different +semantics: + +- Implicit-propagation: both semantics would propagate contexts based on + language built-in structures, allowing implicit propagation across call + boundaries without explicit parameter-passing. + - If there are no modifications in any subtasks, the two would be + practically identical. +- Causality: both semantics don't use the MOST relevant cause as its parent + context because the MOST relevant causes can be un-deterministic and lead to + confusions in real world API compositions (as in + [`unhandledrejection`](#unhandled-rejection) and + [Fulfilled promises](#fulfilled-promises)). + +However, new issues arose for the continuation flow semantic: + +- Merge-points: as feeding back the modifications to the parent scope, there + must be a strategy to handle merge conflicts. +- Cutting Leaf-node: it is proposed that the continuation flow is specifically + addressing the issue that modifications made in leaf-node of an async graph are + discarded, yet the current semantic doesn't not handle the leaf-node cutting + problem in all edge cases. + +## Promise operations + +Promise is the basic control-flow building block in async codes. Since promises +are first-class values, they can be passed around, aggregated, and so on. +Promise handlers can be attached to a promise in a different context than the +creation context of the promise. + +The following promise aggregation APIs may be a merge point where multiple +promises are merged into one single promise, or short-circuit in conditions +picking one single winner and discarding all other states. + +| Name | Description | On-resolve | On-reject | +| -------------------- | ----------------------------------------------- | ------------- | ------------- | +| `Promise.allSettled` | does not short-circuit | Merge | Merge | +| `Promise.all` | short-circuits when an input value is rejected | Merge | Short-circuit | +| `Promise.race` | short-circuits when an input value is settled | Short-circuit | Short-circuit | +| `Promise.any` | short-circuits when an input value is fulfilled | Short-circuit | Merge | + +To expand on the behaviors, given a task with the following definition, with +`ctx` being the corresponding context variable (`AsyncContext.Variable` and +`ContinuationFlow`) in each semantic: + +```js +let taskId = 0 +function randomTask() { + return ctx.run(`task-${taskId++}`, async () => { + await scheduler.wait(Math.random * 10) + if (Math.random() >= 0.5) { + throw new Error() + } + }) +} +``` + +### `Promise.allSettled` + +It is a merge point on `Promise.allSettled`. It aggregates the promise results +that are either resolved or rejected. + +```js +ctx.run('main', async () => { + await Promise.allSettled(_.times(5).map((it) => randomTask())) + ctx.get() // => (1) +}) +``` + +From different observation perspectives, the value at site (1) would be +different on the above semantics: + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, the proposed solution didn't specify + conflicts-resolving yet. If the default behavior merges the values in an + array, it changes the shape of the value. + +### `Promise.all` + +In the resolving path, `Promise.all` is similar to `Promise.allSettled` that it +is a merge point when all promises resolve. + +`Promise.all` picks the fastest reject promise. + +```js +ctx.run('main', async () => { + try { + await Promise.all(_.times(5).map((it) => randomTask())) + ctx.get() // => (1) + } catch (e) { + ctx.get() // => (2) + } +}) +``` + +The value at site (1): + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, the proposed solution didn't specify + conflicts-resolving yet. + +The value at site (2): + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, it is the one caused the rejection, and discarding + leaf contexts of promises that may have been fulfilled. + +### `Promise.race` + +`Promise.race` picks the fastest settled (either fulfilled or rejected) promise. + +```js +ctx.run('main', async () => { + try { + await Promise.race(_.times(5).map((it) => randomTask())) + ctx.get() // => (1) + } catch (e) { + ctx.get() // => (2) + } +}) +``` + +The value at sites (1) and (2): + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, it is the one caused the settlement. + +### `Promise.any` + +In a happy path, `Promise.any` is similar to `Promise.race` that it picks the +fastest resolved promise. However, it aggregates the exception values when all +the promises are rejected. + +It is a merge point when exception values are aggregated. + +```js +ctx.run('main', async () => { + try { + await Promise.any(_.times(5).map((it) => randomTask())) + ctx.get() // => (1) + } catch (e) { + ctx.get() // => (2) + } +}) +``` + +The value at site (1): + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, it is the one caused the fulfillment, and discarding + leaf contexts of promises that may have been rejected. + +The value at site (2): + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, the proposed solution didn't specify + conflicts-resolving yet. + +### Graft promises from outer scope + +The state of a resolved or rejected promise never changes, as in primitives or +object identities. `Promise.prototype.then` creates a new promise from an +existing one and automatically bubbles the return value and the exception +to the new promise. + +`await` operations are meant to be practically similar to a +`Promise.prototype.then`. + +By awaiting a promise created outside of the current context, the two +executions are grafted together and there is a merge point on `await`. + +```js +const gPromise = ctx.run('global', () => { + return Promise.resolve(1) +}) + +ctx.run('main', main) +async function main() { + await gPromise + // -> global --- + // \ + // -> main ----> (1) + ctx.get() // (1) +} +``` + +The value at site (1): + +- For `AsyncContext.Variable`, it is `main`, +- For `ContinuationFlow`, it is `global`, discarding the leaf context before + `await` operation. + +### Unhandled rejection + +Unhandled rejection events are tasks that is scheduled when +[HostPromiseRejectionTracker](https://tc39.es/ecma262/#sec-host-promise-rejection-tracker) +is invoked. + +The `PromiseRejectionEvent` instances of unhandled rejection events are emitted +with the following properties: + +- `promise`: the promise which has no handler, +- `reason`: the exception value. + +Intuitively, the context of the event should be relevant to the promise +instance attached to the `PromiseRejectionEvent` instance. + +For a promise created with the `PromiseConstructor` or `Promise.withResolvers`, +the promise can be rejected in a different context: + +```js +let reject +let p1 // (1) +ctx.run('init', () => { + ;({ p1, reject } = Promise.withResolvers()) +}) + +ctx.run('reject', () => { + reject('error message') +}) + +addEventListener('unhandledrejection', (event) => { + ctx.get() // (2) +}) +``` + +In this case, the `unhandledrejection` event will be dispatched with the promise +instance `p1` and a string of `'error message'`, and the event is scheduled in +the context of `'reject'`. + +For the two type of context variables with `unhandledrejection` event of `p1`: + +- For `AsyncContext.Variable`, the context is `'reject'`, where `p1` was rejected. +- For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. + +--- + +However, if this promise was handled, and the new promise didn't have a proper +handler: + +```js +let reject +let p1 // (1) +ctx.run('init', () => { + ;({ p1, reject } = Promise.withResolvers()) + const p2 = p1 // p1 is not settled yet + .then(undefined, undefined) +}) + +ctx.run('reject', () => { + reject('error message') +}) + +addEventListener('unhandledrejection', (event) => { + ctx.get() // (2) +}) +``` + +The `unhandledrejection` event will be dispatched with the promise +instance `p2` with a string of `'error message'`. In this case, the event is +scheduled in a new microtask which was scheduled in the context of `'reject'`. + +For the two type of context variables, the value at site (2) with +`unhandledrejection` event of `p2`: + +- For `AsyncContext.Variable`, the context is `'init'`, where `p2` + attaches the promise handlers to `p1`. +- For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. + +--- + +If handlers were attached to an already rejected promise: + +```js +let p1 // (1) +ctx.run('reject', () => { + p1 = Promise.reject('error message') +}) + +ctx.run('init', () => { + const p2 = p1 // p1 is already rejected + .then(undefined, undefined) // (2) +}) + +addEventListener('unhandledrejection', (event) => { + ctx.get() // (2) +}) +``` + +In this case, the `unhandledrejection` event is scheduled in the context where +`.then` is called, that is `'init'`. + +> By saying "the `unhandledrejection` event is scheduled" above, it is +> referring to [HostPromiseRejectionTracker](https://html.spec.whatwg.org/#the-hostpromiserejectiontracker-implementation). +> This host hook didn't actually "schedule" the event dispatching, rather putting the +> promise in a queue and the event is actually scheduled from [event loop](https://html.spec.whatwg.org/#notify-about-rejected-promises). + +For the two type of context variables, the value at site (2) with +`unhandledrejection` event of `p2`: + +- For `AsyncContext.Variable`, the context is `'init'`, where `p2` + attaches the promise handlers to `p1`. +- For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. + +### Fulfilled promises + +Similar to the rejected promise issue, the MOST relevant cause's context when +scheduling a microtask for newly created promise handlers are not +deterministic: + +```js +let p1 // (1) +ctx.run('resolve', () => { + p1 = Promise.resolve('yay') +}) + +ctx.run('init', () => { + const p2 = p1 // p1 is already resolved + .then(() => { + ctx.get() // (2) + }) +}) +``` + +[`PerformPromiseThen`](https://tc39.es/ecma262/#sec-performpromisethen) calls +[`HostEnqueuePromiseJob`](https://html.spec.whatwg.org/#hostenqueuepromisejob), +which immediately queues a new microtask to call the promise fulfill reaction. + +Explaining in the callback continuation form, this would be: + +```js +// `Promise.prototype.then` in plain JS. +const then = (p, onFulfill) => { + if (p[PromiseState] === 'FULFILLED') { + let { resolve, reject, promise } = Promise.withResolvers() + queueMicrotask(() => { + resolve(onFulfill(promise[PromiseResult])) + }) + return promise + } + // ... +} +let p1 // (1) +ctx.run('resolve', () => { + p1 = Promise.resolve('yay') +}) + +ctx.run('init', () => { + const p2 = then(p1, undefined) // the promise is already resolved +}) +``` + +In this case, the MOST relevant context would be `'init'` since in this +context, the microtask is scheduled. However, since it is not observable from +JS if a promise is settled or not, actual continuation flow would be +undeterministic and exposes a new approach to inspect the promise internal +state. + +For the two type of context variables, the value at site (2): + +- For `AsyncContext.Variable`, the context is `'init'`. +- For `ContinuationFlow`, the context is still `'resolve'`. + +## Follow-up + +It has been generally agreed that the two type of context variables have their +own unique use cases and may co-exist. With this AsyncContext proposal advancing, +`ContinuationFlow` could be reified in a follow-up proposal. From 9022929ab281edd1989e05af12f469bf9d4c4285 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Mon, 1 Jul 2024 15:51:08 +0100 Subject: [PATCH 2/2] fixup! --- CONTINUATION.md | 241 ++++++++++++++++++++---------------------------- 1 file changed, 102 insertions(+), 139 deletions(-) diff --git a/CONTINUATION.md b/CONTINUATION.md index dda5a11..1eb429b 100644 --- a/CONTINUATION.md +++ b/CONTINUATION.md @@ -1,8 +1,8 @@ # Continuation flows -The proposal as it currently stands defines that a context variable by -propagating values to subtasks without feeding back any modifications in -subtasks to the context of their parent async task. +The proposal as it currently stands defines propagating context variables to +subtasks without feeding back any modifications in subtasks to the context of +their parent async task. ```js const asyncVar = new AsyncContext.Variable() @@ -54,24 +54,18 @@ const cf = new ContinuationFlow() cf.run('main', () => { readFile(file, () => { + // The continuation flow should be preserved here. cf.get() // => main }) }) function readFile(file, callback) { - // snapshot context at fs.open + const snapshot = new AsyncContext.Snapshot() + // readFile is composited with a series of sub-operations. fs.open(file, (err, fd) => { - // restore context before callback - - // snapshot context at fs.read fs.read(fd, (err, text) => { - // restore context before callback - - // snapshot context at fs.close fs.close(fd, (err) => { - // restore context before callback - - callback(err, text) + snapshot.run(callback. err, text) }) }) }) @@ -79,22 +73,26 @@ function readFile(file, callback) { ``` In the above example, callbacks can be composed naturally with the function -argument of `run(value, fn)` signature, and makes it possible to feedback +argument of `run(value, fn)` signature, and making it possible to feedback the modification of the context of subtasks to the callbacks from outer scope. ```js cf.run('main', () => { readFile(file, () => { - cf.get() // => last operation: fs.close + // The continuation flow is a continuation of the fs.close operation. + cf.get() // => `main -> fs.close` }) }) function readFile(file, callback) { + const snapshot = new AsyncContext.Snapshot() // ... // omit the other part fs.close(fd, (err) => { - cf.run('last operation: fs.close', () => { - callback(err, text) + snapshot.run(() => { + cf.run(`${cf.get()} -> fs.close`, () => { + callback(err, text) + }) }) }) } @@ -110,7 +108,7 @@ cf.run('main', main) async function main() { cf.get() // => 'main' - await ctx.run('inner', async () => { + await cf.run('inner', async () => { cf.get() // => 'inner' await task() cf.get() // => 'task-0' @@ -122,7 +120,7 @@ async function main() { let taskId = 0 async function task() { cf.get() // => 'inner' - await ctx.run(`task-${taskId++}`, async () => { + await cf.run(`task-${taskId++}`, async () => { const id = cf.get() // => 'task-0' // ... async operations await 1 @@ -183,13 +181,13 @@ picking one single winner and discarding all other states. | `Promise.any` | short-circuits when an input value is fulfilled | Short-circuit | Merge | To expand on the behaviors, given a task with the following definition, with -`ctx` being the corresponding context variable (`AsyncContext.Variable` and +`valueStore` being the corresponding context variable (`AsyncContext.Variable` and `ContinuationFlow`) in each semantic: ```js let taskId = 0 function randomTask() { - return ctx.run(`task-${taskId++}`, async () => { + return valueStore.run(`task-${taskId++}`, async () => { await scheduler.wait(Math.random * 10) if (Math.random() >= 0.5) { throw new Error() @@ -198,106 +196,50 @@ function randomTask() { } ``` -### `Promise.allSettled` - -It is a merge point on `Promise.allSettled`. It aggregates the promise results -that are either resolved or rejected. - -```js -ctx.run('main', async () => { - await Promise.allSettled(_.times(5).map((it) => randomTask())) - ctx.get() // => (1) -}) -``` - -From different observation perspectives, the value at site (1) would be -different on the above semantics: - -- For `AsyncContext.Variable`, it is `main`, -- For `ContinuationFlow`, the proposed solution didn't specify - conflicts-resolving yet. If the default behavior merges the values in an - array, it changes the shape of the value. - -### `Promise.all` +### Example -In the resolving path, `Promise.all` is similar to `Promise.allSettled` that it -is a merge point when all promises resolve. - -`Promise.all` picks the fastest reject promise. +We'll take `Promise.all` as an example since it merges when all promises +fulfills and take a short-circuit when any of the promises is rejected. ```js -ctx.run('main', async () => { +valueStore.run('main', async () => { try { await Promise.all(_.times(5).map((it) => randomTask())) - ctx.get() // => (1) - } catch (e) { - ctx.get() // => (2) - } -}) -``` - -The value at site (1): - -- For `AsyncContext.Variable`, it is `main`, -- For `ContinuationFlow`, the proposed solution didn't specify - conflicts-resolving yet. - -The value at site (2): - -- For `AsyncContext.Variable`, it is `main`, -- For `ContinuationFlow`, it is the one caused the rejection, and discarding - leaf contexts of promises that may have been fulfilled. - -### `Promise.race` - -`Promise.race` picks the fastest settled (either fulfilled or rejected) promise. - -```js -ctx.run('main', async () => { - try { - await Promise.race(_.times(5).map((it) => randomTask())) - ctx.get() // => (1) + valueStore.get() // => site (1) } catch (e) { - ctx.get() // => (2) + valueStore.get() // => site (2) } }) ``` -The value at sites (1) and (2): - -- For `AsyncContext.Variable`, it is `main`, -- For `ContinuationFlow`, it is the one caused the settlement. - -### `Promise.any` +From different observation perspectives, the value at site (1) would be +different on the above semantics. -In a happy path, `Promise.any` is similar to `Promise.race` that it picks the -fastest resolved promise. However, it aggregates the exception values when all -the promises are rejected. +For `AsyncContext.Variable`, it is `main`. -It is a merge point when exception values are aggregated. +For `ContinuationFlow`, it needs a mechanism to resolve the merge +conflicts, and pick a winner to be the default current context: ```js -ctx.run('main', async () => { - try { - await Promise.any(_.times(5).map((it) => randomTask())) - ctx.get() // => (1) - } catch (e) { - ctx.get() // => (2) - } -}) +await Promise.all([ + task({ id: 1, duration: 100 }), // (1) + task({ id: 2, duration: 1000 }), // (2) + task({ id: 3, duration: 100 }), // (3) +]) +valueStore.get() // site (4) +valueStore.getAggregated() // site (5) ``` -The value at site (1): - -- For `AsyncContext.Variable`, it is `main`, -- For `ContinuationFlow`, it is the one caused the fulfillment, and discarding - leaf contexts of promises that may have been rejected. +The return value at site (4) could be either the first one in the iterator, as +the context of task (1), or the last finished one in the iterator, as the +context of task (2). And the return value at site (5) could be an aggregated +array of all the values [(1), (2), (3)]. The value at site (2): - For `AsyncContext.Variable`, it is `main`, -- For `ContinuationFlow`, the proposed solution didn't specify - conflicts-resolving yet. +- For `ContinuationFlow`, it is the one caused the rejection, and discarding + leaf contexts of promises that may have been fulfilled. ### Graft promises from outer scope @@ -313,21 +255,21 @@ By awaiting a promise created outside of the current context, the two executions are grafted together and there is a merge point on `await`. ```js -const gPromise = ctx.run('global', () => { +const gPromise = valueStore.run('global', () => { // (1) return Promise.resolve(1) }) -ctx.run('main', main) +valueStore.run('main', main) // (2) async function main() { await gPromise // -> global --- // \ - // -> main ----> (1) - ctx.get() // (1) + // -> main ----> (3) + valueStore.get() // site (3) } ``` -The value at site (1): +The value at site (3): - For `AsyncContext.Variable`, it is `main`, - For `ContinuationFlow`, it is `global`, discarding the leaf context before @@ -348,22 +290,28 @@ with the following properties: Intuitively, the context of the event should be relevant to the promise instance attached to the `PromiseRejectionEvent` instance. +Or alternatively, a new `PromiseRejectionEvent` property can be defined as: + +- `asyncSnapshot`: the context relevant to the promise that being rejected. + For a promise created with the `PromiseConstructor` or `Promise.withResolvers`, the promise can be rejected in a different context: ```js let reject -let p1 // (1) -ctx.run('init', () => { +let p1 +valueStore.run('init', () => { // (1) ;({ p1, reject } = Promise.withResolvers()) }) -ctx.run('reject', () => { +valueStore.run('reject', () => { // (2) reject('error message') }) addEventListener('unhandledrejection', (event) => { - ctx.get() // (2) + event.asyncSnapshot.run(() => { + valueStore.get() // site (3) + }) }) ``` @@ -371,7 +319,8 @@ In this case, the `unhandledrejection` event will be dispatched with the promise instance `p1` and a string of `'error message'`, and the event is scheduled in the context of `'reject'`. -For the two type of context variables with `unhandledrejection` event of `p1`: +For the two type of context variables with `unhandledrejection` event of `p1` +at site (3): - For `AsyncContext.Variable`, the context is `'reject'`, where `p1` was rejected. - For `ContinuationFlow`, the context is `'reject'`, where `p1` was rejected. @@ -383,19 +332,21 @@ handler: ```js let reject -let p1 // (1) -ctx.run('init', () => { +let p1 +valueStore.run('init', () => { // (1) ;({ p1, reject } = Promise.withResolvers()) const p2 = p1 // p1 is not settled yet .then(undefined, undefined) }) -ctx.run('reject', () => { +valueStore.run('reject', () => { // (2) reject('error message') }) addEventListener('unhandledrejection', (event) => { - ctx.get() // (2) + event.asyncSnapshot.run(() => { + valueStore.get() // site (3) + }) }) ``` @@ -403,7 +354,7 @@ The `unhandledrejection` event will be dispatched with the promise instance `p2` with a string of `'error message'`. In this case, the event is scheduled in a new microtask which was scheduled in the context of `'reject'`. -For the two type of context variables, the value at site (2) with +For the two type of context variables, the value at site (3) with `unhandledrejection` event of `p2`: - For `AsyncContext.Variable`, the context is `'init'`, where `p2` @@ -415,18 +366,20 @@ For the two type of context variables, the value at site (2) with If handlers were attached to an already rejected promise: ```js -let p1 // (1) -ctx.run('reject', () => { +let p1 +valueStore.run('reject', () => { // (1) p1 = Promise.reject('error message') }) -ctx.run('init', () => { +valueStore.run('init', () => { // (2) const p2 = p1 // p1 is already rejected - .then(undefined, undefined) // (2) + .then(undefined, undefined) }) addEventListener('unhandledrejection', (event) => { - ctx.get() // (2) + event.asyncSnapshot.run(() => { + valueStore.get() // site (3) + }) }) ``` @@ -438,7 +391,7 @@ In this case, the `unhandledrejection` event is scheduled in the context where > This host hook didn't actually "schedule" the event dispatching, rather putting the > promise in a queue and the event is actually scheduled from [event loop](https://html.spec.whatwg.org/#notify-about-rejected-promises). -For the two type of context variables, the value at site (2) with +For the two type of context variables, the value at site (3) with `unhandledrejection` event of `p2`: - For `AsyncContext.Variable`, the context is `'init'`, where `p2` @@ -447,20 +400,21 @@ For the two type of context variables, the value at site (2) with ### Fulfilled promises -Similar to the rejected promise issue, the MOST relevant cause's context when -scheduling a microtask for newly created promise handlers are not -deterministic: +The two proposed semantics are not always following the most relevant cause's +context to reduce undeterministic behavior. Similar to the rejected promise +issue, the MOST relevant cause's context when scheduling a microtask for newly +created promise handlers is unobservable from JavaScript: ```js -let p1 // (1) -ctx.run('resolve', () => { +let p1 +valueStore.run('resolve', () => { // (1) p1 = Promise.resolve('yay') }) -ctx.run('init', () => { +valueStore.run('init', () => { // (2) const p2 = p1 // p1 is already resolved .then(() => { - ctx.get() // (2) + valueStore.get() // site (3) }) }) ``` @@ -483,26 +437,35 @@ const then = (p, onFulfill) => { } // ... } -let p1 // (1) -ctx.run('resolve', () => { +let p1 +valueStore.run('resolve', () => { // (1) p1 = Promise.resolve('yay') }) -ctx.run('init', () => { - const p2 = then(p1, undefined) // the promise is already resolved +valueStore.run('init', () => { // (2) + const p2 = then(p1, undefined) // p1 is already resolved + .then(() => { + valueStore.get() // site (3) + }) }) ``` -In this case, the MOST relevant context would be `'init'` since in this -context, the microtask is scheduled. However, since it is not observable from -JS if a promise is settled or not, actual continuation flow would be +The context at site (3) represents the context triggered the logical execution +of the promise fulfillment handler. In this case, the most relevant cause's +context at site (2) would be `'init'` since in this context, the microtask is +scheduled. + +However, since if a promise is settled or not is not observable from JS, a data +flow that always following the MOST relevant cause's context would be undeterministic and exposes a new approach to inspect the promise internal state. -For the two type of context variables, the value at site (2): +The two proposed semantics are not always following the most relevant cause's +context to reduce undeterministic behavior. And the values at site (2) are +regardless of whether the promise was fulfilled or not: -- For `AsyncContext.Variable`, the context is `'init'`. -- For `ContinuationFlow`, the context is still `'resolve'`. +- For `AsyncContext.Variable`, the context is constantly `'init'`. +- For `ContinuationFlow`, the context is constantly `'resolve'`. ## Follow-up