headless-shutdown.e2e.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath, pathToFileURL } from 'node:url'
  5. import { execa } from 'execa'
  6. import { describe, expect, it } from 'vitest'
  7. import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  8. const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
  9. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  10. const neverDisposePlugin = pathToFileURL(
  11. fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
  12. ).href
  13. const POSIX_HEADLESS_PTY_DRIVER = String.raw`
  14. import errno, json, os, pty, select, signal, sys, time
  15. node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
  16. env = os.environ.copy()
  17. env.update(json.loads(launch_env_json))
  18. pid, fd = pty.fork()
  19. if pid == 0:
  20. os.chdir(cwd)
  21. os.execvpe(node, [node, *json.loads(launch_args_json)], env)
  22. markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
  23. output = bytearray()
  24. marker_index = 0
  25. deadline = time.monotonic() + float(timeout_seconds)
  26. status = None
  27. while time.monotonic() < deadline:
  28. ready, _, _ = select.select([fd], [], [], 0.05)
  29. if ready:
  30. try:
  31. chunk = os.read(fd, 65536)
  32. except OSError as error:
  33. if error.errno != errno.EIO:
  34. raise
  35. chunk = b""
  36. if chunk:
  37. output.extend(chunk)
  38. while marker_index < len(markers) and markers[marker_index] in output:
  39. if marker_index == 0:
  40. open(os.path.join(cwd, "shutdown-armed"), "w").close()
  41. os.write(fd, b"\x03")
  42. marker_index += 1
  43. waited, candidate = os.waitpid(pid, os.WNOHANG)
  44. if waited == pid:
  45. status = candidate
  46. break
  47. if status is None:
  48. os.kill(pid, signal.SIGKILL)
  49. _, status = os.waitpid(pid, 0)
  50. sys.stdout.buffer.write(output)
  51. if marker_index != len(markers):
  52. sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
  53. sys.exit(124)
  54. actual_exit = os.waitstatus_to_exitcode(status)
  55. if actual_exit != 130:
  56. sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
  57. sys.exit(125)
  58. `
  59. async function runHeadlessPtySmoke(): Promise<string> {
  60. const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
  61. try {
  62. const home = join(cwd, '.dsh')
  63. await mkdir(home, { recursive: true })
  64. await writeFile(join(home, 'config.yaml'), [
  65. '- insert:',
  66. ' - id: never-dispose',
  67. ` name: '${neverDisposePlugin}'`,
  68. '',
  69. ].join('\n'))
  70. const launch = resolveExampleLaunch({
  71. srcBin: dshBinScript,
  72. configArgs: ['-p', 'never complete'],
  73. tsconfigPath,
  74. env: {
  75. DSH_HOME: home,
  76. DSH_AGENTS_HOME: join(cwd, '.agents'),
  77. DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
  78. DSH_TELEMETRY_DISABLED: '1',
  79. DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
  80. },
  81. })
  82. const timeoutMs = 15_000
  83. const result = await execa('python3', [
  84. '-c',
  85. POSIX_HEADLESS_PTY_DRIVER,
  86. launch.command,
  87. JSON.stringify(launch.args),
  88. JSON.stringify(launch.env),
  89. cwd,
  90. String(timeoutMs / 1_000),
  91. ], {
  92. stdin: 'ignore',
  93. timeout: timeoutMs + 5_000,
  94. killSignal: 'SIGKILL',
  95. reject: false,
  96. stripFinalNewline: false,
  97. })
  98. if (result.timedOut) {
  99. throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  100. }
  101. if (result.failed) {
  102. throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  103. }
  104. return result.stdout
  105. } finally {
  106. await rm(cwd, { recursive: true, force: true })
  107. }
  108. }
  109. describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
  110. it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
  111. const output = await runHeadlessPtySmoke()
  112. expect(output).toContain('dsh: observing at ')
  113. expect(output).toContain('dsh-test: never-dispose started')
  114. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  115. })