linux-scope.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. /** Linux user-systemd scope launch and managed-range ownership. */
  2. import { controlPipe } from './control-spawn.ts'
  3. import { execFile, spawn, spawnSync } from 'node:child_process'
  4. import { randomBytes } from 'node:crypto'
  5. import { existsSync } from 'node:fs'
  6. import { setTimeout as sleepMs } from 'node:timers/promises'
  7. import type {
  8. SubprocessOutcome,
  9. SubprocessSpawnSpec,
  10. SubprocessTerminalSpawnSpec,
  11. } from '@deepseek-ai/dsh-subprocess'
  12. import { loadLinuxExecve } from './linux-execve.ts'
  13. import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
  14. import {
  15. cleanupLinuxLaunchFiles,
  16. createLinuxLaunchFiles,
  17. deserializeRunnerError,
  18. readLinuxStartupError,
  19. } from './runner-protocol.ts'
  20. import type { LinuxLaunchFiles } from './runner-protocol.ts'
  21. import {
  22. runnerEnvironment,
  23. runnerInvocationAvailable,
  24. runnerStdio,
  25. spawnRunnerInvocation,
  26. } from './runner-launch.ts'
  27. import type { RunnerInvocation } from './runner-launch.ts'
  28. import { childEnv } from './spawn.ts'
  29. /** Test seams for systemd command execution. */
  30. export interface LinuxScopeInternals {
  31. spawn?: typeof spawn
  32. spawnSync?: typeof spawnSync
  33. systemctlQuery?: (command: string, args: readonly string[]) => Promise<SystemctlResult>
  34. systemdRun?: string
  35. systemctl?: string
  36. runnerInvocation?: RunnerInvocation
  37. resolveRunnerInvocation?: () => RunnerInvocation
  38. runnerAvailable?: (invocation: RunnerInvocation) => boolean
  39. loadLinuxExecve?: typeof loadLinuxExecve
  40. sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>
  41. }
  42. interface SystemctlResult {
  43. status: number | null
  44. stdout: string
  45. stderr: string
  46. error?: Error
  47. }
  48. const SYSTEMCTL_TIMEOUT_MS = 5_000
  49. const SCOPE_INITIAL_POLL_INTERVAL_MS = 50
  50. const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu
  51. function managerEnvironment(): NodeJS.ProcessEnv {
  52. const environment = childEnv({ LC_ALL: 'C' })
  53. delete environment.SYSTEMD_LOG_TARGET
  54. return environment
  55. }
  56. function quietSystemdEnvironment(): NodeJS.ProcessEnv {
  57. return childEnv({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' })
  58. }
  59. function querySystemctl(command: string, args: readonly string[]): Promise<SystemctlResult> {
  60. return new Promise((resolveResult) => {
  61. execFile(command, [...args], {
  62. encoding: 'utf8',
  63. env: managerEnvironment(),
  64. timeout: SYSTEMCTL_TIMEOUT_MS,
  65. }, (error, stdout, stderr) => {
  66. const code = error === null ? 0 : (error as Error & { code?: string | number }).code
  67. resolveResult({
  68. status: typeof code === 'number' ? code : null,
  69. stdout,
  70. stderr,
  71. ...error === null ? {} : { error },
  72. })
  73. })
  74. })
  75. }
  76. function unitStem(prefix: string): string {
  77. return `${prefix}-${String(process.pid)}-${randomBytes(6).toString('hex')}`
  78. }
  79. function sleepWithAbort(delayMs: number, signal?: AbortSignal): Promise<void> {
  80. return sleepMs(delayMs, undefined, { signal })
  81. }
  82. /**
  83. * Confirm this exact runner entry and libc execve binding without a probe mode.
  84. * @param internals - optional runner and libc-binding seams used by tests.
  85. * @returns whether the bootstrap can enter the final target.
  86. */
  87. export function probeLinuxBootstrap(internals: LinuxScopeInternals = {}): boolean {
  88. try {
  89. ;(internals.loadLinuxExecve ?? loadLinuxExecve)()
  90. const invocation = internals.runnerInvocation
  91. ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)()
  92. return (internals.runnerAvailable ?? runnerInvocationAvailable)(invocation)
  93. } catch {
  94. return false
  95. }
  96. }
  97. /**
  98. * Confirm current literal-argv transient-scope support before selecting native launch.
  99. * @param internals - optional systemd command seams used by tests.
  100. * @returns whether the current user manager supports the required scope invocation.
  101. */
  102. export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
  103. const unitBase = unitStem('dsh-subprocess-probe')
  104. const result = (internals.spawnSync ?? spawnSync)(internals.systemdRun ?? 'systemd-run', [
  105. '--user',
  106. '--scope',
  107. '--quiet',
  108. '--collect',
  109. '--expand-environment=no',
  110. `--unit=${unitBase}`,
  111. '--',
  112. internals.systemctl ?? 'systemctl',
  113. '--user',
  114. 'show',
  115. `${unitBase}.scope`,
  116. '--property=ActiveState',
  117. '--value',
  118. ], { env: quietSystemdEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
  119. return result.error === undefined && result.status === 0
  120. }
  121. /**
  122. * Confirm that the current user manager remains reachable after a positive deep probe.
  123. * @param internals - optional systemctl seam used by tests.
  124. * @returns whether one lightweight manager query succeeds.
  125. */
  126. export function probeLinuxManager(internals: LinuxScopeInternals = {}): boolean {
  127. const result = (internals.spawnSync ?? spawnSync)(internals.systemctl ?? 'systemctl', [
  128. '--user',
  129. 'show',
  130. '--property=Version',
  131. '--value',
  132. ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
  133. return result.error === undefined && result.status === 0
  134. }
  135. /**
  136. * Re-check every Linux native prerequisite for one eligible spawn.
  137. * @param internals - optional native capability seams used by tests.
  138. * @returns whether the Linux native containment path is currently available.
  139. */
  140. export function probeLinuxNative(internals: LinuxScopeInternals = {}): boolean {
  141. return probeLinuxBootstrap(internals)
  142. && probeLinuxScope(internals)
  143. }
  144. interface DirectRange {
  145. running(): boolean
  146. /** True for group TERM delivery, direct signal submission, or proven direct-PID absence. */
  147. signal(signal: 'SIGTERM' | 'SIGKILL'): boolean
  148. /** Direct exit/error settlement, independent of output drain and managed-range completion. */
  149. settled: Promise<unknown>
  150. }
  151. class LinuxScopeStartup {
  152. readonly terminationSignals = new Set<NodeJS.Signals>()
  153. constructor(readonly files: LinuxLaunchFiles, private readonly kind: 'subprocess' | 'terminal') {}
  154. resolveOutcome(outcome: SubprocessOutcome): SubprocessOutcome {
  155. const startup = readLinuxStartupError(this.files.startupErrorPath)
  156. if (startup !== undefined) throw deserializeRunnerError(startup.error)
  157. if (existsSync(this.files.requestPath)
  158. && !(outcome.signal !== null && this.terminationSignals.has(outcome.signal))) {
  159. throw new Error(`${this.kind} scope exited before its bootstrap consumed the launch request`)
  160. }
  161. return outcome
  162. }
  163. }
  164. class SystemdScopeOwner implements BoundProcessOwner {
  165. private establishment: 'pending' | 'established' = 'pending'
  166. private stopped = false
  167. private terminationRequested = false
  168. private observation: Promise<void> | undefined
  169. private killFailure: Error | undefined
  170. private directKillSettlement: Promise<void> | undefined
  171. private wakeGeneration = 0
  172. private wakeWaiter: { generation: number; resolve: () => void } | undefined
  173. constructor(
  174. private readonly unit: string,
  175. private readonly startup: LinuxScopeStartup,
  176. private readonly direct: DirectRange,
  177. private readonly systemctl: string,
  178. private readonly runSync: typeof spawnSync,
  179. private readonly query: (command: string, args: readonly string[]) => Promise<SystemctlResult>,
  180. private readonly sleep: (delayMs: number, signal?: AbortSignal) => Promise<void>,
  181. ) {}
  182. signal(signal: 'SIGTERM' | 'SIGKILL'): void {
  183. if (this.stopped) return
  184. this.terminationRequested = true
  185. if (this.direct.running()) this.startup.terminationSignals.add(signal)
  186. this.observeRequestConsumption()
  187. const directFallbackRequired = this.establishment === 'pending'
  188. let directSignalled = false
  189. if (directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
  190. const result = this.runSync(this.systemctl, [
  191. '--user',
  192. 'kill',
  193. '--kill-whom=all',
  194. `--signal=${signal}`,
  195. this.unit,
  196. ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
  197. this.wakeObservation()
  198. if (result.error === undefined && result.status === 0) {
  199. if (signal === 'SIGKILL') {
  200. this.killFailure = undefined
  201. this.directKillSettlement = undefined
  202. }
  203. return
  204. }
  205. if (!directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
  206. if (signal === 'SIGKILL') {
  207. const output = `${result.stdout}\n${result.stderr}`
  208. if (!MISSING_UNIT.test(output)) {
  209. this.killFailure = result.error ?? new Error(
  210. `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`,
  211. )
  212. // The direct outcome retains errors; this barrier only joins its physical settlement.
  213. this.directKillSettlement = directSignalled
  214. ? this.direct.settled.then(() => {}, () => {})
  215. : undefined
  216. }
  217. }
  218. }
  219. terminateForHostExit(): void {
  220. if (this.stopped) return
  221. try {
  222. if (this.direct.running()) this.direct.signal('SIGKILL')
  223. } catch { /* Continue with the native owner. */ }
  224. try {
  225. this.runSync(this.systemctl, [
  226. '--user',
  227. 'kill',
  228. '--kill-whom=all',
  229. '--signal=SIGKILL',
  230. this.unit,
  231. ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
  232. } catch {
  233. // Host exit cannot report one range; the runtime continues with the rest.
  234. }
  235. }
  236. private observeRequestConsumption(): void {
  237. if (this.establishment === 'pending' && !existsSync(this.startup.files.requestPath)) {
  238. this.establishment = 'established'
  239. }
  240. }
  241. private absentUnit(): boolean {
  242. this.observeRequestConsumption()
  243. if (this.establishment === 'established') return false
  244. if (!this.direct.running() && existsSync(this.startup.files.requestPath)) {
  245. return false
  246. }
  247. if (this.killFailure !== undefined) throw this.killFailure
  248. return true
  249. }
  250. /**
  251. * Prove an active unit with no processes is the empty managed range rather
  252. * than a launch still placing its payload. systemd ends a scope only on the
  253. * populated-to-empty transition, so a payload killed before it entered the
  254. * cgroup leaves the unit active forever. A departed client cannot add another
  255. * payload; a consumed request proves the payload already entered the scope,
  256. * even while its direct-process exit notification is pending.
  257. */
  258. private emptyRange(tasksCurrent: number | undefined): boolean {
  259. return this.terminationRequested && tasksCurrent === 0
  260. && (!this.direct.running() || !existsSync(this.startup.files.requestPath))
  261. }
  262. /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */
  263. private releaseEmptyRange(): void {
  264. try {
  265. this.runSync(this.systemctl, ['--user', 'stop', this.unit], {
  266. env: managerEnvironment(),
  267. stdio: 'ignore',
  268. timeout: SYSTEMCTL_TIMEOUT_MS,
  269. })
  270. } catch {
  271. // The range is already empty; a failed cleanup leaves only the transient unit.
  272. }
  273. }
  274. private parseUnitState(stdout: string): { loadState: string; activeState: string; tasksCurrent: number | undefined } {
  275. const values = new Map<string, string>()
  276. for (const line of stdout.split(/\r?\n/u)) {
  277. if (line === '') continue
  278. const separator = line.indexOf('=')
  279. if (separator <= 0) {
  280. throw new Error(`systemctl returned malformed state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
  281. }
  282. const name = line.slice(0, separator)
  283. if (values.has(name)) {
  284. throw new Error(`systemctl returned duplicate ${name} for ${this.unit}`)
  285. }
  286. values.set(name, line.slice(separator + 1))
  287. }
  288. const loadState = values.get('LoadState')
  289. const activeState = values.get('ActiveState')
  290. // The manager prints this sentinel for a property the unit does not carry.
  291. const reportedTasks = values.get('TasksCurrent')
  292. const tasksCurrent = reportedTasks === '[not set]' ? undefined : reportedTasks
  293. if (values.size !== (reportedTasks === undefined ? 2 : 3)
  294. || loadState === undefined || activeState === undefined) {
  295. throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
  296. }
  297. if (tasksCurrent !== undefined && !/^\d+$/u.test(tasksCurrent)) {
  298. throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`)
  299. }
  300. return {
  301. loadState,
  302. activeState,
  303. tasksCurrent: tasksCurrent === undefined ? undefined : Number(tasksCurrent),
  304. }
  305. }
  306. private async rangeActive(): Promise<boolean> {
  307. this.observeRequestConsumption()
  308. const generation = this.wakeGeneration
  309. const directRunning = this.direct.running()
  310. const result = await this.query(this.systemctl, [
  311. '--user',
  312. 'show',
  313. this.unit,
  314. '--property=LoadState',
  315. '--property=ActiveState',
  316. '--property=TasksCurrent',
  317. ])
  318. // A signal invalidates state queried before its delivery and direct fallback.
  319. if (generation !== this.wakeGeneration) return true
  320. const output = `${result.stdout}\n${result.stderr}`
  321. if (result.status === 0) {
  322. const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout)
  323. if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit()
  324. if (loadState !== 'loaded') {
  325. throw new Error(
  326. `systemctl returned unknown state for ${this.unit}: ${JSON.stringify({ loadState, activeState })}`,
  327. )
  328. }
  329. this.establishment = 'established'
  330. if (activeState === 'inactive' || activeState === 'failed') return false
  331. if (!['active', 'activating', 'reloading', 'deactivating'].includes(activeState)) {
  332. throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`)
  333. }
  334. if (this.emptyRange(tasksCurrent)) {
  335. this.releaseEmptyRange()
  336. return false
  337. }
  338. if (this.killFailure !== undefined) {
  339. if (directRunning && this.directKillSettlement !== undefined) {
  340. const settlement = this.directKillSettlement
  341. this.directKillSettlement = undefined
  342. await settlement
  343. // A query preceding direct exit cannot prove that its signalled processes survived.
  344. return this.rangeActive()
  345. }
  346. throw this.killFailure
  347. }
  348. return true
  349. }
  350. if (!MISSING_UNIT.test(output)) {
  351. if (result.error !== undefined) throw result.error
  352. throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)
  353. }
  354. return this.absentUnit()
  355. }
  356. private wakeObservation(): void {
  357. this.wakeGeneration += 1
  358. this.wakeWaiter?.resolve()
  359. this.wakeWaiter = undefined
  360. }
  361. private async waitForPoll(delayMs: number, generation: number): Promise<void> {
  362. if (generation !== this.wakeGeneration) return
  363. const wake = Promise.withResolvers<void>()
  364. const waiter = { generation, resolve: wake.resolve }
  365. const sleepController = new AbortController()
  366. this.wakeWaiter = waiter
  367. try {
  368. await Promise.race([this.sleep(delayMs, sleepController.signal), wake.promise])
  369. } finally {
  370. sleepController.abort()
  371. if (this.wakeWaiter === waiter) this.wakeWaiter = undefined
  372. }
  373. }
  374. async waitForExit(): Promise<void> {
  375. if (this.stopped) return
  376. this.observation ??= (async () => {
  377. let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS
  378. let generation = this.wakeGeneration
  379. while (await this.rangeActive()) {
  380. await this.waitForPoll(pollIntervalMs, generation)
  381. generation = this.wakeGeneration
  382. // Keep establishment responsive, then reduce systemctl process churn
  383. // while systemd remains the authoritative owner of an active range.
  384. if (this.establishment === 'established') {
  385. pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS)
  386. }
  387. }
  388. this.stopped = true
  389. })().catch((error: unknown) => {
  390. this.observation = undefined
  391. throw error
  392. })
  393. await this.observation
  394. }
  395. cleanup(): void {
  396. cleanupLinuxLaunchFiles(this.startup.files)
  397. }
  398. }
  399. function scopeArgs(unitBase: string, invocation: RunnerInvocation, argv: readonly string[]): string[] {
  400. return [
  401. '--user',
  402. '--scope',
  403. '--quiet',
  404. '--collect',
  405. '--expand-environment=no',
  406. `--unit=${unitBase}`,
  407. '--',
  408. ...invocation,
  409. '--',
  410. ...argv,
  411. ]
  412. }
  413. function directOutcome(
  414. child: ReturnType<typeof spawn>,
  415. startup: LinuxScopeStartup,
  416. ): Promise<SubprocessOutcome> {
  417. return new Promise((resolveOutcome, rejectOutcome) => {
  418. let settled = false
  419. child.once('error', (error) => {
  420. if (settled) return
  421. settled = true
  422. rejectOutcome(error)
  423. })
  424. child.once('exit', (exitCode, signal) => {
  425. if (settled) return
  426. settled = true
  427. try {
  428. resolveOutcome(startup.resolveOutcome({ exitCode, signal }))
  429. } catch (error) {
  430. /* v8 ignore next -- Node filesystem operations throw Error instances. */
  431. const failure = error instanceof Error ? error : new Error(String(error))
  432. rejectOutcome(failure)
  433. }
  434. })
  435. })
  436. }
  437. /**
  438. * Send a direct-process signal, distinguishing an absent PID from failed delivery.
  439. * @param pid - owned direct-process identity whose exit notification can still be pending.
  440. * @param send - platform signal operation; true means the signal was submitted.
  441. * @returns whether the signal was submitted or the owned PID is already absent.
  442. */
  443. export function signalLinuxDirectProcess(pid: number, send: () => boolean): boolean {
  444. try {
  445. if (send()) return true
  446. } catch { /* A failed signal still permits an independent absence observation. */ }
  447. try {
  448. process.kill(pid, 0)
  449. return false
  450. } catch (error) {
  451. return (error as NodeJS.ErrnoException).code === 'ESRCH'
  452. }
  453. }
  454. function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): boolean {
  455. let groupSignalled = false
  456. try {
  457. groupSignalled = process.kill(-(child.pid as number), signal)
  458. } catch { /* A missing or inaccessible group still permits a direct-process attempt. */ }
  459. if (groupSignalled && signal === 'SIGTERM') return true
  460. // Group success can reflect another member; joining direct exit requires its own SIGKILL submission.
  461. // ChildProcess.kill can emit an error that settles directOutcome before the real exit.
  462. return signalLinuxDirectProcess(child.pid as number, () => process.kill(child.pid as number, signal))
  463. }
  464. /** Linux PTY invocation and owner for the exact one-shot scope/bootstrap. */
  465. export interface LinuxTerminalScopeLaunch {
  466. command: string
  467. args: string[]
  468. cwd: string
  469. env: NodeJS.ProcessEnv
  470. bindOwner: (direct: DirectRange) => BoundProcessOwner
  471. resolveOutcome: (outcome: SubprocessOutcome) => SubprocessOutcome
  472. cleanup: () => void
  473. }
  474. /**
  475. * Prepare one Linux PTY scope using the same launch request and bootstrap core.
  476. * @param spec - terminal target request.
  477. * @param targetEnv - validated complete target environment.
  478. * @param internals - optional runner and systemd seams used by tests.
  479. * @returns invocation and ownership callbacks; requested termination preserves the observed signal even before bootstrap consumption.
  480. */
  481. export function prepareLinuxTerminalScope(
  482. spec: SubprocessTerminalSpawnSpec,
  483. targetEnv: Record<string, string>,
  484. internals: LinuxScopeInternals = {},
  485. ): LinuxTerminalScopeLaunch {
  486. const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
  487. const files = createLinuxLaunchFiles({ cwd: spec.cwd, env: targetEnv })
  488. const startup = new LinuxScopeStartup(files, 'terminal')
  489. const unitBase = unitStem('dsh-terminal')
  490. return {
  491. command: internals.systemdRun ?? 'systemd-run',
  492. args: scopeArgs(unitBase, invocation, spec.argv),
  493. cwd: process.cwd(),
  494. env: runnerEnvironment(files.requestPath, invocation),
  495. bindOwner: direct => new SystemdScopeOwner(
  496. `${unitBase}.scope`,
  497. startup,
  498. direct,
  499. internals.systemctl ?? 'systemctl',
  500. internals.spawnSync ?? spawnSync,
  501. internals.systemctlQuery ?? querySystemctl,
  502. internals.sleep ?? sleepWithAbort,
  503. ),
  504. resolveOutcome: outcome => startup.resolveOutcome(outcome),
  505. cleanup: () => { cleanupLinuxLaunchFiles(files) },
  506. }
  507. }
  508. /**
  509. * Launch one ordinary target inside a transient user scope.
  510. * @param spec - ordinary target request.
  511. * @param targetEnv - validated complete target environment.
  512. * @param internals - optional runner and systemd seams used by tests.
  513. * @returns streams, result, and scope owner; requested termination preserves the observed signal even before bootstrap consumption.
  514. */
  515. export function launchLinuxScope(
  516. spec: SubprocessSpawnSpec,
  517. targetEnv: Record<string, string>,
  518. internals: LinuxScopeInternals = {},
  519. ): ManagedProcessLaunch {
  520. const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
  521. const files = createLinuxLaunchFiles({
  522. cwd: spec.cwd, env: targetEnv,
  523. ...spec.stdio.control === undefined ? {} : { control: spec.stdio.control },
  524. })
  525. const startup = new LinuxScopeStartup(files, 'subprocess')
  526. const unitBase = unitStem('dsh-subprocess')
  527. let child: ReturnType<typeof spawn>
  528. try {
  529. child = (internals.spawn ?? spawn)(internals.systemdRun ?? 'systemd-run', scopeArgs(
  530. unitBase,
  531. invocation,
  532. spec.argv,
  533. ), {
  534. cwd: process.cwd(),
  535. env: runnerEnvironment(files.requestPath, invocation),
  536. stdio: runnerStdio(spec, false),
  537. detached: true,
  538. })
  539. } catch (error) {
  540. cleanupLinuxLaunchFiles(files)
  541. throw error
  542. }
  543. const direct = directOutcome(child, startup)
  544. const owner = new SystemdScopeOwner(
  545. `${unitBase}.scope`,
  546. startup,
  547. {
  548. running: () => child.pid !== undefined && child.exitCode === null && child.signalCode === null,
  549. signal: signal => signalChildGroup(child, signal),
  550. settled: direct,
  551. },
  552. internals.systemctl ?? 'systemctl',
  553. internals.spawnSync ?? spawnSync,
  554. internals.systemctlQuery ?? querySystemctl,
  555. internals.sleep ?? sleepWithAbort,
  556. )
  557. return {
  558. stdin: child.stdin,
  559. stdout: child.stdout,
  560. stderr: child.stderr,
  561. control: controlPipe(child, spec.stdio.control),
  562. direct,
  563. owner,
  564. }
  565. }