spawn-runner.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */
  2. import { closeSync } from 'node:fs'
  3. import {
  4. closeHandleChecked,
  5. isJobEmpty,
  6. loadWin32ProcessBindings,
  7. pollProcessExit,
  8. spawnCurrentTokenJobProcess,
  9. terminateJob,
  10. Win32Error,
  11. } from '@deepseek-ai/dsh-win32-process'
  12. import type {
  13. CurrentTokenProcessBindings,
  14. NativePtr,
  15. } from '@deepseek-ai/dsh-win32-process'
  16. import { loadLinuxExecve } from './linux-execve.ts'
  17. import {
  18. consumeLinuxLaunchRequest,
  19. isWindowsTerminateRequest,
  20. linuxLaunchFilesFromLocator,
  21. parseWindowsStartRequest,
  22. serializeRunnerError,
  23. writeLinuxStartupError,
  24. } from './runner-protocol.ts'
  25. import type {
  26. LinuxLaunchFiles,
  27. SerializedRunnerError,
  28. WindowsRunnerResult,
  29. WindowsStartRequest,
  30. } from './runner-protocol.ts'
  31. import {
  32. parseRunnerTargetArgv,
  33. resolveWindowsExecutable,
  34. SUBPROCESS_RUNNER_ENV,
  35. WINDOWS_RUNNER_SELECTION,
  36. } from './runner-launch.ts'
  37. type RunnerHost = Pick<NodeJS.Process, 'env' | 'exitCode' | 'connected' | 'cwd' | 'chdir' | 'on' | 'off' | 'once' | 'disconnect'> & {
  38. send?: NodeJS.Process['send']
  39. }
  40. /** Injectable operations used by the protocol-owner tests. */
  41. export interface SpawnRunnerInternals {
  42. execve(file: string, argv: string[], env: Record<string, string>): never
  43. loadWin32ProcessBindings(): CurrentTokenProcessBindings
  44. spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess
  45. closeFileDescriptor(fileDescriptor: number): void
  46. resolveWindowsExecutable: typeof resolveWindowsExecutable
  47. pollProcessExit: typeof pollProcessExit
  48. isJobEmpty: typeof isJobEmpty
  49. terminateJob: typeof terminateJob
  50. closeHandleChecked: typeof closeHandleChecked
  51. }
  52. const defaultInternals: SpawnRunnerInternals = {
  53. /* v8 ignore next -- source/built/packaged subprocess smoke executes this only in a replaceable child process. */
  54. execve: (file, argv, env) => loadLinuxExecve()(file, argv, env),
  55. loadWin32ProcessBindings,
  56. spawnCurrentTokenJobProcess,
  57. closeFileDescriptor: closeSync,
  58. resolveWindowsExecutable,
  59. pollProcessExit,
  60. isJobEmpty,
  61. terminateJob,
  62. closeHandleChecked,
  63. }
  64. const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'ENOENT'])
  65. const WINDOWS_SPAWN_ERROR_CODES = new Map<number, string>([
  66. [2, 'ENOENT'],
  67. [3, 'ENOENT'],
  68. [267, 'ENOENT'],
  69. [5, 'EPERM'],
  70. [193, 'EFTYPE'],
  71. [740, 'EACCES'],
  72. ])
  73. function nodeSpawnError(
  74. syscall: string,
  75. code: string,
  76. path?: string,
  77. ): SerializedRunnerError {
  78. const message = `${syscall} ${code}`
  79. return {
  80. name: 'Error',
  81. message,
  82. code,
  83. syscall,
  84. ...path === undefined ? {} : { path },
  85. }
  86. }
  87. function asSpawnError(
  88. error: unknown,
  89. program: string,
  90. ): SerializedRunnerError {
  91. const serialized = serializeRunnerError(error)
  92. if (!(error instanceof Win32Error)) {
  93. return serialized.code === undefined
  94. ? serialized
  95. : nodeSpawnError(`spawn ${program}`, serialized.code, program)
  96. }
  97. const code = WINDOWS_SPAWN_ERROR_CODES.get(error.win32Code) ?? 'UNKNOWN'
  98. if (NODE_SPAWN_DETAIL_CODES.has(code)) {
  99. return nodeSpawnError(`spawn ${program}`, code, program)
  100. }
  101. return nodeSpawnError('spawn', code)
  102. }
  103. function windowsPathNotFoundError(program: string): SerializedRunnerError {
  104. return nodeSpawnError(`spawn ${program}`, 'ENOENT', program)
  105. }
  106. function windowsStartCancelledError(): SerializedRunnerError {
  107. return {
  108. name: 'Error',
  109. message: 'subprocess target start was cancelled',
  110. }
  111. }
  112. function linuxPathNotFoundError(program: string): NodeJS.ErrnoException {
  113. return Object.assign(new Error(`spawn ${program} ENOENT`), {
  114. code: 'ENOENT',
  115. errno: -2,
  116. syscall: `spawn ${program}`,
  117. path: program,
  118. spawnargs: [] as string[],
  119. })
  120. }
  121. function execLinuxFile(
  122. file: string,
  123. argv: string[],
  124. env: Record<string, string>,
  125. internals: SpawnRunnerInternals,
  126. ): never {
  127. try {
  128. return internals.execve(file, argv, env)
  129. } catch (error) {
  130. if ((error as NodeJS.ErrnoException).code !== 'ENOEXEC') throw error
  131. return internals.execve('/bin/sh', ['/bin/sh', file, ...argv.slice(1)], env)
  132. }
  133. }
  134. function execLinuxTarget(
  135. request: { cwd: string; env: Record<string, string> },
  136. argv: string[],
  137. internals: SpawnRunnerInternals,
  138. ): never {
  139. const program = argv[0] as string
  140. if (program.includes('/')) return execLinuxFile(program, argv, request.env, internals)
  141. const path = request.env.PATH ?? '/usr/bin:/bin'
  142. let permissionFailure: Error | undefined
  143. for (const directory of path.split(':')) {
  144. const root = directory.startsWith('/')
  145. ? directory
  146. : `${request.cwd}${request.cwd.endsWith('/') ? '' : '/'}${directory}`
  147. const candidate = `${root}${root.endsWith('/') ? '' : '/'}${program}`
  148. try {
  149. return execLinuxFile(candidate, argv, request.env, internals)
  150. } catch (error) {
  151. const code = (error as NodeJS.ErrnoException).code
  152. if (code === 'EACCES') {
  153. permissionFailure ??= error as Error
  154. continue
  155. }
  156. if (code === 'ENOENT' || code === 'ENOTDIR') continue
  157. throw error
  158. }
  159. }
  160. throw permissionFailure ?? linuxPathNotFoundError(program)
  161. }
  162. function runLinux(
  163. locator: string,
  164. argv: string[],
  165. host: RunnerHost,
  166. internals: SpawnRunnerInternals,
  167. ): void {
  168. const files = linuxLaunchFilesFromLocator(locator)
  169. let request: ReturnType<typeof consumeLinuxLaunchRequest>
  170. try {
  171. request = consumeLinuxLaunchRequest(files.requestPath)
  172. } catch (error) {
  173. writeLinuxStartupError(files, { type: 'error', error: serializeRunnerError(error) })
  174. host.exitCode = 127
  175. return
  176. }
  177. try {
  178. host.chdir(request.cwd)
  179. execLinuxTarget({ ...request, cwd: host.cwd() }, argv, internals)
  180. } catch (error) {
  181. writeLinuxStartupError(files, {
  182. type: 'error',
  183. error: asSpawnError(error, argv[0] as string),
  184. })
  185. host.exitCode = 127
  186. }
  187. }
  188. function sendMessage(host: RunnerHost, result: WindowsRunnerResult): Promise<void> {
  189. return new Promise((resolve, reject) => {
  190. if (!host.connected || host.send === undefined) {
  191. reject(new Error('subprocess runner IPC is not connected'))
  192. return
  193. }
  194. try {
  195. host.send(result, (error) => {
  196. if (error === null) resolve()
  197. else reject(error)
  198. })
  199. } catch (error) {
  200. /* v8 ignore next -- process.send throws Error instances. */
  201. const failure = error instanceof Error ? error : new Error(String(error))
  202. reject(failure)
  203. }
  204. })
  205. }
  206. class WindowsJobRunner {
  207. private api: CurrentTokenProcessBindings | undefined
  208. private processHandle: NativePtr | undefined
  209. private jobHandle: NativePtr | undefined
  210. private pollTimer: ReturnType<typeof setInterval> | undefined
  211. private startSeen = false
  212. private terminateRequested = false
  213. private resultStarted = false
  214. private resultDelivered = false
  215. private finished = false
  216. private readonly completion = Promise.withResolvers<void>()
  217. constructor(
  218. private readonly argv: string[],
  219. private readonly host: RunnerHost,
  220. private readonly internals: SpawnRunnerInternals,
  221. ) {}
  222. run(): Promise<void> {
  223. if (!this.host.connected || this.host.send === undefined) {
  224. this.finish(127)
  225. return this.completion.promise
  226. }
  227. this.host.on('message', this.onMessage)
  228. this.host.once('disconnect', this.onDisconnect)
  229. return this.completion.promise
  230. }
  231. private readonly onMessage = (value: unknown): void => {
  232. if (this.finished) return
  233. if (isWindowsTerminateRequest(value)) {
  234. this.requestTermination()
  235. return
  236. }
  237. if (this.startSeen) {
  238. void this.runnerFailure(new Error('subprocess runner received more than one Windows start request'))
  239. return
  240. }
  241. let request: WindowsStartRequest
  242. try {
  243. request = parseWindowsStartRequest(value)
  244. } catch (error) {
  245. void this.runnerFailure(error)
  246. return
  247. }
  248. this.startSeen = true
  249. void this.start(request)
  250. }
  251. private readonly onDisconnect = (): void => {
  252. if (this.finished) return
  253. this.releaseOwnedJob()
  254. this.finish(127, false)
  255. }
  256. private async start(request: WindowsStartRequest): Promise<void> {
  257. if (this.terminateRequested) {
  258. await this.publishTerminalResult({ type: 'error', error: windowsStartCancelledError() }, 0)
  259. return
  260. }
  261. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  262. if (this.finished) return
  263. // IPC may set this field while start() is suspended above.
  264. // oxlint-disable-next-line typescript/no-unnecessary-condition
  265. if (this.terminateRequested) {
  266. await this.publishTerminalResult({ type: 'error', error: windowsStartCancelledError() }, 0)
  267. return
  268. }
  269. try {
  270. const [command, ...args] = this.argv
  271. const applicationName = this.internals.resolveWindowsExecutable(
  272. command as string,
  273. request.cwd,
  274. request.env,
  275. undefined,
  276. { ...this.host.env },
  277. )
  278. if (applicationName === undefined) {
  279. await this.publishTerminalResult({
  280. type: 'error',
  281. error: windowsPathNotFoundError(command as string),
  282. }, 0)
  283. return
  284. }
  285. this.api = this.internals.loadWin32ProcessBindings()
  286. const spawned = this.internals.spawnCurrentTokenJobProcess(this.api, {
  287. command: command as string,
  288. applicationName,
  289. args,
  290. cwd: request.cwd,
  291. env: request.env,
  292. stdio: { stdin: 4, stdout: 5, stderr: 6 },
  293. })
  294. this.processHandle = spawned.process
  295. this.jobHandle = spawned.job
  296. for (const fileDescriptor of [4, 5, 6]) {
  297. this.internals.closeFileDescriptor(fileDescriptor)
  298. }
  299. this.pollTimer = setInterval(() => { this.poll() }, 10)
  300. } catch (error) {
  301. if (this.jobHandle === undefined && error instanceof Win32Error && error.api === 'CreateProcessW') {
  302. await this.publishTerminalResult({
  303. type: 'error',
  304. error: asSpawnError(error, this.argv[0] as string),
  305. }, 0)
  306. return
  307. }
  308. await this.runnerFailure(error)
  309. }
  310. }
  311. private requestTermination(): void {
  312. if (this.terminateRequested) return
  313. this.terminateRequested = true
  314. try {
  315. this.terminateOwnedJob()
  316. } catch (error) {
  317. void this.runnerFailure(error)
  318. }
  319. }
  320. private terminateOwnedJob(): void {
  321. const job = this.jobHandle
  322. if (job === undefined) return
  323. /* v8 ignore next -- a Job handle is assigned only after the bindings are loaded;
  324. * the guard above is the only reachable empty-owner state. */
  325. if (this.api === undefined) return
  326. this.internals.terminateJob(this.api, job, 1)
  327. }
  328. private poll(): void {
  329. if (this.finished) return
  330. /* v8 ignore next -- poll is installed only after start() stores the bindings; retained as a defensive invariant guard. */
  331. if (this.api === undefined) return
  332. try {
  333. if (this.processHandle !== undefined) {
  334. const exitCode = this.internals.pollProcessExit(this.api, this.processHandle)
  335. if (exitCode !== undefined) {
  336. this.internals.closeHandleChecked(this.api, this.processHandle, 'ordinary direct process')
  337. this.processHandle = undefined
  338. void this.publishTerminalResult({ type: 'target-exit', exitCode })
  339. }
  340. }
  341. if (this.jobHandle !== undefined && this.internals.isJobEmpty(this.api, this.jobHandle)) {
  342. this.internals.closeHandleChecked(this.api, this.jobHandle, 'ordinary process Job')
  343. this.jobHandle = undefined
  344. if (this.resultDelivered) this.finish(0)
  345. }
  346. } catch (error) {
  347. void this.runnerFailure(error)
  348. }
  349. }
  350. private async publishTerminalResult(result: WindowsRunnerResult, exitCode?: number): Promise<void> {
  351. /* v8 ignore next -- each state transition has a single result call site; the guard contains only re-entrant internal defects. */
  352. if (this.finished || this.resultStarted) return
  353. this.resultStarted = true
  354. try {
  355. await sendMessage(this.host, result)
  356. this.resultDelivered = true
  357. } catch {
  358. this.releaseOwnedJob()
  359. this.finish(127, false)
  360. return
  361. }
  362. if (exitCode !== undefined) {
  363. this.finish(exitCode)
  364. return
  365. }
  366. if (this.jobHandle === undefined) this.finish(0)
  367. }
  368. private async runnerFailure(error: unknown): Promise<void> {
  369. /* v8 ignore next -- callers stop/detach on finish; this guard contains only an already-queued internal callback. */
  370. if (this.finished) return
  371. if (!this.resultStarted) {
  372. this.resultStarted = true
  373. try {
  374. await sendMessage(this.host, { type: 'error', error: serializeRunnerError(error) })
  375. this.resultDelivered = true
  376. } catch {
  377. // The disconnected parent observes runner infrastructure failure.
  378. }
  379. }
  380. this.releaseOwnedJob()
  381. this.finish(127)
  382. }
  383. private releaseOwnedJob(): void {
  384. if (this.pollTimer !== undefined) clearInterval(this.pollTimer)
  385. this.pollTimer = undefined
  386. const api = this.api
  387. if (api === undefined) return
  388. if (this.jobHandle !== undefined) {
  389. try { this.internals.terminateJob(api, this.jobHandle, 1) } catch { /* Continue to kill-on-close. */ }
  390. try { this.internals.closeHandleChecked(api, this.jobHandle, 'ordinary process Job cleanup') } catch { /* Best effort after failure. */ }
  391. this.jobHandle = undefined
  392. }
  393. if (this.processHandle !== undefined) {
  394. try { this.internals.closeHandleChecked(api, this.processHandle, 'ordinary direct process cleanup') } catch { /* Best effort after failure. */ }
  395. this.processHandle = undefined
  396. }
  397. }
  398. private finish(exitCode: number, disconnect = true): void {
  399. if (this.finished) return
  400. this.finished = true
  401. if (this.pollTimer !== undefined) clearInterval(this.pollTimer)
  402. this.pollTimer = undefined
  403. this.host.off('message', this.onMessage)
  404. this.host.off('disconnect', this.onDisconnect)
  405. this.host.exitCode = exitCode
  406. if (disconnect && this.host.connected) this.host.disconnect()
  407. this.completion.resolve()
  408. }
  409. }
  410. /**
  411. * Execute the selected Linux bootstrap or Windows Job runner.
  412. * @param selection - Windows sentinel or Linux launch-request locator.
  413. * @param argv - private runner arguments beginning with the target delimiter.
  414. * @param host - process transport and lifecycle host.
  415. * @param internals - native and filesystem operations used by the runner.
  416. */
  417. export async function runSpawnRunner(
  418. selection: string,
  419. argv: readonly string[],
  420. host: RunnerHost = process,
  421. internals: SpawnRunnerInternals = defaultInternals,
  422. ): Promise<void> {
  423. Reflect.deleteProperty(host.env, SUBPROCESS_RUNNER_ENV)
  424. const targetArgv = parseRunnerTargetArgv(argv)
  425. if (selection === WINDOWS_RUNNER_SELECTION) {
  426. await new WindowsJobRunner(targetArgv, host, internals).run()
  427. return
  428. }
  429. runLinux(selection, targetArgv, host, internals)
  430. }
  431. /**
  432. * Best-effort reporting for failures before the selected runner established its owner.
  433. * @param selection - Windows sentinel, Linux launch-request locator, or no selection.
  434. * @param error - failure raised before normal runner settlement.
  435. * @param host - process transport and lifecycle host.
  436. */
  437. export async function reportSpawnRunnerFailure(
  438. selection: string | undefined,
  439. error: unknown,
  440. host: RunnerHost = process,
  441. ): Promise<void> {
  442. if (selection === WINDOWS_RUNNER_SELECTION) {
  443. try { await sendMessage(host, { type: 'error', error: serializeRunnerError(error) }) } catch { /* No transport remains. */ }
  444. host.exitCode = 127
  445. if (host.connected) host.disconnect()
  446. return
  447. }
  448. if (selection !== undefined) {
  449. try {
  450. const files: LinuxLaunchFiles = linuxLaunchFilesFromLocator(selection)
  451. writeLinuxStartupError(files, { type: 'error', error: serializeRunnerError(error) })
  452. } catch {
  453. // The parent will report an unconsumed request or missing runner result.
  454. }
  455. }
  456. host.exitCode = 127
  457. }