fetch-http.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  3. import { AddressInfo } from 'node:net'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import WebRuntime from '@deepseek-ai/dsh-web'
  6. import { HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-http'
  7. import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http'
  8. import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-http'
  9. import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '../src/policy.ts'
  10. const limits: HttpFetchLimits = {
  11. maxUrlLength: 2048,
  12. maxResponseBytes: 5_000_000,
  13. maxBodyChars: 100_000,
  14. timeoutMs: 5_000,
  15. maxRedirects: 5,
  16. userAgent: 'test-agent/1.0',
  17. }
  18. type Handler = (req: IncomingMessage, res: ServerResponse) => void
  19. let server: Server
  20. let base: string
  21. let handler: Handler
  22. beforeEach(async () => {
  23. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('default') }
  24. server = createServer((req, res) => { handler(req, res) })
  25. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  26. const { port } = server.address() as AddressInfo
  27. base = `http://127.0.0.1:${port}`
  28. })
  29. afterEach(async () => {
  30. vi.unstubAllGlobals()
  31. await new Promise<void>(resolve => server.close(() => { resolve() }))
  32. })
  33. function provider(overrides: Partial<HttpFetchLimits> = {}): HttpFetchProvider {
  34. return new HttpFetchProvider({ ...limits, ...overrides })
  35. }
  36. describe('policy helpers', () => {
  37. it('validates scheme, credentials, and length', () => {
  38. expect(validateFetchUrl('https://example.com/x', 2048).hostname).toBe('example.com')
  39. expect(() => validateFetchUrl('ftp://example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  40. expect(() => validateFetchUrl('not a url', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  41. expect(() => validateFetchUrl('https://user:pass@example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  42. expect(() => validateFetchUrl(`https://example.com/${'a'.repeat(3000)}`, 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  43. })
  44. it('classifies content types', () => {
  45. expect(classifyContentType('text/html; charset=utf-8')).toBe('html')
  46. expect(classifyContentType('application/xhtml+xml')).toBe('html')
  47. expect(classifyContentType('text/plain')).toBe('text')
  48. expect(classifyContentType('application/json')).toBe('text')
  49. expect(classifyContentType('image/png')).toBeUndefined()
  50. expect(classifyContentType(null)).toBeUndefined()
  51. })
  52. it('compares origins', () => {
  53. expect(isSameOrigin(new URL('https://a.com/x'), new URL('https://a.com/y'))).toBe(true)
  54. expect(isSameOrigin(new URL('https://a.com'), new URL('https://b.com'))).toBe(false)
  55. expect(isSameOrigin(new URL('http://a.com'), new URL('https://a.com'))).toBe(false)
  56. })
  57. it('parses the charset parameter', () => {
  58. expect(parseCharset('text/html; charset=UTF-8')).toBe('utf-8')
  59. expect(parseCharset('text/plain; charset="iso-8859-1"')).toBe('iso-8859-1')
  60. expect(parseCharset('text/plain')).toBeUndefined()
  61. expect(parseCharset(null)).toBeUndefined()
  62. })
  63. it('builds a decoder for a charset and defaults to UTF-8', () => {
  64. expect(decoderForCharset(undefined).encoding).toBe('utf-8')
  65. expect(decoderForCharset('iso-8859-1').encoding).toBe('windows-1252')
  66. expect(() => decoderForCharset('not-a-charset')).toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  67. })
  68. })
  69. describe('HttpFetchProvider success', () => {
  70. it('fetches a text body', async () => {
  71. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
  72. const result = await provider().fetch({ url: base })
  73. expect(provider().available()).toBe(true)
  74. expect(result.statusCode).toBe(200)
  75. expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
  76. expect(result.truncated).toBe(false)
  77. })
  78. it('fetches an html body and classifies it as html', async () => {
  79. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>hi</h1>') }
  80. const result = await provider().fetch({ url: base })
  81. expect(result.body).toEqual({ kind: 'html', content: '<h1>hi</h1>' })
  82. })
  83. it('sends the configured user agent', async () => {
  84. let seen: string | undefined
  85. handler = (req, res) => { seen = req.headers['user-agent']; res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
  86. await provider().fetch({ url: base })
  87. expect(seen).toBe('test-agent/1.0')
  88. })
  89. it('returns a non-2xx response as a result, not an error', async () => {
  90. handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('nope') }
  91. const result = await provider().fetch({ url: base })
  92. expect(result.statusCode).toBe(404)
  93. expect(result.body).toEqual({ kind: 'text', content: 'nope' })
  94. })
  95. })
  96. describe('HttpFetchProvider caps', () => {
  97. it('rejects an over-cap Content-Length with WEB_FETCH_TOO_LARGE', async () => {
  98. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '999999' }); res.end('x'.repeat(999999)) }
  99. await expect(provider({ maxResponseBytes: 10 }).fetch({ url: base }))
  100. .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TOO_LARGE' }))
  101. })
  102. it('truncates a stream that grows past the byte cap', async () => {
  103. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') }
  104. const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base })
  105. expect(result.body.content).toBe('abcd')
  106. expect(result.truncated).toBe(true)
  107. })
  108. it('does not flag a body that exactly fills the byte cap as truncated', async () => {
  109. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcd') }
  110. const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base })
  111. expect(result.body.content).toBe('abcd')
  112. expect(result.truncated).toBe(false)
  113. })
  114. it('truncates a decoded body past the character cap', async () => {
  115. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') }
  116. const result = await provider({ maxBodyChars: 3 }).fetch({ url: base })
  117. expect(result.body.content).toBe('abc')
  118. expect(result.truncated).toBe(true)
  119. })
  120. it('rejects an unsupported content type', async () => {
  121. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'image/png' }); res.end('binary') }
  122. await expect(provider().fetch({ url: base }))
  123. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  124. })
  125. it('rejects a response with no content type at all', async () => {
  126. handler = (_req, res) => { res.writeHead(200); res.end('no type') }
  127. await expect(provider().fetch({ url: base }))
  128. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  129. })
  130. it('accepts a declared content-length within the cap', async () => {
  131. handler = (_req, res) => { const body = 'sized'; res.writeHead(200, { 'content-type': 'text/plain', 'content-length': String(body.length) }); res.end(body) }
  132. const result = await provider().fetch({ url: base })
  133. expect(result.body.content).toBe('sized')
  134. })
  135. it('decodes a non-UTF-8 declared charset', async () => {
  136. // 0xE9 is "é" in ISO-8859-1; decoded as UTF-8 it would be a replacement char.
  137. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=iso-8859-1' }); res.end(Buffer.from([0x63, 0x61, 0x66, 0xE9])) }
  138. const result = await provider().fetch({ url: base })
  139. expect(result.body.content).toBe('café')
  140. })
  141. it('rejects an unsupported declared charset', async () => {
  142. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=not-a-charset' }); res.end('x') }
  143. await expect(provider().fetch({ url: base }))
  144. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  145. })
  146. })
  147. describe('HttpFetchProvider redirects', () => {
  148. it('follows a same-origin redirect and reports the final URL', async () => {
  149. handler = (req, res) => {
  150. if (req.url === '/start') { res.writeHead(302, { location: '/end' }); res.end() }
  151. else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('arrived') }
  152. }
  153. const result = await provider().fetch({ url: `${base}/start` })
  154. expect(result.body.content).toBe('arrived')
  155. expect(result.url).toBe(`${base}/end`)
  156. })
  157. it('blocks a cross-origin redirect with WEB_REDIRECT_BLOCKED', async () => {
  158. handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() }
  159. await expect(provider().fetch({ url: base }))
  160. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  161. })
  162. it('re-validates a redirect target, rejecting same-origin credentials in the Location', async () => {
  163. const { port } = server.address() as AddressInfo
  164. handler = (_req, res) => { res.writeHead(302, { location: `http://user:pass@127.0.0.1:${port}/` }); res.end() }
  165. await expect(provider().fetch({ url: base }))
  166. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  167. })
  168. it('rejects exceeding the redirect hop cap', async () => {
  169. handler = (req, res) => {
  170. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  171. res.writeHead(302, { location: `/?n=${n + 1}` })
  172. res.end()
  173. }
  174. await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }))
  175. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  176. })
  177. it('follows exactly maxRedirects hops: a chain landing on the Nth redirect succeeds', async () => {
  178. // maxRedirects: 2 → /?n=0 → /?n=1 → /?n=2(200). Exactly 2 redirects + 1
  179. // final = 3 requests; the cap is inclusive of the landing request.
  180. let requests = 0
  181. handler = (req, res) => {
  182. requests++
  183. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  184. if (n >= 2) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') }
  185. else { res.writeHead(302, { location: `/?n=${n + 1}` }); res.end() }
  186. }
  187. const result = await provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })
  188. expect(result.body.content).toBe('landed')
  189. expect(requests).toBe(3)
  190. })
  191. it('makes exactly maxRedirects+1 requests before blocking an over-long chain', async () => {
  192. // maxRedirects: 2 on an infinite chain: requests at n=0,1,2 (the 3rd is the
  193. // over-limit redirect, refused before its Location is followed) = 3 total.
  194. let requests = 0
  195. handler = (req, res) => {
  196. requests++
  197. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  198. res.writeHead(302, { location: `/?n=${n + 1}` })
  199. res.end()
  200. }
  201. await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }))
  202. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 2 redirects' }))
  203. expect(requests).toBe(3)
  204. })
  205. it('reports an over-limit redirect as "exceeded", not cross-origin, even when the over-limit hop points cross-origin', async () => {
  206. // The redirect budget is checked BEFORE the over-limit hop's target is
  207. // origin-validated, so the diagnosis is "exceeded", not "cross-origin".
  208. handler = (req, res) => {
  209. const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
  210. const location = n === 0 ? '/?n=1' : 'https://example.com/'
  211. res.writeHead(302, { location })
  212. res.end()
  213. }
  214. await expect(provider({ maxRedirects: 1 }).fetch({ url: `${base}/?n=0` }))
  215. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 1 redirects' }))
  216. })
  217. it('maxRedirects: 0 follows no redirect but still fetches a direct 200', async () => {
  218. handler = (req, res) => {
  219. if (req.url === '/r') { res.writeHead(302, { location: '/done' }); res.end() }
  220. else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('direct') }
  221. }
  222. await expect(provider({ maxRedirects: 0 }).fetch({ url: `${base}/r` }))
  223. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  224. const direct = await provider({ maxRedirects: 0 }).fetch({ url: `${base}/done` })
  225. expect(direct.body.content).toBe('direct')
  226. })
  227. it('treats a redirect without a Location header as a provider error', async () => {
  228. handler = (_req, res) => { res.writeHead(302); res.end() }
  229. await expect(provider().fetch({ url: base }))
  230. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  231. })
  232. it('follows a relative same-origin redirect', async () => {
  233. handler = (req, res) => {
  234. if (req.url === '/a') { res.writeHead(301, { location: 'b' }); res.end() }
  235. else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') }
  236. }
  237. const result = await provider().fetch({ url: `${base}/a` })
  238. expect(result.body.content).toBe('landed')
  239. })
  240. })
  241. describe('HttpFetchProvider invalid URLs and abort', () => {
  242. it('rejects a non-http scheme before any network access', async () => {
  243. await expect(provider().fetch({ url: 'ftp://example.com' }))
  244. .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  245. })
  246. it('rejects credentials in the URL', async () => {
  247. await expect(provider().fetch({ url: 'http://user:pass@127.0.0.1/' }))
  248. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  249. })
  250. it('honors a pre-aborted signal', async () => {
  251. const controller = new AbortController()
  252. controller.abort()
  253. await expect(provider().fetch({ url: base }, controller.signal))
  254. .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  255. })
  256. it('aborts an in-flight fetch via the signal', async () => {
  257. handler = (_req, _res) => { /* never responds */ }
  258. const controller = new AbortController()
  259. const promise = provider().fetch({ url: base }, controller.signal)
  260. controller.abort()
  261. await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
  262. })
  263. it('times out a slow response with WEB_FETCH_TIMEOUT', async () => {
  264. handler = (_req, _res) => { /* never responds */ }
  265. await expect(provider({ timeoutMs: 50 }).fetch({ url: base }))
  266. .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
  267. })
  268. it('classifies a timeout DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED', async () => {
  269. // Promise body that resolves headers (so fetch() returns) but a content-length
  270. // that outlasts the bytes sent, so readCapped()'s reader awaits more and the
  271. // timeout fires mid-read — the reader then surfaces a generic AbortError that
  272. // must still be recovered as the timeout reason via signal.reason.
  273. handler = (_req, res) => {
  274. res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '100' })
  275. res.write('partial')
  276. // never send the remaining bytes nor end the response
  277. }
  278. await expect(provider({ timeoutMs: 80 }).fetch({ url: base }))
  279. .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
  280. })
  281. it('maps a connection failure to WEB_PROVIDER_ERROR', async () => {
  282. // Port 1 on loopback is not listening: a real connection failure (not abort).
  283. await expect(provider().fetch({ url: 'http://127.0.0.1:1/' }))
  284. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  285. })
  286. })
  287. describe('HttpFetchProvider body cancellation on error paths', () => {
  288. /** A fake Response whose body.cancel is observable. */
  289. type FakeInit = { status: number; headers: Record<string, string>; location?: string }
  290. function fakeResponse(init: FakeInit): { response: Response; cancelled: () => boolean } {
  291. let cancelled = false
  292. const headers = new Headers(init.headers)
  293. if (init.location !== undefined) headers.set('location', init.location)
  294. const response = {
  295. status: init.status,
  296. headers,
  297. body: { cancel: () => { cancelled = true; return Promise.resolve() } },
  298. } as unknown as Response
  299. return { response, cancelled: () => cancelled }
  300. }
  301. it('cancels the body when a cross-origin redirect is blocked', async () => {
  302. const { response, cancelled } = fakeResponse({ status: 302, headers: {}, location: 'https://elsewhere.test/' })
  303. vi.stubGlobal('fetch', vi.fn(async () => response))
  304. await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
  305. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  306. expect(cancelled()).toBe(true)
  307. })
  308. it('cancels the body when an unsupported charset is rejected', async () => {
  309. const { response, cancelled } = fakeResponse({ status: 200, headers: { 'content-type': 'text/plain; charset=not-a-charset' } })
  310. vi.stubGlobal('fetch', vi.fn(async () => response))
  311. await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
  312. .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
  313. expect(cancelled()).toBe(true)
  314. })
  315. it('cancels the body when a redirect has no Location header', async () => {
  316. const { response, cancelled } = fakeResponse({ status: 302, headers: {} })
  317. vi.stubGlobal('fetch', vi.fn(async () => response))
  318. await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
  319. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
  320. expect(cancelled()).toBe(true)
  321. })
  322. })
  323. describe('web-fetch-http plugin registration', () => {
  324. it('registers the provider into ctx.web (HMR-safe)', async () => {
  325. const ctx = new Context()
  326. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  327. const fiber = await ctx.plugin(fetchPlugin, {})
  328. await expect(ctx.web.fetch({ url: `${base}/` }))
  329. .resolves.toMatchObject({ statusCode: 200 })
  330. await fiber.dispose()
  331. await expect(ctx.web.fetch({ url: `${base}/` }))
  332. .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
  333. })
  334. it('has no default export (namespace plugin export shape)', () => {
  335. expect('default' in fetchPlugin).toBe(false)
  336. })
  337. it('rejects a non-positive resource limit at construction', async () => {
  338. const ctx = new Context()
  339. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  340. await expect(ctx.plugin(fetchPlugin, { maxResponseBytes: -1 }))
  341. .rejects.toThrow(/maxResponseBytes must be a positive finite number/)
  342. })
  343. it('rejects a zero timeout at construction', async () => {
  344. const ctx = new Context()
  345. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  346. await expect(ctx.plugin(fetchPlugin, { timeoutMs: 0 }))
  347. .rejects.toThrow(/timeoutMs must be a positive finite number/)
  348. })
  349. it('rejects a timeout beyond Node timer range at construction', async () => {
  350. const ctx = new Context()
  351. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  352. await expect(ctx.plugin(fetchPlugin, { timeoutMs: 2_147_483_648 }))
  353. .rejects.toThrow(/timeoutMs must be no greater than 2147483647/)
  354. })
  355. it('rejects a fractional redirect cap at construction', async () => {
  356. const ctx = new Context()
  357. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  358. await expect(ctx.plugin(fetchPlugin, { maxRedirects: 1.5 }))
  359. .rejects.toThrow(/maxRedirects must be a non-negative integer/)
  360. })
  361. it('rejects a negative redirect cap at construction', async () => {
  362. const ctx = new Context()
  363. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  364. await expect(ctx.plugin(fetchPlugin, { maxRedirects: -1 }))
  365. .rejects.toThrow(/maxRedirects must be a non-negative integer/)
  366. })
  367. it('accepts maxRedirects: 0 (follow no redirects) as valid config', async () => {
  368. const ctx = new Context()
  369. await ctx.plugin(WebRuntime, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
  370. const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
  371. await expect(ctx.web.fetch({ url: `${base}/` }))
  372. .resolves.toMatchObject({ statusCode: 200 })
  373. await fiber.dispose()
  374. })
  375. })