process-exit-host.ts 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. import { access, readFile, writeFile } from 'node:fs/promises'
  2. import { join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  6. const [kind, trigger, root] = process.argv.slice(2)
  7. if ((kind !== 'ordinary' && kind !== 'terminal')
  8. || (trigger !== 'direct' && trigger !== 'uncaught-exception'
  9. && trigger !== 'unhandled-rejection' && trigger !== 'dispose')
  10. || root === undefined) {
  11. throw new Error('usage: process-exit-host.ts <ordinary|terminal> <direct|uncaught-exception|unhandled-rejection|dispose> <root>')
  12. }
  13. const treeState = join(root, 'tree.json')
  14. const proceed = join(root, 'proceed')
  15. const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url))
  16. async function waitForFile(path: string): Promise<void> {
  17. for (;;) {
  18. try {
  19. await access(path)
  20. return
  21. } catch (_notReady) {
  22. await new Promise(resolve => setTimeout(resolve, 10))
  23. }
  24. }
  25. }
  26. const listenersBefore = new Set(process.listeners('exit'))
  27. const ctx = new Context()
  28. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  29. const ownedListeners = process.listeners('exit').filter(listener => !listenersBefore.has(listener))
  30. // Independent process-lifetime listeners may be installed after this provider.
  31. const unrelatedListener = (): void => {}
  32. if (trigger === 'dispose') process.once('exit', unrelatedListener)
  33. if (kind === 'ordinary') {
  34. ctx.subprocess.spawn({
  35. argv: [process.execPath, managedTree, treeState],
  36. cwd: process.cwd(),
  37. stdio: {
  38. stdin: 'ignore',
  39. stdout: { maxBytes: 1024 },
  40. stderr: { maxBytes: 1024 },
  41. },
  42. graceMs: trigger === 'dispose' ? 100 : 30_000,
  43. })
  44. } else {
  45. await ctx.subprocess.spawnTerminal({
  46. argv: [process.execPath, managedTree, treeState],
  47. cwd: process.cwd(),
  48. rows: 24,
  49. cols: 80,
  50. terminalType: 'dumb',
  51. graceMs: 30_000,
  52. })
  53. }
  54. await waitForFile(treeState)
  55. const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unknown; descendant?: unknown }
  56. if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) {
  57. throw new Error('managed tree published invalid process ids')
  58. }
  59. await waitForFile(proceed)
  60. if (trigger === 'dispose') {
  61. await fiber.dispose()
  62. await writeFile(join(root, 'dispose.json'), JSON.stringify({
  63. ownedListenersAfterLoad: ownedListeners.length,
  64. ownedListenersAfterDispose: ownedListeners.filter(listener => process.listeners('exit').includes(listener)).length,
  65. unrelatedListenerPreserved: process.listeners('exit').includes(unrelatedListener),
  66. }))
  67. } else if (trigger === 'direct') {
  68. process.exit(23)
  69. } else if (trigger === 'uncaught-exception') {
  70. setImmediate(() => { throw new Error('host-exit-uncaught-exception') })
  71. await new Promise(() => {})
  72. } else {
  73. void Promise.reject(new Error('host-exit-unhandled-rejection'))
  74. await new Promise(() => {})
  75. }