linux-scope.ts 17 KB

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