headless-shutdown.e2e.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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-test: never-dispose ready", 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. // Pre-initialize the headless profile with the never-dispose row in its
  64. // user patch layer (the same file a long-lived profile boot hot-reloads).
  65. const profileDir = join(home, 'profiles', 'headless')
  66. await mkdir(profileDir, { recursive: true })
  67. await writeFile(join(profileDir, 'package.json'), JSON.stringify({
  68. name: 'dsh-profile-headless',
  69. private: true,
  70. dependencies: {},
  71. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
  72. }, undefined, 2))
  73. await writeFile(join(profileDir, 'cordis.patch.yml'), [
  74. '- insert:',
  75. ' - id: never-dispose',
  76. ` name: '${neverDisposePlugin}'`,
  77. '',
  78. ].join('\n'))
  79. const launch = resolveExampleLaunch({
  80. srcBin: dshBinScript,
  81. configArgs: ['--profile', 'headless', 'never complete'],
  82. tsconfigPath,
  83. env: {
  84. DSH_HOME: home,
  85. DSH_AGENTS_HOME: join(cwd, '.agents'),
  86. DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
  87. DSH_TELEMETRY_DISABLED: '1',
  88. DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
  89. },
  90. })
  91. const timeoutMs = 15_000
  92. const result = await execa('python3', [
  93. '-c',
  94. POSIX_HEADLESS_PTY_DRIVER,
  95. launch.command,
  96. JSON.stringify(launch.args),
  97. JSON.stringify(launch.env),
  98. cwd,
  99. String(timeoutMs / 1_000),
  100. ], {
  101. stdin: 'ignore',
  102. timeout: timeoutMs + 5_000,
  103. killSignal: 'SIGKILL',
  104. reject: false,
  105. stripFinalNewline: false,
  106. })
  107. if (result.timedOut) {
  108. throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  109. }
  110. if (result.failed) {
  111. throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  112. }
  113. return result.stdout
  114. } finally {
  115. await rm(cwd, { recursive: true, force: true })
  116. }
  117. }
  118. describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
  119. it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
  120. const output = await runHeadlessPtySmoke()
  121. expect(output).not.toContain('dsh: observing at ')
  122. expect(output).toContain('dsh-test: never-dispose ready')
  123. expect(output).toContain('dsh-test: never-dispose started')
  124. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  125. })