spawn-runner.ts 16 KB

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