spawn-runner.ts 16 KB

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