als-shim.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. /**
  2. * Behavioural check of the folding AsyncLocalStorage shim: the two shapes the
  3. * agent service actually uses (nested instances; a boundary whose operation
  4. * returns a promise), the hook layer that carries a registration context into a
  5. * callback, and the explicit-switch slots the module transform's `await`
  6. * rewriting drives.
  7. *
  8. * Layering, because three mechanisms answer `getStore()` and the cases below
  9. * pick them apart deliberately:
  10. * 1. the **folding stack** — `run()` boundaries, unwound by identity;
  11. * 2. the **hook layer** — patched `then`/timers, so a callback reads the store
  12. * from where it was REGISTERED rather than where it runs;
  13. * 3. the **explicit switch** — `__snapshotAll`/`__restoreAll` and the
  14. * `alsCausality` face, which the transformed modules reach at every
  15. * suspension point. This is the layer that survives true interleaving, and
  16. * case 17 is the one that shows the folding stack alone cannot.
  17. *
  18. * Scope boundary: this file owns the shim (the state). `als-runtime.spec.ts`
  19. * owns the protocol that moves snapshots around, with the causality face stubbed.
  20. *
  21. * Every import goes through the **package name**, not a relative path: a check
  22. * that reaches built `lib/` while the shim resolves by package name to `src/`
  23. * gets two module instances and a shim mounted in the wrong world (the failure
  24. * mode asserted in `../node/fs.spec.ts`). One resolution path per module.
  25. */
  26. import { expect, test } from 'vitest'
  27. import {
  28. AsyncLocalStorage, __restoreAll, __snapshotAll, alsCausality, runAtAsyncContextRoot,
  29. } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/async_hooks.ts'
  30. import { installAsyncContextHooks } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/polyfill/async-context/async-context-hooks.ts'
  31. import { installTimerGlobals } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/timers.ts'
  32. // Same order the worker entry uses: patch the platform, then wrap the timers over
  33. // the patched platform. The folding cases below must hold with both in place.
  34. installAsyncContextHooks()
  35. installTimerGlobals()
  36. // Both sides are serialized at call time, not inside the case: several blocks
  37. // below reuse a mutable array as the observed value, so a captured reference
  38. // would read a later block's state by the time the case executes.
  39. const check = (label: string, actual: unknown, expected: unknown): void => {
  40. const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
  41. test(label, () => { expect(seen).toBe(wanted) })
  42. }
  43. const delay = (ms = 0): Promise<void> => new Promise((resolve) => { setTimeout(resolve, ms) })
  44. // 0. The migration's own precondition: the hook layer really is installed over
  45. // this module instance. If the check and the shim ever resolve to two
  46. // instances again, the patched `then` below belongs to the other copy and
  47. // every hook-layer case would silently test an unpatched platform.
  48. {
  49. const als = new AsyncLocalStorage<string>()
  50. let seen: string | undefined = 'unset'
  51. als.run('installed', () => { void Promise.resolve().then(() => { seen = als.getStore() }) })
  52. await delay()
  53. check('hook layer is installed over this module instance', seen, 'installed')
  54. }
  55. // 1. Synchronous operation: visible inside, gone after.
  56. {
  57. const als = new AsyncLocalStorage<string>()
  58. const seen = als.run('sync', () => als.getStore())
  59. check('sync body sees store', seen, 'sync')
  60. check('sync boundary closes', als.getStore(), undefined)
  61. check('sync return value preserved', als.run('x', () => 42), 42)
  62. }
  63. // 2. The reason for the upgrade: the store survives awaits inside the operation.
  64. {
  65. const als = new AsyncLocalStorage<string>()
  66. const observed: (string | undefined)[] = []
  67. const operation = async (): Promise<void> => {
  68. observed.push(als.getStore())
  69. await delay()
  70. observed.push(als.getStore())
  71. await delay(5)
  72. observed.push(als.getStore())
  73. }
  74. const running = als.run('agent-1', operation)
  75. observed.push(als.getStore())
  76. await running
  77. await delay()
  78. check('store visible across awaits', observed, ['agent-1', 'agent-1', 'agent-1', 'agent-1'])
  79. check('boundary closes after settle', als.getStore(), undefined)
  80. }
  81. // 3. Rejection still closes the boundary, and the caller still sees the rejection.
  82. {
  83. const als = new AsyncLocalStorage<string>()
  84. const failing = als.run('doomed', async () => {
  85. await delay()
  86. throw new Error('operation failed')
  87. })
  88. const message = await failing.then(() => 'resolved', (error: unknown) => (error as Error).message)
  89. check('rejection propagates', message, 'operation failed')
  90. await delay()
  91. check('boundary closes after rejection', als.getStore(), undefined)
  92. }
  93. // 4. A synchronous throw closes the boundary too.
  94. {
  95. const als = new AsyncLocalStorage<string>()
  96. try {
  97. als.run('thrower', () => { throw new Error('sync failure') })
  98. } catch { /* expected */ }
  99. check('boundary closes after sync throw', als.getStore(), undefined)
  100. }
  101. // 5. Nested boundaries on ONE instance unwind by identity, innermost first.
  102. {
  103. const als = new AsyncLocalStorage<string>()
  104. const observed: (string | undefined)[] = []
  105. await als.run('outer', async () => {
  106. observed.push(als.getStore())
  107. await als.run('inner', async () => {
  108. await delay()
  109. observed.push(als.getStore())
  110. })
  111. await delay()
  112. observed.push(als.getStore())
  113. })
  114. await delay()
  115. check('nested unwind', observed, ['outer', 'inner', 'outer'])
  116. check('nested boundaries all closed', als.getStore(), undefined)
  117. }
  118. // 6. The agent service's real shape: two instances, the outer run returning the
  119. // inner run's promise (agent/src/index.ts:649).
  120. {
  121. const runs = new AsyncLocalStorage<{ id: number }>()
  122. const initiators = new AsyncLocalStorage<string>()
  123. const observed: unknown[] = []
  124. const operation = async (): Promise<void> => {
  125. await delay()
  126. // What requireInitiator() does, several awaits below the boundary.
  127. observed.push([initiators.getStore(), runs.getStore()?.id])
  128. }
  129. const parent = runs.getStore()
  130. check('parent chain empty at first boundary', parent, undefined)
  131. await runs.run({ id: 1 }, () => initiators.run('agent-1', operation))
  132. await delay()
  133. check('both instances answered inside', observed, [['agent-1', 1]])
  134. check('runs closed', runs.getStore(), undefined)
  135. check('initiators closed', initiators.getStore(), undefined)
  136. }
  137. // 7. exit()/withoutInitiator hides the inherited store and restores it.
  138. {
  139. const als = new AsyncLocalStorage<string>()
  140. const observed: (string | undefined)[] = []
  141. await als.run('agent-1', async () => {
  142. observed.push(als.getStore())
  143. await als.exit(async () => {
  144. await delay()
  145. observed.push(als.getStore())
  146. })
  147. observed.push(als.getStore())
  148. })
  149. await delay()
  150. check('exit hides then restores', observed, ['agent-1', undefined, 'agent-1'])
  151. }
  152. // 8. Interleaved boundaries: attribution follows the newest entry (the documented
  153. // single-concurrency limit) but nothing throws and the stack fully unwinds.
  154. {
  155. const als = new AsyncLocalStorage<string>()
  156. const seen: (string | undefined)[] = []
  157. const body = async (_label: string, ms: number): Promise<void> => {
  158. await delay(ms)
  159. seen.push(als.getStore())
  160. }
  161. const first = als.run('A', () => body('A', 20))
  162. const second = als.run('B', () => body('B', 5))
  163. await Promise.all([first, second])
  164. await delay()
  165. check('interleaved reads never crash', seen.length, 2)
  166. check('interleaved reads resolve to an open boundary', seen.every(entry => entry === 'A' || entry === 'B'), true)
  167. check('stack unwinds after interleaving', als.getStore(), undefined)
  168. }
  169. // 9. disable() drops everything, as teardown expects.
  170. {
  171. const als = new AsyncLocalStorage<string>()
  172. const pending = als.run('leaked', async () => { await delay(50) })
  173. als.disable()
  174. check('disable clears the stack', als.getStore(), undefined)
  175. await pending
  176. }
  177. // 10. HOOK LAYER: a `.then` callback reads the store from where it was REGISTERED,
  178. // even though the registering boundary is long closed by the time it runs.
  179. {
  180. const als = new AsyncLocalStorage<string>()
  181. let seen: string | undefined = 'unset'
  182. const promise = new Promise<void>((resolve) => { setTimeout(resolve, 10) })
  183. als.run('registrar', () => { void promise.then(() => { seen = als.getStore() }) })
  184. check('boundary closed before the callback runs', als.getStore(), undefined)
  185. await delay(30)
  186. check('then callback reads the registration store', seen, 'registrar')
  187. }
  188. // 11. HOOK LAYER under interleaving: each callback reads ITS OWN registration
  189. // store, which the folding stack alone could not distinguish.
  190. {
  191. const als = new AsyncLocalStorage<string>()
  192. const seen: (string | undefined)[] = []
  193. const register = (label: string, ms: number): void => {
  194. als.run(label, () => {
  195. setTimeout(() => { seen.push(`${label}:${String(als.getStore())}`) }, ms)
  196. void Promise.resolve().then(() => { seen.push(`${label}-then:${String(als.getStore())}`) })
  197. queueMicrotask(() => { seen.push(`${label}-micro:${String(als.getStore())}`) })
  198. })
  199. }
  200. register('A', 20)
  201. register('B', 5)
  202. await delay(40)
  203. check('interleaved timers read their own store', seen.filter((entry): entry is string => entry !== undefined && entry.includes(':')).sort(), [
  204. 'A-micro:A', 'A-then:A', 'A:A', 'B-micro:B', 'B-then:B', 'B:B',
  205. ])
  206. }
  207. // 12. `catch`/`finally` inherit the patched `then` (they invoke it on the receiver).
  208. {
  209. const als = new AsyncLocalStorage<string>()
  210. const seen: (string | undefined)[] = []
  211. const rejected = Promise.reject(new Error('boom'))
  212. const settled = als.run('handler', () => rejected
  213. .catch(() => { seen.push(als.getStore()) })
  214. .finally(() => { seen.push(als.getStore()) }))
  215. await settled
  216. await delay()
  217. check('catch and finally carry the registration store', seen, ['handler', 'handler'])
  218. }
  219. // 13. An empty handler slot stays empty: a rejection must not be swallowed by a
  220. // wrapper standing in for an absent fulfilled handler.
  221. {
  222. const als = new AsyncLocalStorage<string>()
  223. const outcome = await als.run('slots', () => Promise
  224. .reject(new Error('preserved'))
  225. .then(undefined, (error: unknown) => `caught:${(error as Error).message}`))
  226. check('empty fulfilled slot preserved', outcome, 'caught:preserved')
  227. const passthrough = await als.run('slots', () => Promise.resolve('value').then(undefined, () => 'wrong'))
  228. check('value passes an empty fulfilled slot', passthrough, 'value')
  229. }
  230. // 14. A boundary opened inside a restored callback owns its reads, and the overlay
  231. // comes back afterwards.
  232. {
  233. const als = new AsyncLocalStorage<string>()
  234. const seen: (string | undefined)[] = []
  235. als.run('outer', () => {
  236. queueMicrotask(() => {
  237. seen.push(als.getStore())
  238. als.run('inner', () => { seen.push(als.getStore()) })
  239. seen.push(als.getStore())
  240. })
  241. })
  242. await delay(10)
  243. check('nested run inside a restored callback', seen, ['outer', 'inner', 'outer'])
  244. }
  245. // 15. The tunnel entry's root context masks whatever was open before it.
  246. {
  247. const als = new AsyncLocalStorage<string>()
  248. let seen: string | undefined = 'unset'
  249. als.run('stale', () => {
  250. runAtAsyncContextRoot(() => { seen = als.getStore() })
  251. })
  252. check('root context masks an open boundary', seen, undefined)
  253. check('root context restores afterwards', als.getStore(), undefined)
  254. }
  255. // 16. Promises stay native: the patch wraps handlers, not the chain.
  256. {
  257. const als = new AsyncLocalStorage<string>()
  258. const chained = als.run('native', () => Promise.resolve(1).then(value => value + 1))
  259. check('then returns a native promise', chained instanceof Promise, true)
  260. check('chained value', await chained, 2)
  261. }
  262. // 17. EXPLICIT SWITCH: a resumed frame reads what its pause point read, even
  263. // while another boundary is open — this is what the loader's await rewriting
  264. // buys over the folding stack.
  265. {
  266. const als = new AsyncLocalStorage<string>()
  267. const seen: (string | undefined)[] = []
  268. // Frame A pauses inside its boundary…
  269. let paused: ReturnType<typeof __snapshotAll> | undefined
  270. als.run('A', () => { paused = __snapshotAll() })
  271. // …an unrelated boundary opens and stays open…
  272. const other = als.run('B', async () => { await delay(20) })
  273. seen.push(als.getStore())
  274. // …and frame A resumes: the ambient slot answers A, not the open B.
  275. const release = __restoreAll(paused!)
  276. seen.push(als.getStore())
  277. release()
  278. seen.push(als.getStore())
  279. await other
  280. await delay()
  281. check('resume answers the paused context', seen, ['B', 'A', 'B'])
  282. check('all slots empty afterwards', als.getStore(), undefined)
  283. }
  284. // 18. Two frames pausing and resuming alternately keep their own contexts. A
  285. // disposer only ever undoes ITS OWN publish: released while shadowed it is a
  286. // no-op (never clobbers the newer frame), and released on top it restores the
  287. // context it shadowed — the frame that owned that context re-publishes at its
  288. // next await anyway, and any new boundary shadows it.
  289. {
  290. const als = new AsyncLocalStorage<string>()
  291. const seen: string[] = []
  292. const pauseIn = (label: string): ReturnType<typeof __snapshotAll> => {
  293. let snapshot: ReturnType<typeof __snapshotAll> | undefined
  294. als.run(label, () => { snapshot = __snapshotAll() })
  295. return snapshot!
  296. }
  297. const first = pauseIn('one')
  298. const second = pauseIn('two')
  299. const releaseFirst = __restoreAll(first)
  300. seen.push(`first:${String(als.getStore())}`)
  301. const releaseSecond = __restoreAll(second)
  302. seen.push(`second:${String(als.getStore())}`)
  303. releaseFirst() // out of order on purpose
  304. seen.push(`afterFirstRelease:${String(als.getStore())}`)
  305. releaseSecond()
  306. seen.push(`afterSecondRelease:${String(als.getStore())}`)
  307. check('interleaved resumes keep their own context', seen, [
  308. 'first:one', 'second:two', 'afterFirstRelease:two', 'afterSecondRelease:one',
  309. ])
  310. }
  311. // 19. The ambient slot outranks the folding stack but not a hook overlay: the
  312. // documented slot order.
  313. {
  314. const als = new AsyncLocalStorage<string>()
  315. const seen: (string | undefined)[] = []
  316. let paused: ReturnType<typeof __snapshotAll> | undefined
  317. als.run('ambient', () => { paused = __snapshotAll() })
  318. als.run('stack', () => {
  319. const release = __restoreAll(paused!)
  320. seen.push(als.getStore())
  321. release()
  322. seen.push(als.getStore())
  323. })
  324. check('ambient outranks the stack, stack returns after release', seen, ['ambient', 'stack'])
  325. }
  326. // 20. The rewriter's face (`AlsCausality`: snapshot + void restore) replaces the
  327. // resumed slot instead of stacking, so repeated resumes cannot leak context.
  328. {
  329. const als = new AsyncLocalStorage<string>()
  330. const pauseIn = (label: string): ReturnType<typeof alsCausality.snapshot> => {
  331. let snapshot: ReturnType<typeof alsCausality.snapshot> | undefined
  332. als.run(label, () => { snapshot = alsCausality.snapshot() })
  333. return snapshot!
  334. }
  335. const one = pauseIn('one')
  336. const two = pauseIn('two')
  337. alsCausality.restore(one)
  338. check('void restore publishes the paused context', als.getStore(), 'one')
  339. alsCausality.restore(two)
  340. check('a later resume replaces it', als.getStore(), 'two')
  341. alsCausality.restore(one)
  342. check('resuming the first frame again republishes its own', als.getStore(), 'one')
  343. // A new boundary shadows the resumed slot, and the slot comes back after it.
  344. als.run('boundary', () => { check('boundary shadows the resumed slot', als.getStore(), 'boundary') })
  345. check('resumed slot returns after the boundary', als.getStore(), 'one')
  346. als.disable()
  347. check('disable clears the resumed slot', als.getStore(), undefined)
  348. }
  349. // 21. A rewritten frame's await round trip (pause → other work interleaves → resume)
  350. // is exactly what `AlsRuntime.pause/resume` does with this face.
  351. {
  352. const als = new AsyncLocalStorage<string>()
  353. const seen: (string | undefined)[] = []
  354. const paused = await als.run('frame', async () => {
  355. const captured = alsCausality.snapshot()
  356. await delay(10)
  357. return captured
  358. })
  359. const other = als.run('interleaved', async () => { await delay(30) })
  360. seen.push(als.getStore())
  361. alsCausality.restore(paused)
  362. seen.push(als.getStore())
  363. await other
  364. await delay()
  365. check('resume wins over an interleaved boundary', seen, ['interleaved', 'frame'])
  366. }