linux-scope.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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. inspectTaskCount(): number | undefined {
  183. const result = this.runSync(this.systemctl, [
  184. '--user', 'show', '--property=LoadState', '--property=ActiveState', '--property=TasksCurrent', this.unit,
  185. ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
  186. if (result.error !== undefined || result.status !== 0 || typeof result.stdout !== 'string') return undefined
  187. const state = this.parseUnitState(result.stdout)
  188. return state.loadState === 'loaded' && state.activeState === 'active' ? state.tasksCurrent : undefined
  189. }
  190. signal(signal: 'SIGTERM' | 'SIGKILL'): void {
  191. if (this.stopped) return
  192. this.terminationRequested = true
  193. if (this.direct.running()) this.startup.terminationSignals.add(signal)
  194. this.observeRequestConsumption()
  195. const directFallbackRequired = this.establishment === 'pending'
  196. let directSignalled = false
  197. if (directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
  198. const result = this.runSync(this.systemctl, [
  199. '--user',
  200. 'kill',
  201. '--kill-whom=all',
  202. `--signal=${signal}`,
  203. this.unit,
  204. ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
  205. this.wakeObservation()
  206. if (result.error === undefined && result.status === 0) {
  207. if (signal === 'SIGKILL') {
  208. this.killFailure = undefined
  209. this.directKillSettlement = undefined
  210. }
  211. return
  212. }
  213. if (!directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
  214. if (signal === 'SIGKILL') {
  215. const output = `${result.stdout}\n${result.stderr}`
  216. if (!MISSING_UNIT.test(output)) {
  217. this.killFailure = result.error ?? new Error(
  218. `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`,
  219. )
  220. // The direct outcome retains errors; this barrier only joins its physical settlement.
  221. this.directKillSettlement = directSignalled
  222. ? this.direct.settled.then(() => {}, () => {})
  223. : undefined
  224. }
  225. }
  226. }
  227. terminateForHostExit(): void {
  228. if (this.stopped) return
  229. try {
  230. if (this.direct.running()) this.direct.signal('SIGKILL')
  231. } catch { /* Continue with the native owner. */ }
  232. try {
  233. this.runSync(this.systemctl, [
  234. '--user',
  235. 'kill',
  236. '--kill-whom=all',
  237. '--signal=SIGKILL',
  238. this.unit,
  239. ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS })
  240. } catch {
  241. // Host exit cannot report one range; the runtime continues with the rest.
  242. }
  243. }
  244. private observeRequestConsumption(): void {
  245. if (this.establishment === 'pending' && !existsSync(this.startup.files.requestPath)) {
  246. this.establishment = 'established'
  247. }
  248. }
  249. private absentUnit(): boolean {
  250. this.observeRequestConsumption()
  251. if (this.establishment === 'established') return false
  252. if (!this.direct.running() && existsSync(this.startup.files.requestPath)) {
  253. return false
  254. }
  255. if (this.killFailure !== undefined) throw this.killFailure
  256. return true
  257. }
  258. /**
  259. * Prove an active unit with no processes is the empty managed range rather
  260. * than a launch still placing its payload. systemd ends a scope only on the
  261. * populated-to-empty transition, so a payload killed before it entered the
  262. * cgroup leaves the unit active forever. A departed client cannot add another
  263. * payload; a consumed request proves the payload already entered the scope,
  264. * even while its direct-process exit notification is pending.
  265. */
  266. private emptyRange(tasksCurrent: number | undefined): boolean {
  267. return this.terminationRequested && tasksCurrent === 0
  268. && (!this.direct.running() || !existsSync(this.startup.files.requestPath))
  269. }
  270. /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */
  271. private releaseEmptyRange(): void {
  272. try {
  273. this.runSync(this.systemctl, ['--user', 'stop', this.unit], {
  274. env: managerEnvironment(),
  275. stdio: 'ignore',
  276. timeout: SYSTEMCTL_TIMEOUT_MS,
  277. })
  278. } catch {
  279. // The range is already empty; a failed cleanup leaves only the transient unit.
  280. }
  281. }
  282. private parseUnitState(stdout: string): { loadState: string; activeState: string; tasksCurrent: number | undefined } {
  283. const values = new Map<string, string>()
  284. for (const line of stdout.split(/\r?\n/u)) {
  285. if (line === '') continue
  286. const separator = line.indexOf('=')
  287. if (separator <= 0) {
  288. throw new Error(`systemctl returned malformed state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
  289. }
  290. const name = line.slice(0, separator)
  291. if (values.has(name)) {
  292. throw new Error(`systemctl returned duplicate ${name} for ${this.unit}`)
  293. }
  294. values.set(name, line.slice(separator + 1))
  295. }
  296. const loadState = values.get('LoadState')
  297. const activeState = values.get('ActiveState')
  298. // The manager prints this sentinel for a property the unit does not carry.
  299. const reportedTasks = values.get('TasksCurrent')
  300. const tasksCurrent = reportedTasks === '[not set]' ? undefined : reportedTasks
  301. if (values.size !== (reportedTasks === undefined ? 2 : 3)
  302. || loadState === undefined || activeState === undefined) {
  303. throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`)
  304. }
  305. if (tasksCurrent !== undefined && !/^\d+$/u.test(tasksCurrent)) {
  306. throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`)
  307. }
  308. return {
  309. loadState,
  310. activeState,
  311. tasksCurrent: tasksCurrent === undefined ? undefined : Number(tasksCurrent),
  312. }
  313. }
  314. private async rangeActive(): Promise<boolean> {
  315. this.observeRequestConsumption()
  316. const generation = this.wakeGeneration
  317. const directRunning = this.direct.running()
  318. const result = await this.query(this.systemctl, [
  319. '--user',
  320. 'show',
  321. this.unit,
  322. '--property=LoadState',
  323. '--property=ActiveState',
  324. '--property=TasksCurrent',
  325. ])
  326. // A signal invalidates state queried before its delivery and direct fallback.
  327. if (generation !== this.wakeGeneration) return true
  328. const output = `${result.stdout}\n${result.stderr}`
  329. if (result.status === 0) {
  330. const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout)
  331. if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit()
  332. if (loadState !== 'loaded') {
  333. throw new Error(
  334. `systemctl returned unknown state for ${this.unit}: ${JSON.stringify({ loadState, activeState })}`,
  335. )
  336. }
  337. this.establishment = 'established'
  338. if (activeState === 'inactive' || activeState === 'failed') return false
  339. if (!['active', 'activating', 'reloading', 'deactivating'].includes(activeState)) {
  340. throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`)
  341. }
  342. if (this.emptyRange(tasksCurrent)) {
  343. this.releaseEmptyRange()
  344. return false
  345. }
  346. if (this.killFailure !== undefined) {
  347. if (directRunning && this.directKillSettlement !== undefined) {
  348. const settlement = this.directKillSettlement
  349. this.directKillSettlement = undefined
  350. await settlement
  351. // A query preceding direct exit cannot prove that its signalled processes survived.
  352. return this.rangeActive()
  353. }
  354. throw this.killFailure
  355. }
  356. return true
  357. }
  358. if (!MISSING_UNIT.test(output)) {
  359. if (result.error !== undefined) throw result.error
  360. throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)
  361. }
  362. return this.absentUnit()
  363. }
  364. private wakeObservation(): void {
  365. this.wakeGeneration += 1
  366. this.wakeWaiter?.resolve()
  367. this.wakeWaiter = undefined
  368. }
  369. private async waitForPoll(delayMs: number, generation: number): Promise<void> {
  370. if (generation !== this.wakeGeneration) return
  371. const wake = Promise.withResolvers<void>()
  372. const waiter = { generation, resolve: wake.resolve }
  373. const sleepController = new AbortController()
  374. this.wakeWaiter = waiter
  375. try {
  376. await Promise.race([this.sleep(delayMs, sleepController.signal), wake.promise])
  377. } finally {
  378. sleepController.abort()
  379. if (this.wakeWaiter === waiter) this.wakeWaiter = undefined
  380. }
  381. }
  382. async waitForExit(): Promise<void> {
  383. if (this.stopped) return
  384. this.observation ??= (async () => {
  385. let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS
  386. let generation = this.wakeGeneration
  387. while (await this.rangeActive()) {
  388. await this.waitForPoll(pollIntervalMs, generation)
  389. generation = this.wakeGeneration
  390. // Keep establishment responsive, then reduce systemctl process churn
  391. // while systemd remains the authoritative owner of an active range.
  392. if (this.establishment === 'established') {
  393. pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS)
  394. }
  395. }
  396. this.stopped = true
  397. })().catch((error: unknown) => {
  398. this.observation = undefined
  399. throw error
  400. })
  401. await this.observation
  402. }
  403. cleanup(): void {
  404. cleanupLinuxLaunchFiles(this.startup.files)
  405. }
  406. }
  407. function scopeArgs(unitBase: string, invocation: RunnerInvocation, argv: readonly string[]): string[] {
  408. return [
  409. '--user',
  410. '--scope',
  411. '--quiet',
  412. '--collect',
  413. '--expand-environment=no',
  414. `--unit=${unitBase}`,
  415. '--',
  416. ...invocation,
  417. '--',
  418. ...argv,
  419. ]
  420. }
  421. function directOutcome(
  422. child: ReturnType<typeof spawn>,
  423. startup: LinuxScopeStartup,
  424. ): Promise<SubprocessOutcome> {
  425. return new Promise((resolveOutcome, rejectOutcome) => {
  426. let settled = false
  427. child.once('error', (error) => {
  428. if (settled) return
  429. settled = true
  430. rejectOutcome(error)
  431. })
  432. child.once('exit', (exitCode, signal) => {
  433. if (settled) return
  434. settled = true
  435. try {
  436. resolveOutcome(startup.resolveOutcome({ exitCode, signal }))
  437. } catch (error) {
  438. /* v8 ignore next -- Node filesystem operations throw Error instances. */
  439. const failure = error instanceof Error ? error : new Error(String(error))
  440. rejectOutcome(failure)
  441. }
  442. })
  443. })
  444. }
  445. /**
  446. * Send a direct-process signal, distinguishing an absent PID from failed delivery.
  447. * @param pid - owned direct-process identity whose exit notification can still be pending.
  448. * @param send - platform signal operation; true means the signal was submitted.
  449. * @returns whether the signal was submitted or the owned PID is already absent.
  450. */
  451. export function signalLinuxDirectProcess(pid: number, send: () => boolean): boolean {
  452. try {
  453. if (send()) return true
  454. } catch { /* A failed signal still permits an independent absence observation. */ }
  455. try {
  456. process.kill(pid, 0)
  457. return false
  458. } catch (error) {
  459. return (error as NodeJS.ErrnoException).code === 'ESRCH'
  460. }
  461. }
  462. function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): boolean {
  463. let groupSignalled = false
  464. try {
  465. groupSignalled = process.kill(-(child.pid as number), signal)
  466. } catch { /* A missing or inaccessible group still permits a direct-process attempt. */ }
  467. if (groupSignalled && signal === 'SIGTERM') return true
  468. // Group success can reflect another member; joining direct exit requires its own SIGKILL submission.
  469. // ChildProcess.kill can emit an error that settles directOutcome before the real exit.
  470. return signalLinuxDirectProcess(child.pid as number, () => process.kill(child.pid as number, signal))
  471. }
  472. /** Linux PTY invocation and owner for the exact one-shot scope/bootstrap. */
  473. export interface LinuxTerminalScopeLaunch {
  474. command: string
  475. args: string[]
  476. cwd: string
  477. env: NodeJS.ProcessEnv
  478. bindOwner: (direct: DirectRange) => BoundProcessOwner
  479. resolveOutcome: (outcome: SubprocessOutcome) => SubprocessOutcome
  480. cleanup: () => void
  481. }
  482. /**
  483. * Prepare one Linux PTY scope using the same launch request and bootstrap core.
  484. * @param spec - terminal target request.
  485. * @param targetEnv - validated complete target environment.
  486. * @param internals - optional runner and systemd seams used by tests.
  487. * @returns invocation and ownership callbacks; requested termination preserves the observed signal even before bootstrap consumption.
  488. */
  489. export function prepareLinuxTerminalScope(
  490. spec: SubprocessTerminalSpawnSpec,
  491. targetEnv: Record<string, string>,
  492. internals: LinuxScopeInternals = {},
  493. ): LinuxTerminalScopeLaunch {
  494. const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
  495. const files = createLinuxLaunchFiles({ cwd: spec.cwd, env: targetEnv })
  496. const startup = new LinuxScopeStartup(files, 'terminal')
  497. const unitBase = unitStem('dsh-terminal')
  498. return {
  499. command: internals.systemdRun ?? 'systemd-run',
  500. args: scopeArgs(unitBase, invocation, spec.argv),
  501. cwd: process.cwd(),
  502. env: runnerEnvironment(files.requestPath, invocation),
  503. bindOwner: direct => new SystemdScopeOwner(
  504. `${unitBase}.scope`,
  505. startup,
  506. direct,
  507. internals.systemctl ?? 'systemctl',
  508. internals.spawnSync ?? spawnSync,
  509. internals.systemctlQuery ?? querySystemctl,
  510. internals.sleep ?? sleepWithAbort,
  511. ),
  512. resolveOutcome: outcome => startup.resolveOutcome(outcome),
  513. cleanup: () => { cleanupLinuxLaunchFiles(files) },
  514. }
  515. }
  516. /**
  517. * Launch one ordinary target inside a transient user scope.
  518. * @param spec - ordinary target request.
  519. * @param targetEnv - validated complete target environment.
  520. * @param internals - optional runner and systemd seams used by tests.
  521. * @returns streams, result, and scope owner; requested termination preserves the observed signal even before bootstrap consumption.
  522. */
  523. export function launchLinuxScope(
  524. spec: SubprocessSpawnSpec,
  525. targetEnv: Record<string, string>,
  526. internals: LinuxScopeInternals = {},
  527. ): ManagedProcessLaunch {
  528. const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
  529. const files = createLinuxLaunchFiles({
  530. cwd: spec.cwd, env: targetEnv,
  531. ...spec.stdio.control === undefined ? {} : { control: spec.stdio.control },
  532. })
  533. const startup = new LinuxScopeStartup(files, 'subprocess')
  534. const unitBase = unitStem('dsh-subprocess')
  535. let child: ReturnType<typeof spawn>
  536. try {
  537. child = (internals.spawn ?? spawn)(internals.systemdRun ?? 'systemd-run', scopeArgs(
  538. unitBase,
  539. invocation,
  540. spec.argv,
  541. ), {
  542. cwd: process.cwd(),
  543. env: runnerEnvironment(files.requestPath, invocation),
  544. stdio: runnerStdio(spec, false),
  545. detached: true,
  546. })
  547. } catch (error) {
  548. cleanupLinuxLaunchFiles(files)
  549. throw error
  550. }
  551. const direct = directOutcome(child, startup)
  552. const owner = new SystemdScopeOwner(
  553. `${unitBase}.scope`,
  554. startup,
  555. {
  556. running: () => child.pid !== undefined && child.exitCode === null && child.signalCode === null,
  557. signal: signal => signalChildGroup(child, signal),
  558. settled: direct,
  559. },
  560. internals.systemctl ?? 'systemctl',
  561. internals.spawnSync ?? spawnSync,
  562. internals.systemctlQuery ?? querySystemctl,
  563. internals.sleep ?? sleepWithAbort,
  564. )
  565. return {
  566. stdin: child.stdin,
  567. stdout: child.stdout,
  568. stderr: child.stderr,
  569. control: controlPipe(child, spec.stdio.control),
  570. direct,
  571. owner,
  572. }
  573. }