native-windows.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterAll, describe, expect, it } from 'vitest'
  6. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  7. import { targetEnvironment } from '../src/runner-launch.ts'
  8. import { bindManagedProcess } from '../src/spawn.ts'
  9. import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts'
  10. const scratch = mkdtempSync(join(tmpdir(), 'dsh-native-windows-'))
  11. afterAll(() => { rmSync(scratch, { recursive: true, force: true }) })
  12. function spec(argv: string[], graceMs = 100, env?: NodeJS.ProcessEnv): SubprocessSpawnSpec {
  13. return {
  14. argv,
  15. cwd: scratch,
  16. stdio: {
  17. stdin: 'ignore',
  18. stdout: { maxBytes: 64_000 },
  19. stderr: { maxBytes: 64_000 },
  20. },
  21. graceMs,
  22. env,
  23. }
  24. }
  25. async function waitForPid(path: string): Promise<number> {
  26. const deadline = Date.now() + 5_000
  27. while (Date.now() < deadline) {
  28. try {
  29. const pid = Number(readFileSync(path, 'utf8').trim())
  30. if (Number.isSafeInteger(pid) && pid > 0) return pid
  31. } catch {
  32. // Target has not written its descendant pid yet.
  33. }
  34. await new Promise(resolve => setTimeout(resolve, 20))
  35. }
  36. throw new Error(`pid file ${path} was not written`)
  37. }
  38. async function waitGone(pid: number): Promise<void> {
  39. const deadline = Date.now() + 5_000
  40. while (Date.now() < deadline) {
  41. try {
  42. process.kill(pid, 0)
  43. } catch {
  44. return
  45. }
  46. await new Promise(resolve => setTimeout(resolve, 20))
  47. }
  48. throw new Error(`pid ${pid} remained alive`)
  49. }
  50. function cleanup(pid: number): void {
  51. spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
  52. }
  53. type SpawnFailure = NodeJS.ErrnoException & { path?: string }
  54. function expectedSpawnFailure(error: SpawnFailure): Record<string, unknown> {
  55. const expected: Record<string, unknown> = {
  56. name: error.name,
  57. message: error.message,
  58. code: error.code,
  59. syscall: error.syscall,
  60. }
  61. if (Object.hasOwn(error, 'path')) expected.path = error.path
  62. return expected
  63. }
  64. function directSpawnFailure(argv: readonly string[], cwd = scratch): Promise<SpawnFailure> {
  65. return new Promise((resolve, reject) => {
  66. try {
  67. const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' })
  68. child.once('error', resolve)
  69. child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) })
  70. } catch (error) {
  71. resolve(error as SpawnFailure)
  72. }
  73. })
  74. }
  75. const windowsNative = process.platform === 'win32' && probeWindowsJob()
  76. describe.skipIf(!windowsNative)('Windows Job native containment', () => {
  77. it('keeps raw stdin writable while the runner starts the target', async () => {
  78. const output = join(scratch, `stdin-${Date.now()}.txt`)
  79. const script = `
  80. const { writeFileSync } = require('node:fs')
  81. let input = ''
  82. process.stdin.setEncoding('utf8')
  83. process.stdin.on('data', chunk => { input += chunk })
  84. process.stdin.on('end', () => { writeFileSync(${JSON.stringify(output)}, input) })
  85. `
  86. const request = {
  87. ...spec([process.execPath, '-e', script]),
  88. stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const,
  89. }
  90. const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request)))
  91. if (handle.stdin === undefined) throw new Error('expected piped stdin')
  92. await new Promise<void>((resolve, reject) => {
  93. handle.stdin?.once('error', reject)
  94. handle.stdin?.end('immediate-stdin', resolve)
  95. })
  96. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  97. await expect(handle.waitForExit()).resolves.toBe(true)
  98. expect(readFileSync(output, 'utf8')).toBe('immediate-stdin')
  99. })
  100. it('preserves direct Node null-device semantics for ignored stdin', async () => {
  101. const script = `
  102. const stat = require('node:fs').fstatSync(0)
  103. process.stdout.write(JSON.stringify({
  104. file: stat.isFile(),
  105. directory: stat.isDirectory(),
  106. block: stat.isBlockDevice(),
  107. character: stat.isCharacterDevice(),
  108. fifo: stat.isFIFO(),
  109. socket: stat.isSocket(),
  110. }))
  111. `
  112. const direct = spawnSync(process.execPath, ['-e', script], {
  113. cwd: scratch,
  114. stdio: ['ignore', 'pipe', 'inherit'],
  115. encoding: 'utf8',
  116. })
  117. expect(direct.status).toBe(0)
  118. const request = spec([process.execPath, '-e', script])
  119. const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request)))
  120. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  121. await expect(handle.waitForExit()).resolves.toBe(true)
  122. expect(handle.collected.stdout?.readFrom(0).text).toBe(direct.stdout)
  123. })
  124. it('reports direct exit before terminating its default-inheritance descendant', async () => {
  125. const pidFile = join(scratch, `job-survivor-${Date.now()}.pid`)
  126. const factsFile = join(scratch, `job-facts-${Date.now()}.json`)
  127. const targetCwd = join(scratch, `target-cwd-${Date.now()}`)
  128. mkdirSync(targetCwd)
  129. const script = `
  130. const { spawn } = require('node:child_process')
  131. const { writeFileSync } = require('node:fs')
  132. const { dirname } = require('node:path')
  133. const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { cwd: dirname(process.execPath), stdio: 'ignore', detached: true })
  134. writeFileSync(${JSON.stringify(pidFile)}, String(child.pid))
  135. writeFileSync(${JSON.stringify(factsFile)}, JSON.stringify({ cwd: process.cwd(), value: process.env.TARGET_VALUE, arg: process.argv[1] }))
  136. child.unref()
  137. process.stdout.end()
  138. process.stderr.end()
  139. process.exitCode = 42
  140. `
  141. const request = {
  142. ...spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }),
  143. cwd: targetCwd,
  144. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } as const,
  145. }
  146. const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request)))
  147. let descendant: number | undefined
  148. try {
  149. if (handle.stdout === undefined) throw new Error('expected piped stdout')
  150. if (handle.stderr === undefined) throw new Error('expected piped stderr')
  151. handle.stdout.resume()
  152. handle.stderr.resume()
  153. const stdoutEnded = new Promise<void>((resolve, reject) => {
  154. handle.stdout?.once('end', resolve)
  155. handle.stdout?.once('error', reject)
  156. })
  157. const stderrEnded = new Promise<void>((resolve, reject) => {
  158. handle.stderr?.once('end', resolve)
  159. handle.stderr?.once('error', reject)
  160. })
  161. descendant = await waitForPid(pidFile)
  162. await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null })
  163. await expect(Promise.race([
  164. Promise.all([stdoutEnded, stderrEnded]).then(() => true),
  165. new Promise<boolean>(resolve => setTimeout(() => { resolve(false) }, 5_000)),
  166. ])).resolves.toBe(true)
  167. expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({
  168. cwd: targetCwd,
  169. value: 'explicit',
  170. arg: 'literal $HOME ${UNCHANGED}',
  171. }))
  172. await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false)
  173. handle.terminate()
  174. await expect(handle.waitForExit()).resolves.toBe(true)
  175. await waitGone(descendant)
  176. } finally {
  177. handle.terminate()
  178. await Promise.allSettled([handle.done, handle.waitForExit()])
  179. if (descendant !== undefined) cleanup(descendant)
  180. rmSync(targetCwd, { recursive: true, force: true })
  181. }
  182. })
  183. it('preserves missing-target and invalid-executable rejection errors', async () => {
  184. const relativeExecutable = `relative-node-${String(Date.now())}.exe`
  185. copyFileSync(process.execPath, join(scratch, relativeExecutable))
  186. const relative = spec([relativeExecutable, '-e', 'process.exit(17)'])
  187. const relativeHandle = bindManagedProcess(relative, launchWindowsJob(relative, targetEnvironment(relative)))
  188. await expect(relativeHandle.done).resolves.toEqual({ exitCode: 17, signal: null })
  189. await expect(relativeHandle.waitForExit()).resolves.toBe(true)
  190. const missing = spec([`missing-native-target-${Date.now()}.exe`])
  191. const expectedMissing = await directSpawnFailure(missing.argv)
  192. const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing, targetEnvironment(missing)))
  193. await expect(missingHandle.done).rejects.toMatchObject(expectedSpawnFailure(expectedMissing))
  194. await expect(missingHandle.waitForExit()).resolves.toBe(true)
  195. const expectedAccessDenied = await directSpawnFailure([scratch])
  196. const accessDenied = spec([scratch])
  197. const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied, targetEnvironment(accessDenied)))
  198. await expect(accessDeniedHandle.done).rejects.toMatchObject(expectedSpawnFailure(expectedAccessDenied))
  199. await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true)
  200. const missingCwd = join(scratch, `missing-cwd-${Date.now()}`)
  201. const cwdArgv = [process.execPath, '-e', 'process.exit(0)']
  202. const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd)
  203. const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd }
  204. const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd, targetEnvironment(invalidCwd)))
  205. await expect(invalidCwdHandle.done).rejects.toMatchObject(expectedSpawnFailure(expectedCwd))
  206. await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true)
  207. const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`)
  208. writeFileSync(invalidExecutable, 'not a Windows executable\r\n')
  209. const directError = await directSpawnFailure([invalidExecutable])
  210. const invalid = spec([invalidExecutable])
  211. const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid, targetEnvironment(invalid)))
  212. await expect(invalidHandle.done).rejects.toMatchObject(expectedSpawnFailure(directError))
  213. await expect(invalidHandle.waitForExit()).resolves.toBe(true)
  214. })
  215. })