als-runtime.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. /**
  2. * Semantic check of the suspension runtime (`src/polyfill/async-context/als-runtime.ts`): the object the
  3. * transformed modules call at every suspension point.
  4. *
  5. * Scope boundary, and why this file does not need the Node-compatibility layer:
  6. * `als-runtime.ts` owns no state. It moves snapshots through an injected
  7. * {@link AlsCausality} face, and the state itself lives in the
  8. * `node:async_hooks` proxy. So the causality face is stubbed here with a
  9. * recording double, which makes the *ordering* contract — the part transformed
  10. * code depends on — directly observable:
  11. *
  12. * - `pause` captures BEFORE suspending (not after), so the snapshot belongs to
  13. * the frame that suspended;
  14. * - `resume` restores BEFORE returning or rethrowing, so the resumed frame's
  15. * first observable act is already in the right context;
  16. * - both completion paths do this, which is why the token always fulfills.
  17. *
  18. * The shim-backed end of the same contract (does a real AsyncLocalStorage
  19. * actually fold, do the hooks cover timers) is `als-shim.spec.ts`. This file is
  20. * the middle layer: the protocol, in isolation.
  21. */
  22. import { expect, test } from 'vitest'
  23. import { createAlsRuntime, type AlsCausality, type AlsToken } from '../../src/polyfill/async-context/als-runtime.ts'
  24. const check = (label: string, actual: unknown, expected: unknown): void => {
  25. const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
  26. test(label, () => { expect(seen).toBe(wanted) })
  27. }
  28. /**
  29. * A causality double standing in for the `node:async_hooks` proxy: one mutable
  30. * "current store" plus a log, so every snapshot/restore is observable in order.
  31. */
  32. function recordingCausality(): {
  33. readonly causality: AlsCausality
  34. readonly log: string[]
  35. current: string
  36. } {
  37. const state = {
  38. current: 'root',
  39. log: [] as string[],
  40. causality: {
  41. snapshot: (): unknown => {
  42. state.log.push(`snapshot:${state.current}`)
  43. return state.current
  44. },
  45. restore: (snapshot: unknown): void => {
  46. state.current = snapshot as string
  47. state.log.push(`restore:${state.current}`)
  48. },
  49. },
  50. }
  51. return state
  52. }
  53. // ---------------------------------------------------------------------------
  54. // 1. pause: capture before suspending, and always fulfill.
  55. // ---------------------------------------------------------------------------
  56. {
  57. const state = recordingCausality()
  58. const als = createAlsRuntime(state.causality)
  59. state.current = 'session-A'
  60. const pending = als.pause('value')
  61. // The capture is synchronous with the call, before any microtask can run: that
  62. // is what makes the snapshot belong to the suspending frame.
  63. check('pause captures synchronously, before suspending', state.log, ['snapshot:session-A'])
  64. // Something else runs on this thread while the frame is suspended.
  65. state.current = 'session-B'
  66. const token = await pending
  67. check('token reports fulfilment', token.ok, true)
  68. check('token carries the awaited value', token.value, 'value')
  69. check('token carries the snapshot taken at pause time', token.snapshot, 'session-A')
  70. check('pause does not restore by itself', state.current, 'session-B')
  71. }
  72. {
  73. // A rejection must travel INSIDE the token, so the token itself always
  74. // fulfills; otherwise `await __als.pause(x)` would throw before `resume` had a
  75. // chance to restore, and the catch clause would run in the wrong context.
  76. const state = recordingCausality()
  77. const als = createAlsRuntime(state.causality)
  78. state.current = 'session-R'
  79. const failure = new Error('boom')
  80. const token = await als.pause(Promise.reject(failure))
  81. check('a rejection does not reject the token', token.ok, false)
  82. check('the token carries the error', token.error, failure)
  83. check('the rejected token still carries the snapshot', token.snapshot, 'session-R')
  84. }
  85. {
  86. // Non-promise and thenable inputs both work: the rewrite wraps every `await`
  87. // operand, most of which are not promises.
  88. const als = createAlsRuntime(recordingCausality().causality)
  89. check('pause accepts a plain value', (await als.pause(7)).value, 7)
  90. check('pause accepts a thenable', (await als.pause({ then: (resolve: (v: unknown) => void) => { resolve('t') } })).value, 't')
  91. const nested = await als.pause(Promise.resolve(Promise.resolve('deep')))
  92. check('pause unwraps a nested promise', nested.value, 'deep')
  93. }
  94. // ---------------------------------------------------------------------------
  95. // 2. resume: restore before handing control back, on both paths.
  96. // ---------------------------------------------------------------------------
  97. {
  98. const state = recordingCausality()
  99. const als = createAlsRuntime(state.causality)
  100. state.current = 'session-A'
  101. const token = await als.pause('payload')
  102. state.current = 'someone-else'
  103. state.log.length = 0
  104. const value = als.resume(token)
  105. check('resume returns the value', value, 'payload')
  106. check('resume restored the captured snapshot', state.current, 'session-A')
  107. check('resume restores exactly once', state.log, ['restore:session-A'])
  108. }
  109. {
  110. // The rejection path restores too, and only then rethrows: a catch clause
  111. // must observe the caller's store.
  112. const state = recordingCausality()
  113. const als = createAlsRuntime(state.causality)
  114. state.current = 'session-C'
  115. const failure = new Error('nope')
  116. const token = await als.pause(Promise.reject(failure))
  117. state.current = 'someone-else'
  118. let caught: unknown
  119. try {
  120. als.resume(token)
  121. } catch (reason) {
  122. caught = reason
  123. }
  124. check('resume rethrows the original error', caught, failure)
  125. check('resume restored the context before rethrowing', state.current, 'session-C')
  126. }
  127. {
  128. // Two frames suspended at once must not cross: this is the single-threaded
  129. // shape of the concurrency bug the whole protocol exists to prevent.
  130. const state = recordingCausality()
  131. const als = createAlsRuntime(state.causality)
  132. state.current = 'lane-1'
  133. const first = als.pause('one')
  134. state.current = 'lane-2'
  135. const second = als.pause('two')
  136. const [tokenA, tokenB] = await Promise.all([first, second])
  137. state.current = 'root'
  138. check('interleaved pauses keep their own snapshots', [tokenA.snapshot, tokenB.snapshot], ['lane-1', 'lane-2'])
  139. als.resume(tokenA)
  140. check('resuming the first frame restores lane-1', state.current, 'lane-1')
  141. als.resume(tokenB)
  142. check('resuming the second frame restores lane-2', state.current, 'lane-2')
  143. }
  144. // ---------------------------------------------------------------------------
  145. // 3. snapshot / afterYield: the generator half.
  146. // ---------------------------------------------------------------------------
  147. {
  148. const state = recordingCausality()
  149. const als = createAlsRuntime(state.causality)
  150. state.current = 'gen-A'
  151. const captured = als.snapshot()
  152. check('snapshot returns the current store', captured, 'gen-A')
  153. // While suspended at a `yield`, the consumer may run anything.
  154. state.current = 'consumer'
  155. const sent = als.afterYield(captured, 'sent-value')
  156. check('afterYield passes the consumer value through unchanged', sent, 'sent-value')
  157. check('afterYield restores the generator context', state.current, 'gen-A')
  158. }
  159. {
  160. // afterYield must be transparent to every value shape, including undefined:
  161. // `yield x` with no `next(v)` sends undefined, and swallowing it would change
  162. // the generator's observable behaviour.
  163. const als = createAlsRuntime(recordingCausality().causality)
  164. check('afterYield passes undefined through', als.afterYield('s', undefined), undefined)
  165. check('afterYield passes null through', als.afterYield('s', null), null)
  166. const object = { a: 1 }
  167. check('afterYield passes an object through by identity', als.afterYield('s', object) === object, true)
  168. }
  169. // ---------------------------------------------------------------------------
  170. // 4. iterator: async sources pass through, sync sources are adapted.
  171. // ---------------------------------------------------------------------------
  172. {
  173. const als = createAlsRuntime(recordingCausality().causality)
  174. // An async iterable's own iterator is used directly (no wrapping), so its
  175. // `return`/`throw` stay whatever the source provided.
  176. const inner = { next: () => Promise.resolve({ done: true, value: undefined }) }
  177. const source = { [Symbol.asyncIterator]: () => inner }
  178. check('an async iterable yields its own iterator', als.iterator(source) === inner, true)
  179. }
  180. {
  181. const als = createAlsRuntime(recordingCausality().causality)
  182. // Async-from-sync: a sync iterator whose values are promises must be awaited,
  183. // because `for await` awaits each value.
  184. const source = {
  185. [Symbol.iterator]: () => [Promise.resolve('a'), Promise.resolve('b')][Symbol.iterator](),
  186. }
  187. const iterator = als.iterator(source)
  188. check('sync source step 1 is awaited', await iterator.next(), { done: false, value: 'a' })
  189. check('sync source step 2 is awaited', await iterator.next(), { done: false, value: 'b' })
  190. check('sync source reports completion', (await iterator.next()).done, true)
  191. }
  192. {
  193. const als = createAlsRuntime(recordingCausality().causality)
  194. // `return()` on the adapter must reach the sync iterator's own `return`,
  195. // because that is where a generator's `finally` runs.
  196. let closed = 0
  197. const source = {
  198. [Symbol.iterator]: () => ({
  199. next: () => ({ done: false, value: 1 }),
  200. return: (sent?: unknown) => {
  201. closed += 1
  202. return { done: true, value: sent }
  203. },
  204. }),
  205. }
  206. const iterator = als.iterator(source)
  207. await iterator.next()
  208. const result = await iterator.return?.('bye')
  209. check('adapter forwards return to the sync iterator', closed, 1)
  210. check('adapter reports the forwarded return result', result, { done: true, value: 'bye' })
  211. }
  212. {
  213. const als = createAlsRuntime(recordingCausality().causality)
  214. // A sync iterator with no `return` must not crash the adapter: plain array
  215. // iterators have one, but hand-rolled ones often do not.
  216. const iterator = als.iterator({ [Symbol.iterator]: () => ({ next: () => ({ done: true, value: undefined }) }) })
  217. check('adapter tolerates a sync iterator without return', await iterator.return?.(undefined), { done: true, value: undefined })
  218. }
  219. {
  220. const als = createAlsRuntime(recordingCausality().causality)
  221. // A non-iterable is a programming error in the transformed source, and must be
  222. // a loud TypeError rather than a silent empty loop.
  223. const rejects = (label: string, value: unknown): void => {
  224. let outcome: string
  225. try {
  226. als.iterator(value)
  227. outcome = 'no TypeError'
  228. } catch (reason) {
  229. outcome = reason instanceof TypeError ? 'TypeError' : `no TypeError: ${String(reason)}`
  230. }
  231. test(label, () => { expect(outcome).toBe('TypeError') })
  232. }
  233. rejects('a plain object is not iterable', {})
  234. rejects('a number is not iterable', 7)
  235. rejects('null is not iterable', null)
  236. rejects('undefined is not iterable', undefined)
  237. }
  238. // ---------------------------------------------------------------------------
  239. // 5. close: teardown that cannot itself become the failure.
  240. // ---------------------------------------------------------------------------
  241. {
  242. const als = createAlsRuntime(recordingCausality().causality)
  243. let closed = 0
  244. const iterator = {
  245. next: () => Promise.resolve({ done: true, value: undefined }),
  246. return: (): Promise<IteratorResult<unknown>> => {
  247. closed += 1
  248. return Promise.resolve({ done: true, value: 'closed' })
  249. },
  250. }
  251. check('close forwards the iterator result', await als.close(iterator), { done: true, value: 'closed' })
  252. check('close calls return exactly once', closed, 1)
  253. }
  254. {
  255. const als = createAlsRuntime(recordingCausality().causality)
  256. // An iterator that throws while closing has nothing left to release, and the
  257. // loop is already leaving: swallowing keeps the original failure (or the
  258. // `break`) as the observable outcome instead of masking it with a teardown error.
  259. const throwing = {
  260. next: () => Promise.resolve({ done: true, value: undefined }),
  261. return: (): Promise<IteratorResult<unknown>> => Promise.reject(new Error('teardown exploded')),
  262. }
  263. check('close swallows a failing return', await als.close(throwing), undefined)
  264. const synchronouslyThrowing = {
  265. next: () => Promise.resolve({ done: true, value: undefined }),
  266. return: (): Promise<IteratorResult<unknown>> => { throw new Error('teardown exploded synchronously') },
  267. }
  268. check('close swallows a synchronously throwing return', await als.close(synchronouslyThrowing), undefined)
  269. }
  270. {
  271. const als = createAlsRuntime(recordingCausality().causality)
  272. // No `return` at all: nothing to do, and no crash.
  273. check('close tolerates an iterator without return', await als.close({ next: () => Promise.resolve({ done: true, value: undefined }) }), undefined)
  274. }
  275. // ---------------------------------------------------------------------------
  276. // 6. The inert runtime. Without a causality face, the rewrite still runs and
  277. // still hops a microtask, but no state moves. A comparison arm built on this
  278. // mode must be genuinely inert, or the comparison proves nothing.
  279. // ---------------------------------------------------------------------------
  280. {
  281. const inert = createAlsRuntime()
  282. check('inert snapshot is undefined', inert.snapshot(), undefined)
  283. const token = await inert.pause('value')
  284. check('inert pause still fulfills with the value', [token.ok, token.value], [true, 'value'])
  285. check('inert pause carries an undefined snapshot', token.snapshot, undefined)
  286. check('inert resume still returns the value', inert.resume(token), 'value')
  287. // Failure semantics must not change with the causality face withheld —
  288. // otherwise the control arm would differ in error handling as well as in
  289. // context propagation, and the comparison would prove nothing.
  290. const failure = new Error('inert boom')
  291. const rejected = await inert.pause(Promise.reject(failure))
  292. check('inert pause reports rejection in the token', rejected.ok, false)
  293. let caught: unknown
  294. try {
  295. inert.resume(rejected)
  296. } catch (reason) {
  297. caught = reason
  298. }
  299. check('inert resume still rethrows', caught, failure)
  300. check('inert afterYield is still transparent', inert.afterYield(undefined, 'sent'), 'sent')
  301. // The iterator and close verbs are pure plumbing and must work identically.
  302. const iterator = inert.iterator({ [Symbol.iterator]: () => ['x'][Symbol.iterator]() })
  303. check('inert iterator still adapts a sync source', await iterator.next(), { done: false, value: 'x' })
  304. check('inert close still resolves', await inert.close({ next: () => Promise.resolve({ done: true, value: undefined }) }), undefined)
  305. }
  306. {
  307. // The one thing the inert arm must NOT do: keep a store alive across a
  308. // suspension. This is the assertion that gives the control arm its meaning.
  309. const inert = createAlsRuntime()
  310. const token = await inert.pause('v')
  311. const before = inert.snapshot()
  312. inert.resume(token)
  313. check('inert resume moves no state', [before, inert.snapshot()], [undefined, undefined])
  314. }
  315. // ---------------------------------------------------------------------------
  316. // 7. The two runtimes are independent instances (the loader builds one per
  317. // boot, and a stray shared closure would couple them).
  318. // ---------------------------------------------------------------------------
  319. {
  320. const first = recordingCausality()
  321. const second = recordingCausality()
  322. const alsA = createAlsRuntime(first.causality)
  323. const alsB = createAlsRuntime(second.causality)
  324. first.current = 'A'
  325. second.current = 'B'
  326. const tokenA = await alsA.pause(1)
  327. const tokenB = await alsB.pause(2)
  328. check('each runtime captures through its own causality face', [tokenA.snapshot, tokenB.snapshot], ['A', 'B'])
  329. first.current = 'moved'
  330. alsA.resume(tokenA)
  331. check('restoring through one runtime does not touch the other', [first.current, second.current], ['A', 'B'])
  332. }
  333. // ---------------------------------------------------------------------------
  334. // 8. The token shape the transform emits against, pinned as a type-level and
  335. // runtime contract (the emitted code reads `.ok`, `.value`, `.error`,
  336. // `.snapshot` directly).
  337. // ---------------------------------------------------------------------------
  338. {
  339. const als = createAlsRuntime(recordingCausality().causality)
  340. const fulfilled: AlsToken = await als.pause('v')
  341. check('a fulfilled token exposes ok/value/snapshot', Object.keys(fulfilled).sort(), ['ok', 'snapshot', 'value'])
  342. const rejected: AlsToken = await als.pause(Promise.reject(new Error('e')))
  343. check('a rejected token exposes ok/error/snapshot', Object.keys(rejected).sort(), ['error', 'ok', 'snapshot'])
  344. }