terminal.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. /** E2B PTY allocation and process-session ownership for the subprocess seam. */
  2. import { Buffer } from 'node:buffer'
  3. import { randomUUID } from 'node:crypto'
  4. import { PassThrough } from 'node:stream'
  5. import { posix } from 'node:path'
  6. import {
  7. CommandExitError,
  8. e2bControlEnvs,
  9. FileNotFoundError,
  10. SandboxNotFoundError,
  11. quoteE2BShellArg,
  12. } from '@deepseek-ai/dsh-e2b'
  13. import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
  14. import type {
  15. SubprocessOutcome,
  16. SubprocessTerminalForeground,
  17. SubprocessTerminalHandle,
  18. SubprocessTerminalSignal,
  19. SubprocessTerminalSpawnSpec,
  20. } from '@deepseek-ai/dsh-subprocess'
  21. import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
  22. import {
  23. bootstrapEnvironment,
  24. readRemoteEnvironment,
  25. serializeRemoteEnvironment,
  26. } from './environment.ts'
  27. import { asError, commandOpts, delay, signalOpts, signalRemoteGroups } from './remote.ts'
  28. const TERMINAL_RUNNER_SOURCE = [
  29. '#!/bin/bash',
  30. 'set -euo pipefail',
  31. 'dsh_state=$1',
  32. 'mapfile -d \'\' -t dsh_env < "$dsh_state/environment"',
  33. 'mapfile -d \'\' -t dsh_argv < "$dsh_state/argv"',
  34. 'dsh_output_marker=$(<"$dsh_state/output-marker")',
  35. 'rm -f -- "$dsh_state/environment" "$dsh_state/argv" "$dsh_state/output-marker" "$dsh_state/runner.bash"',
  36. 'if (( ${#dsh_argv[@]} == 0 )); then',
  37. " printf 'terminal runner received empty argv\\n' >&2",
  38. ' exit 125',
  39. 'fi',
  40. 'printf \'%s\' "$dsh_output_marker"',
  41. 'exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"',
  42. '',
  43. ].join('\n')
  44. interface TerminalPaths {
  45. runner: string
  46. environment: string
  47. argv: string
  48. outputMarker: string
  49. }
  50. class BootstrapOutputFilter {
  51. readonly ready: Promise<void>
  52. private readonly readyState = Promise.withResolvers<void>()
  53. private pending = Buffer.alloc(0)
  54. private published = false
  55. constructor(
  56. private readonly marker: Buffer,
  57. private readonly output: PassThrough,
  58. ) {
  59. this.ready = this.readyState.promise
  60. }
  61. push(data: Uint8Array): void {
  62. if (this.published) {
  63. this.write(data)
  64. return
  65. }
  66. const combined = Buffer.concat([this.pending, Buffer.from(data)])
  67. const markerOffset = combined.indexOf(this.marker)
  68. if (markerOffset < 0) {
  69. const retained = Math.min(combined.length, this.marker.length - 1)
  70. this.pending = Buffer.from(combined.subarray(combined.length - retained))
  71. return
  72. }
  73. this.published = true
  74. this.pending = Buffer.alloc(0)
  75. this.readyState.resolve()
  76. this.write(combined.subarray(markerOffset + this.marker.length))
  77. }
  78. private write(data: Uint8Array): void {
  79. if (data.length > 0 && !this.output.destroyed) this.output.write(data)
  80. }
  81. }
  82. async function waitForBootstrapOutput(
  83. ready: Promise<void>,
  84. completion: Promise<CommandResult>,
  85. signal?: AbortSignal,
  86. ): Promise<void> {
  87. signal?.throwIfAborted()
  88. await new Promise<void>((resolve, reject) => {
  89. let settled = false
  90. let removeAbort: (() => void) | undefined
  91. const finish = (complete: () => void): void => {
  92. if (settled) return
  93. settled = true
  94. removeAbort?.()
  95. complete()
  96. }
  97. const onExit = (): void => {
  98. finish(() => { reject(new Error('subprocess-e2b: terminal exited before publishing its output boundary')) })
  99. }
  100. if (signal !== undefined) {
  101. const onAbort = (): void => {
  102. finish(() => { reject(asError(signal.reason)) })
  103. }
  104. signal.addEventListener('abort', onAbort, { once: true })
  105. removeAbort = () => { signal.removeEventListener('abort', onAbort) }
  106. }
  107. void ready.then(() => { finish(resolve) })
  108. void completion.then(onExit, onExit)
  109. })
  110. }
  111. function parsePositiveId(value: string, message: string): number {
  112. const raw = value.trim()
  113. const id = Number(raw)
  114. if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(id)) throw new Error(message)
  115. return id
  116. }
  117. function serializeValues(values: readonly string[], kind: string): string {
  118. for (const value of values) {
  119. if (value.includes('\0')) throw new Error(`subprocess-e2b: terminal ${kind} must not contain NUL bytes`)
  120. }
  121. return values.map(value => `${value}\0`).join('')
  122. }
  123. async function terminalSessionId(
  124. sandbox: Sandbox,
  125. pid: number,
  126. envs: Record<string, string>,
  127. signal?: AbortSignal,
  128. ): Promise<number> {
  129. const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, commandOpts(envs, signal))
  130. signal?.throwIfAborted()
  131. return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`)
  132. }
  133. async function sessionProcessGroups(
  134. sandbox: Sandbox,
  135. sessionId: number,
  136. envs: Record<string, string>,
  137. ): Promise<number[]> {
  138. let result: CommandResult
  139. try {
  140. result = await sandbox.commands.run(
  141. `set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`,
  142. commandOpts(envs),
  143. )
  144. } catch (error: unknown) {
  145. if (error instanceof SandboxNotFoundError) return []
  146. throw error
  147. }
  148. const groups = new Set<number>()
  149. for (const raw of result.stdout.trim().split(/\s+/)) {
  150. if (raw.length === 0) continue
  151. const group = parsePositiveId(
  152. raw,
  153. `subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${sessionId}`,
  154. )
  155. if (group <= 1) {
  156. throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${sessionId}`)
  157. }
  158. groups.add(group)
  159. }
  160. return [...groups]
  161. }
  162. async function awaitSessionEmpty(
  163. sandbox: Sandbox,
  164. sessionId: number,
  165. envs: Record<string, string>,
  166. graceMs: number,
  167. pollMs: number,
  168. kill = false,
  169. ): Promise<number[]> {
  170. const deadline = Date.now() + graceMs
  171. for (;;) {
  172. const groups = await sessionProcessGroups(sandbox, sessionId, envs)
  173. if (groups.length === 0) return groups
  174. if (kill) {
  175. await signalRemoteGroups(sandbox, envs, groups, 'KILL')
  176. if (Date.now() >= deadline) return await sessionProcessGroups(sandbox, sessionId, envs)
  177. } else if (Date.now() >= deadline) {
  178. return groups
  179. }
  180. await delay(Math.min(pollMs, Math.max(1, deadline - Date.now())))
  181. }
  182. }
  183. async function rollbackUnpublishedTerminal(
  184. sandbox: Sandbox,
  185. handle: CommandHandle,
  186. completion: Promise<CommandResult>,
  187. envs: Record<string, string>,
  188. graceMs: number,
  189. pollMs: number,
  190. ): Promise<void> {
  191. let topLevelExited = false
  192. void completion.then(
  193. () => { topLevelExited = true },
  194. () => { topLevelExited = true },
  195. )
  196. const validPid = Number.isSafeInteger(handle.pid) && handle.pid > 1
  197. const attemptFailures: Error[] = []
  198. let sessionId: number | undefined
  199. if (validPid) {
  200. sessionId = handle.pid
  201. try {
  202. sessionId = await terminalSessionId(sandbox, handle.pid, envs)
  203. } catch (_sessionLookupFailure) {
  204. // E2B's PTY leader is also the provisional POSIX session leader, so its
  205. // PID remains usable after the setup lookup itself fails or is canceled.
  206. }
  207. try {
  208. let groups = await sessionProcessGroups(sandbox, sessionId, envs)
  209. if (groups.length > 0) {
  210. await signalRemoteGroups(sandbox, envs, groups, 'TERM')
  211. groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs)
  212. }
  213. if (groups.length > 0) {
  214. await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs, true)
  215. }
  216. } catch (error: unknown) {
  217. attemptFailures.push(asError(error))
  218. }
  219. }
  220. // Completion can settle while any awaited provider cleanup above is running.
  221. // oxlint-disable-next-line typescript/no-unnecessary-condition -- Provider cleanup yields to completion.
  222. if (!topLevelExited) {
  223. try {
  224. await handle.kill()
  225. } catch (error: unknown) {
  226. if (error instanceof SandboxNotFoundError) return
  227. attemptFailures.push(asError(error))
  228. }
  229. await Promise.race([completion.catch(() => undefined), delay(graceMs)])
  230. }
  231. const proofFailures: Error[] = []
  232. if (sessionId !== undefined) {
  233. try {
  234. const groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, pollMs, true)
  235. if (groups.length > 0) {
  236. proofFailures.push(new Error(
  237. `subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`,
  238. ))
  239. }
  240. } catch (error: unknown) {
  241. proofFailures.push(asError(error))
  242. }
  243. }
  244. // The bounded completion race above updates this callback-owned state.
  245. // oxlint-disable-next-line typescript/no-unnecessary-condition -- The callback mutates this after a race.
  246. if (!topLevelExited) {
  247. proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`))
  248. }
  249. if (proofFailures.length > 0) {
  250. throw new AggregateError(
  251. [...attemptFailures, ...proofFailures],
  252. 'subprocess-e2b: terminal setup rollback did not reach quiescence',
  253. )
  254. }
  255. try {
  256. await handle.disconnect()
  257. } catch (error: unknown) {
  258. if (!(error instanceof SandboxNotFoundError)) throw error
  259. }
  260. }
  261. /** One E2B PTY and all process groups in its remote process session. */
  262. export class E2BTerminalHandle implements SubprocessTerminalHandle {
  263. readonly pid: number
  264. readonly done: Promise<SubprocessOutcome>
  265. private topLevelExited = false
  266. private cleanup: Promise<void> | undefined
  267. private readonly operationController = new AbortController()
  268. private readonly operations = new Set<Promise<unknown>>()
  269. private terminationSignal: NodeJS.Signals | null = null
  270. constructor(
  271. private readonly sandbox: Sandbox,
  272. private readonly handle: CommandHandle,
  273. readonly output: PassThrough,
  274. private readonly completion: Promise<CommandResult>,
  275. private readonly sessionId: number,
  276. private readonly controlEnvs: Record<string, string>,
  277. private readonly stateDir: string,
  278. private readonly graceMs: number,
  279. private readonly pollMs: number,
  280. ) {
  281. this.pid = handle.pid
  282. this.done = this.waitForCommand()
  283. }
  284. // TODO(e2b-pgid-identity): Replace retained numeric PTY/session ids when E2B
  285. // exposes identity-bound input, foreground-signal, and cleanup operations.
  286. /** @inheritdoc */
  287. write(data: string): Promise<void> {
  288. return this.trackOperation(async (signal) => {
  289. if (this.topLevelExited) throw new Error('terminal process has exited')
  290. await this.sandbox.pty.sendInput(this.pid, Buffer.from(data, 'utf8'), { signal })
  291. })
  292. }
  293. /** @inheritdoc */
  294. inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
  295. return this.trackOperation(signal => this.inspectForegroundOnce(signal))
  296. }
  297. /** @inheritdoc */
  298. signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
  299. return this.trackOperation(async (operationSignal) => {
  300. const foreground = await this.inspectForegroundOnce(operationSignal)
  301. if (foreground === undefined) {
  302. throw new Error(`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`)
  303. }
  304. if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
  305. throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
  306. }
  307. await this.sandbox.commands.run(
  308. `kill -${signal.slice(3)} -- -${foreground.processGroupId}`,
  309. commandOpts(this.controlEnvs, operationSignal),
  310. )
  311. return foreground.processGroupId
  312. })
  313. }
  314. /** @inheritdoc */
  315. terminate(): Promise<void> {
  316. if (this.cleanup !== undefined) return this.cleanup
  317. this.operationController.abort(new Error('subprocess-e2b: terminal is terminating'))
  318. const cleanup = this.closeAfterOperations()
  319. this.cleanup = cleanup
  320. void cleanup.catch((_cleanupFailure: unknown) => {
  321. this.cleanup = undefined
  322. })
  323. return cleanup
  324. }
  325. private async inspectForegroundOnce(
  326. signal: AbortSignal,
  327. ): Promise<SubprocessTerminalForeground | undefined> {
  328. try {
  329. const result = await this.sandbox.commands.run(
  330. `ps -o tpgid= -p ${this.pid}`,
  331. commandOpts(this.controlEnvs, signal),
  332. )
  333. return {
  334. processGroupId: parsePositiveId(
  335. result.stdout,
  336. `subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`,
  337. ),
  338. // E2B exposes process-table commands but not the /proc memory access
  339. // needed to prove a specific syscall is waiting on fd 0.
  340. inputWaiting: false,
  341. }
  342. } catch (error: unknown) {
  343. if (error instanceof CommandExitError && (error.exitCode === 1 || this.topLevelExited)) return undefined
  344. throw error
  345. }
  346. }
  347. private trackOperation<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
  348. if (this.operationController.signal.aborted) {
  349. return Promise.reject(new Error('subprocess-e2b: terminal is terminating'))
  350. }
  351. const pending = operation(this.operationController.signal)
  352. this.operations.add(pending)
  353. void pending.then(
  354. () => { this.operations.delete(pending) },
  355. () => { this.operations.delete(pending) },
  356. )
  357. return pending
  358. }
  359. private async closeAfterOperations(): Promise<void> {
  360. await Promise.allSettled(this.operations)
  361. await this.closeOnce()
  362. }
  363. private async waitForCommand(): Promise<SubprocessOutcome> {
  364. try {
  365. const result = await this.completion
  366. return { exitCode: result.exitCode, signal: null }
  367. } catch (error: unknown) {
  368. if (error instanceof CommandExitError) {
  369. return this.terminationSignal === null
  370. ? { exitCode: error.exitCode, signal: null }
  371. : { exitCode: null, signal: this.terminationSignal }
  372. }
  373. this.output.destroy(error instanceof Error ? error : new Error(String(error)))
  374. throw error
  375. } finally {
  376. this.topLevelExited = true
  377. if (!this.output.destroyed) this.output.end()
  378. }
  379. }
  380. private async closeOnce(): Promise<void> {
  381. let groups = await sessionProcessGroups(this.sandbox, this.sessionId, this.controlEnvs)
  382. if (groups.length > 0) {
  383. this.terminationSignal = 'SIGTERM'
  384. await signalRemoteGroups(this.sandbox, this.controlEnvs, groups, 'TERM')
  385. groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, this.pollMs)
  386. }
  387. if (groups.length === 0 && !this.topLevelExited) {
  388. await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
  389. }
  390. if (groups.length > 0 || !this.topLevelExited) {
  391. this.terminationSignal = 'SIGKILL'
  392. if (!this.topLevelExited) {
  393. try {
  394. await this.handle.kill()
  395. } catch (error: unknown) {
  396. if (error instanceof SandboxNotFoundError) return
  397. throw error
  398. }
  399. }
  400. groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, this.pollMs, true)
  401. if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
  402. }
  403. if (groups.length > 0) {
  404. throw new Error(`subprocess-e2b: terminal cleanup failed; surviving process groups: ${groups.join(', ')}`)
  405. }
  406. if (!this.topLevelExited) {
  407. throw new Error(`subprocess-e2b: terminal cleanup failed; surviving pid: ${this.pid}`)
  408. }
  409. try {
  410. await this.handle.disconnect()
  411. } catch (error: unknown) {
  412. if (!(error instanceof SandboxNotFoundError)) throw error
  413. }
  414. try {
  415. await this.sandbox.files.remove(this.stateDir)
  416. } catch (_adapterPrivateStateRemovalFailure) {
  417. // The terminal is quiescent; owner teardown bounds private residue.
  418. }
  419. }
  420. }
  421. /**
  422. * Allocate an E2B PTY, replace its bootstrap shell with the requested argv,
  423. * and return only after the private runner has published readiness.
  424. * @param runtime - Shared E2B sandbox owner.
  425. * @param spec - Fully specified terminal-process request.
  426. * @param stateDir - Private remote directory for one startup transaction.
  427. * @param pollMs - Remote session liveness poll cadence.
  428. * @returns The live subprocess terminal handle.
  429. */
  430. export async function spawnE2BTerminal(
  431. runtime: E2BSandboxService,
  432. spec: SubprocessTerminalSpawnSpec,
  433. stateDir: string,
  434. pollMs = 20,
  435. ): Promise<E2BTerminalHandle> {
  436. const sandbox = await runtime.getSandbox()
  437. spec.signal?.throwIfAborted()
  438. const paths: TerminalPaths = {
  439. runner: posix.join(stateDir, 'runner.bash'),
  440. environment: posix.join(stateDir, 'environment'),
  441. argv: posix.join(stateDir, 'argv'),
  442. outputMarker: posix.join(stateDir, 'output-marker'),
  443. }
  444. const outputMarker = Buffer.from(`dsh-e2b-bootstrap:${randomUUID()}`)
  445. const output = new PassThrough()
  446. const outputFilter = new BootstrapOutputFilter(outputMarker, output)
  447. let handle: CommandHandle | undefined
  448. let completion: Promise<CommandResult> | undefined
  449. let stateDirectoryCreated = false
  450. let controlEnvs: Record<string, string> = {}
  451. try {
  452. const ambient = await readRemoteEnvironment(sandbox, spec.signal)
  453. controlEnvs = bootstrapEnvironment(ambient)
  454. const environment = serializeRemoteEnvironment(ambient, spec.env)
  455. const argv = serializeValues(spec.argv, 'argv')
  456. stateDirectoryCreated = true
  457. await sandbox.files.makeDir(stateDir, signalOpts(spec.signal))
  458. await sandbox.commands.run(
  459. `chmod 700 -- ${quoteE2BShellArg(stateDir)}`,
  460. commandOpts(controlEnvs, spec.signal),
  461. )
  462. await sandbox.files.write([
  463. { path: paths.runner, data: TERMINAL_RUNNER_SOURCE },
  464. { path: paths.environment, data: environment },
  465. { path: paths.argv, data: argv },
  466. { path: paths.outputMarker, data: outputMarker.toString('utf8') },
  467. ], signalOpts(spec.signal))
  468. await sandbox.commands.run(
  469. `chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)} ${quoteE2BShellArg(paths.outputMarker)}`,
  470. commandOpts(controlEnvs, spec.signal),
  471. )
  472. handle = await sandbox.pty.create({
  473. rows: spec.rows,
  474. cols: spec.cols,
  475. cwd: spec.cwd,
  476. envs: e2bControlEnvs(controlEnvs),
  477. timeoutMs: 0,
  478. onData: (data) => { outputFilter.push(data) },
  479. })
  480. completion = handle.wait()
  481. void completion.catch(() => {})
  482. spec.signal?.throwIfAborted()
  483. if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
  484. throw new Error(`subprocess-e2b: E2B returned invalid terminal pid ${handle.pid}`)
  485. }
  486. const command = `exec /bin/bash ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(stateDir)}\r`
  487. await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal))
  488. await waitForBootstrapOutput(outputFilter.ready, completion, spec.signal)
  489. const sessionId = await terminalSessionId(sandbox, handle.pid, controlEnvs, spec.signal)
  490. return new E2BTerminalHandle(
  491. sandbox,
  492. handle,
  493. output,
  494. completion,
  495. sessionId,
  496. controlEnvs,
  497. stateDir,
  498. spec.graceMs,
  499. pollMs,
  500. )
  501. } catch (error: unknown) {
  502. output.destroy()
  503. let terminalQuiescent = handle === undefined
  504. let stateRemoved = !stateDirectoryCreated
  505. const cleanup = async (): Promise<void> => {
  506. const failures: Error[] = []
  507. if (!terminalQuiescent && handle !== undefined) {
  508. try {
  509. if (completion === undefined) await handle.kill()
  510. else await rollbackUnpublishedTerminal(sandbox, handle, completion, controlEnvs, spec.graceMs, pollMs)
  511. terminalQuiescent = true
  512. } catch (cleanupError: unknown) {
  513. if (cleanupError instanceof SandboxNotFoundError) terminalQuiescent = true
  514. else failures.push(asError(cleanupError))
  515. }
  516. }
  517. if (!stateRemoved) {
  518. try {
  519. await sandbox.files.remove(stateDir)
  520. stateRemoved = true
  521. } catch (stateError: unknown) {
  522. if (stateError instanceof FileNotFoundError || stateError instanceof SandboxNotFoundError) stateRemoved = true
  523. else failures.push(asError(stateError))
  524. }
  525. }
  526. if (failures.length > 0) {
  527. throw new AggregateError(failures, 'subprocess-e2b: terminal setup cleanup did not complete')
  528. }
  529. }
  530. try {
  531. await cleanup()
  532. } catch (cleanupError: unknown) {
  533. // TODO(e2b-terminal-setup-rollback): Retain retry state only if a real
  534. // double failure must be recovered before sandbox disposal or timeout.
  535. throw new AggregateError([asError(error), asError(cleanupError)], asError(error).message)
  536. }
  537. throw error
  538. }
  539. }