| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- /**
- * Semantic check of the suspension runtime (`src/polyfill/async-context/als-runtime.ts`): the object the
- * transformed modules call at every suspension point.
- *
- * Scope boundary, and why this file does not need the Node-compatibility layer:
- * `als-runtime.ts` owns no state. It moves snapshots through an injected
- * {@link AlsCausality} face, and the state itself lives in the
- * `node:async_hooks` proxy. So the causality face is stubbed here with a
- * recording double, which makes the *ordering* contract — the part transformed
- * code depends on — directly observable:
- *
- * - `pause` captures BEFORE suspending (not after), so the snapshot belongs to
- * the frame that suspended;
- * - `resume` restores BEFORE returning or rethrowing, so the resumed frame's
- * first observable act is already in the right context;
- * - both completion paths do this, which is why the token always fulfills.
- *
- * The shim-backed end of the same contract (does a real AsyncLocalStorage
- * actually fold, do the hooks cover timers) is `als-shim.spec.ts`. This file is
- * the middle layer: the protocol, in isolation.
- */
- import { expect, test } from 'vitest'
- import { createAlsRuntime, type AlsCausality, type AlsToken } from '../../src/polyfill/async-context/als-runtime.ts'
- const check = (label: string, actual: unknown, expected: unknown): void => {
- const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
- test(label, () => { expect(seen).toBe(wanted) })
- }
- /**
- * A causality double standing in for the `node:async_hooks` proxy: one mutable
- * "current store" plus a log, so every snapshot/restore is observable in order.
- */
- function recordingCausality(): {
- readonly causality: AlsCausality
- readonly log: string[]
- current: string
- } {
- const state = {
- current: 'root',
- log: [] as string[],
- causality: {
- snapshot: (): unknown => {
- state.log.push(`snapshot:${state.current}`)
- return state.current
- },
- restore: (snapshot: unknown): void => {
- state.current = snapshot as string
- state.log.push(`restore:${state.current}`)
- },
- },
- }
- return state
- }
- // ---------------------------------------------------------------------------
- // 1. pause: capture before suspending, and always fulfill.
- // ---------------------------------------------------------------------------
- {
- const state = recordingCausality()
- const als = createAlsRuntime(state.causality)
- state.current = 'session-A'
- const pending = als.pause('value')
- // The capture is synchronous with the call, before any microtask can run: that
- // is what makes the snapshot belong to the suspending frame.
- check('pause captures synchronously, before suspending', state.log, ['snapshot:session-A'])
- // Something else runs on this thread while the frame is suspended.
- state.current = 'session-B'
- const token = await pending
- check('token reports fulfilment', token.ok, true)
- check('token carries the awaited value', token.value, 'value')
- check('token carries the snapshot taken at pause time', token.snapshot, 'session-A')
- check('pause does not restore by itself', state.current, 'session-B')
- }
- {
- // A rejection must travel INSIDE the token, so the token itself always
- // fulfills; otherwise `await __als.pause(x)` would throw before `resume` had a
- // chance to restore, and the catch clause would run in the wrong context.
- const state = recordingCausality()
- const als = createAlsRuntime(state.causality)
- state.current = 'session-R'
- const failure = new Error('boom')
- const token = await als.pause(Promise.reject(failure))
- check('a rejection does not reject the token', token.ok, false)
- check('the token carries the error', token.error, failure)
- check('the rejected token still carries the snapshot', token.snapshot, 'session-R')
- }
- {
- // Non-promise and thenable inputs both work: the rewrite wraps every `await`
- // operand, most of which are not promises.
- const als = createAlsRuntime(recordingCausality().causality)
- check('pause accepts a plain value', (await als.pause(7)).value, 7)
- check('pause accepts a thenable', (await als.pause({ then: (resolve: (v: unknown) => void) => { resolve('t') } })).value, 't')
- const nested = await als.pause(Promise.resolve(Promise.resolve('deep')))
- check('pause unwraps a nested promise', nested.value, 'deep')
- }
- // ---------------------------------------------------------------------------
- // 2. resume: restore before handing control back, on both paths.
- // ---------------------------------------------------------------------------
- {
- const state = recordingCausality()
- const als = createAlsRuntime(state.causality)
- state.current = 'session-A'
- const token = await als.pause('payload')
- state.current = 'someone-else'
- state.log.length = 0
- const value = als.resume(token)
- check('resume returns the value', value, 'payload')
- check('resume restored the captured snapshot', state.current, 'session-A')
- check('resume restores exactly once', state.log, ['restore:session-A'])
- }
- {
- // The rejection path restores too, and only then rethrows: a catch clause
- // must observe the caller's store.
- const state = recordingCausality()
- const als = createAlsRuntime(state.causality)
- state.current = 'session-C'
- const failure = new Error('nope')
- const token = await als.pause(Promise.reject(failure))
- state.current = 'someone-else'
- let caught: unknown
- try {
- als.resume(token)
- } catch (reason) {
- caught = reason
- }
- check('resume rethrows the original error', caught, failure)
- check('resume restored the context before rethrowing', state.current, 'session-C')
- }
- {
- // Two frames suspended at once must not cross: this is the single-threaded
- // shape of the concurrency bug the whole protocol exists to prevent.
- const state = recordingCausality()
- const als = createAlsRuntime(state.causality)
- state.current = 'lane-1'
- const first = als.pause('one')
- state.current = 'lane-2'
- const second = als.pause('two')
- const [tokenA, tokenB] = await Promise.all([first, second])
- state.current = 'root'
- check('interleaved pauses keep their own snapshots', [tokenA.snapshot, tokenB.snapshot], ['lane-1', 'lane-2'])
- als.resume(tokenA)
- check('resuming the first frame restores lane-1', state.current, 'lane-1')
- als.resume(tokenB)
- check('resuming the second frame restores lane-2', state.current, 'lane-2')
- }
- // ---------------------------------------------------------------------------
- // 3. snapshot / afterYield: the generator half.
- // ---------------------------------------------------------------------------
- {
- const state = recordingCausality()
- const als = createAlsRuntime(state.causality)
- state.current = 'gen-A'
- const captured = als.snapshot()
- check('snapshot returns the current store', captured, 'gen-A')
- // While suspended at a `yield`, the consumer may run anything.
- state.current = 'consumer'
- const sent = als.afterYield(captured, 'sent-value')
- check('afterYield passes the consumer value through unchanged', sent, 'sent-value')
- check('afterYield restores the generator context', state.current, 'gen-A')
- }
- {
- // afterYield must be transparent to every value shape, including undefined:
- // `yield x` with no `next(v)` sends undefined, and swallowing it would change
- // the generator's observable behaviour.
- const als = createAlsRuntime(recordingCausality().causality)
- check('afterYield passes undefined through', als.afterYield('s', undefined), undefined)
- check('afterYield passes null through', als.afterYield('s', null), null)
- const object = { a: 1 }
- check('afterYield passes an object through by identity', als.afterYield('s', object) === object, true)
- }
- // ---------------------------------------------------------------------------
- // 4. iterator: async sources pass through, sync sources are adapted.
- // ---------------------------------------------------------------------------
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // An async iterable's own iterator is used directly (no wrapping), so its
- // `return`/`throw` stay whatever the source provided.
- const inner = { next: () => Promise.resolve({ done: true, value: undefined }) }
- const source = { [Symbol.asyncIterator]: () => inner }
- check('an async iterable yields its own iterator', als.iterator(source) === inner, true)
- }
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // Async-from-sync: a sync iterator whose values are promises must be awaited,
- // because `for await` awaits each value.
- const source = {
- [Symbol.iterator]: () => [Promise.resolve('a'), Promise.resolve('b')][Symbol.iterator](),
- }
- const iterator = als.iterator(source)
- check('sync source step 1 is awaited', await iterator.next(), { done: false, value: 'a' })
- check('sync source step 2 is awaited', await iterator.next(), { done: false, value: 'b' })
- check('sync source reports completion', (await iterator.next()).done, true)
- }
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // `return()` on the adapter must reach the sync iterator's own `return`,
- // because that is where a generator's `finally` runs.
- let closed = 0
- const source = {
- [Symbol.iterator]: () => ({
- next: () => ({ done: false, value: 1 }),
- return: (sent?: unknown) => {
- closed += 1
- return { done: true, value: sent }
- },
- }),
- }
- const iterator = als.iterator(source)
- await iterator.next()
- const result = await iterator.return?.('bye')
- check('adapter forwards return to the sync iterator', closed, 1)
- check('adapter reports the forwarded return result', result, { done: true, value: 'bye' })
- }
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // A sync iterator with no `return` must not crash the adapter: plain array
- // iterators have one, but hand-rolled ones often do not.
- const iterator = als.iterator({ [Symbol.iterator]: () => ({ next: () => ({ done: true, value: undefined }) }) })
- check('adapter tolerates a sync iterator without return', await iterator.return?.(undefined), { done: true, value: undefined })
- }
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // A non-iterable is a programming error in the transformed source, and must be
- // a loud TypeError rather than a silent empty loop.
- const rejects = (label: string, value: unknown): void => {
- let outcome: string
- try {
- als.iterator(value)
- outcome = 'no TypeError'
- } catch (reason) {
- outcome = reason instanceof TypeError ? 'TypeError' : `no TypeError: ${String(reason)}`
- }
- test(label, () => { expect(outcome).toBe('TypeError') })
- }
- rejects('a plain object is not iterable', {})
- rejects('a number is not iterable', 7)
- rejects('null is not iterable', null)
- rejects('undefined is not iterable', undefined)
- }
- // ---------------------------------------------------------------------------
- // 5. close: teardown that cannot itself become the failure.
- // ---------------------------------------------------------------------------
- {
- const als = createAlsRuntime(recordingCausality().causality)
- let closed = 0
- const iterator = {
- next: () => Promise.resolve({ done: true, value: undefined }),
- return: (): Promise<IteratorResult<unknown>> => {
- closed += 1
- return Promise.resolve({ done: true, value: 'closed' })
- },
- }
- check('close forwards the iterator result', await als.close(iterator), { done: true, value: 'closed' })
- check('close calls return exactly once', closed, 1)
- }
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // An iterator that throws while closing has nothing left to release, and the
- // loop is already leaving: swallowing keeps the original failure (or the
- // `break`) as the observable outcome instead of masking it with a teardown error.
- const throwing = {
- next: () => Promise.resolve({ done: true, value: undefined }),
- return: (): Promise<IteratorResult<unknown>> => Promise.reject(new Error('teardown exploded')),
- }
- check('close swallows a failing return', await als.close(throwing), undefined)
- const synchronouslyThrowing = {
- next: () => Promise.resolve({ done: true, value: undefined }),
- return: (): Promise<IteratorResult<unknown>> => { throw new Error('teardown exploded synchronously') },
- }
- check('close swallows a synchronously throwing return', await als.close(synchronouslyThrowing), undefined)
- }
- {
- const als = createAlsRuntime(recordingCausality().causality)
- // No `return` at all: nothing to do, and no crash.
- check('close tolerates an iterator without return', await als.close({ next: () => Promise.resolve({ done: true, value: undefined }) }), undefined)
- }
- // ---------------------------------------------------------------------------
- // 6. The inert runtime. Without a causality face, the rewrite still runs and
- // still hops a microtask, but no state moves. A comparison arm built on this
- // mode must be genuinely inert, or the comparison proves nothing.
- // ---------------------------------------------------------------------------
- {
- const inert = createAlsRuntime()
- check('inert snapshot is undefined', inert.snapshot(), undefined)
- const token = await inert.pause('value')
- check('inert pause still fulfills with the value', [token.ok, token.value], [true, 'value'])
- check('inert pause carries an undefined snapshot', token.snapshot, undefined)
- check('inert resume still returns the value', inert.resume(token), 'value')
- // Failure semantics must not change with the causality face withheld —
- // otherwise the control arm would differ in error handling as well as in
- // context propagation, and the comparison would prove nothing.
- const failure = new Error('inert boom')
- const rejected = await inert.pause(Promise.reject(failure))
- check('inert pause reports rejection in the token', rejected.ok, false)
- let caught: unknown
- try {
- inert.resume(rejected)
- } catch (reason) {
- caught = reason
- }
- check('inert resume still rethrows', caught, failure)
- check('inert afterYield is still transparent', inert.afterYield(undefined, 'sent'), 'sent')
- // The iterator and close verbs are pure plumbing and must work identically.
- const iterator = inert.iterator({ [Symbol.iterator]: () => ['x'][Symbol.iterator]() })
- check('inert iterator still adapts a sync source', await iterator.next(), { done: false, value: 'x' })
- check('inert close still resolves', await inert.close({ next: () => Promise.resolve({ done: true, value: undefined }) }), undefined)
- }
- {
- // The one thing the inert arm must NOT do: keep a store alive across a
- // suspension. This is the assertion that gives the control arm its meaning.
- const inert = createAlsRuntime()
- const token = await inert.pause('v')
- const before = inert.snapshot()
- inert.resume(token)
- check('inert resume moves no state', [before, inert.snapshot()], [undefined, undefined])
- }
- // ---------------------------------------------------------------------------
- // 7. The two runtimes are independent instances (the loader builds one per
- // boot, and a stray shared closure would couple them).
- // ---------------------------------------------------------------------------
- {
- const first = recordingCausality()
- const second = recordingCausality()
- const alsA = createAlsRuntime(first.causality)
- const alsB = createAlsRuntime(second.causality)
- first.current = 'A'
- second.current = 'B'
- const tokenA = await alsA.pause(1)
- const tokenB = await alsB.pause(2)
- check('each runtime captures through its own causality face', [tokenA.snapshot, tokenB.snapshot], ['A', 'B'])
- first.current = 'moved'
- alsA.resume(tokenA)
- check('restoring through one runtime does not touch the other', [first.current, second.current], ['A', 'B'])
- }
- // ---------------------------------------------------------------------------
- // 8. The token shape the transform emits against, pinned as a type-level and
- // runtime contract (the emitted code reads `.ok`, `.value`, `.error`,
- // `.snapshot` directly).
- // ---------------------------------------------------------------------------
- {
- const als = createAlsRuntime(recordingCausality().causality)
- const fulfilled: AlsToken = await als.pause('v')
- check('a fulfilled token exposes ok/value/snapshot', Object.keys(fulfilled).sort(), ['ok', 'snapshot', 'value'])
- const rejected: AlsToken = await als.pause(Promise.reject(new Error('e')))
- check('a rejected token exposes ok/error/snapshot', Object.keys(rejected).sort(), ['error', 'ok', 'snapshot'])
- }
|