linux-scope.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /** Linux user-systemd scope launch and managed-range ownership. */
  2. import { randomBytes } from 'node:crypto'
  3. import { execFile, spawn, spawnSync } from 'node:child_process'
  4. import { setTimeout as sleepMs } from 'node:timers/promises'
  5. import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  6. import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
  7. import { DirectResultUnavailableError, observeChildLifecycle } from './managed-owner.ts'
  8. import { childEnv } from './spawn.ts'
  9. import {
  10. cleanupAfterRunner,
  11. type RunnerInvocation,
  12. runnerDirectResult,
  13. runnerFiles,
  14. runnerStdio,
  15. spawnRunnerInvocation,
  16. } from './runner-launch.ts'
  17. import { cleanupRunnerFiles } from './runner-protocol.ts'
  18. /** Test seams for systemd command execution. */
  19. export interface LinuxScopeInternals {
  20. spawn?: typeof spawn
  21. spawnSync?: typeof spawnSync
  22. systemctlQuery?: (command: string, args: readonly string[]) => Promise<SystemctlResult>
  23. systemdRun?: string
  24. systemctl?: string
  25. runnerInvocation?: RunnerInvocation
  26. }
  27. interface SystemctlResult {
  28. status: number | null
  29. stdout: string
  30. stderr: string
  31. error?: Error
  32. }
  33. const SYSTEMCTL_TIMEOUT_MS = 5_000
  34. const SCOPE_POLL_INTERVAL_MS = 200
  35. const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu
  36. function systemctlEnv(): NodeJS.ProcessEnv {
  37. return childEnv({ LC_ALL: 'C' })
  38. }
  39. function querySystemctl(command: string, args: readonly string[]): Promise<SystemctlResult> {
  40. return new Promise((resolve) => {
  41. execFile(command, [...args], {
  42. encoding: 'utf8',
  43. env: systemctlEnv(),
  44. timeout: SYSTEMCTL_TIMEOUT_MS,
  45. }, (error, stdout, stderr) => {
  46. const code = error === null ? 0 : (error as Error & { code?: string | number }).code
  47. resolve({
  48. status: typeof code === 'number' ? code : null,
  49. stdout,
  50. stderr,
  51. ...error === null ? {} : { error },
  52. })
  53. })
  54. })
  55. }
  56. function unitStem(prefix: string): string {
  57. return `${prefix}-${process.pid}-${randomBytes(6).toString('hex')}`
  58. }
  59. /**
  60. * Confirm a modern readable user manager and literal-argument scope launch.
  61. * @param internals - injected command paths and runners.
  62. * @returns true only before any user command is selected for native launch.
  63. */
  64. export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
  65. const runSync = internals.spawnSync ?? spawnSync
  66. const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
  67. const [runnerCommand, ...runnerPrefix] = invocation
  68. const systemdRun = internals.systemdRun ?? 'systemd-run'
  69. const systemctl = internals.systemctl ?? 'systemctl'
  70. const timeout = 5_000
  71. const manager = runSync(systemctl, ['--user', 'show-environment'], {
  72. encoding: 'utf8',
  73. env: systemctlEnv(),
  74. stdio: 'ignore',
  75. timeout,
  76. })
  77. if (manager.error !== undefined || manager.status !== 0) return false
  78. const runner = runSync(runnerCommand, [...runnerPrefix, '--mode', 'probe-node'], {
  79. env: childEnv(),
  80. stdio: 'ignore',
  81. timeout,
  82. })
  83. if (runner.error !== undefined || runner.status !== 0) return false
  84. const unitBase = unitStem('dsh-subprocess-probe')
  85. const probe = runSync(systemdRun, [
  86. '--user',
  87. '--scope',
  88. '--quiet',
  89. '--collect',
  90. '--expand-environment=no',
  91. `--unit=${unitBase}`,
  92. '--',
  93. systemctl,
  94. '--user',
  95. 'show',
  96. `${unitBase}.scope`,
  97. '--property=ActiveState',
  98. '--value',
  99. ], {
  100. env: childEnv(),
  101. stdio: 'ignore',
  102. timeout,
  103. })
  104. return probe.error === undefined && probe.status === 0
  105. }
  106. class SystemdScopeOwner implements BoundProcessOwner {
  107. private stopped = false
  108. private observation: Promise<void> | undefined
  109. private killFailure: Error | undefined
  110. constructor(
  111. private readonly unit: string,
  112. private readonly systemctl: string,
  113. private readonly runSync: typeof spawnSync,
  114. private readonly query: (command: string, args: readonly string[]) => Promise<SystemctlResult>,
  115. private readonly launcherRunning: () => boolean,
  116. private readonly onForceKillAttempt: () => void,
  117. ) {}
  118. signal(signal: 'SIGTERM' | 'SIGKILL'): void {
  119. if (this.stopped) return
  120. const result = this.runSync(this.systemctl, [
  121. '--user',
  122. 'kill',
  123. '--kill-whom=all',
  124. `--signal=${signal}`,
  125. this.unit,
  126. ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS })
  127. if (signal === 'SIGKILL' && result.error === undefined) this.onForceKillAttempt()
  128. if (result.error === undefined && result.status === 0) {
  129. return
  130. }
  131. if (signal === 'SIGKILL') {
  132. const output = `${result.stdout}\n${result.stderr}`
  133. this.killFailure = result.error ?? new Error(
  134. `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`,
  135. )
  136. }
  137. }
  138. private async active(): Promise<boolean> {
  139. const result = await this.query(this.systemctl, [
  140. '--user',
  141. 'show',
  142. this.unit,
  143. '--property=ActiveState',
  144. '--value',
  145. ])
  146. const output = `${result.stdout}\n${result.stderr}`
  147. if (result.status !== 0) {
  148. if (MISSING_UNIT.test(output)) {
  149. if (!this.launcherRunning()) return false
  150. } else {
  151. if (result.error !== undefined) throw result.error
  152. throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)
  153. }
  154. } else {
  155. const state = result.stdout.trim()
  156. if (state === 'inactive' || state === 'failed') return false
  157. if (state !== 'active' && state !== 'activating' && state !== 'deactivating') {
  158. throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`)
  159. }
  160. }
  161. if (this.killFailure !== undefined) throw this.killFailure
  162. return true
  163. }
  164. async waitForExit(): Promise<void> {
  165. if (this.stopped) return
  166. this.observation ??= (async () => {
  167. while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS)
  168. this.stopped = true
  169. })()
  170. await this.observation
  171. }
  172. }
  173. /** Prepared node-pty argv plus the owner for the exact transient scope it enters. */
  174. export interface LinuxTerminalScopeLaunch {
  175. command: string
  176. args: string[]
  177. bindOwner(launcherRunning: () => boolean): BoundProcessOwner
  178. }
  179. /**
  180. * Wrap one terminal argv directly in a transient user-systemd scope.
  181. * @param argv - original terminal command and arguments.
  182. * @param internals - injected systemd commands used by tests.
  183. * @returns the node-pty command, literal arguments, and owner binding for the same unit.
  184. */
  185. export function prepareLinuxTerminalScope(
  186. argv: readonly string[],
  187. internals: LinuxScopeInternals = {},
  188. ): LinuxTerminalScopeLaunch {
  189. const runSync = internals.spawnSync ?? spawnSync
  190. const query = internals.systemctlQuery ?? querySystemctl
  191. const systemdRun = internals.systemdRun ?? 'systemd-run'
  192. const systemctl = internals.systemctl ?? 'systemctl'
  193. const unitBase = unitStem('dsh-terminal')
  194. return {
  195. command: systemdRun,
  196. args: [
  197. '--user',
  198. '--scope',
  199. '--quiet',
  200. '--collect',
  201. '--expand-environment=no',
  202. `--unit=${unitBase}`,
  203. '--',
  204. ...argv,
  205. ],
  206. bindOwner: launcherRunning => new SystemdScopeOwner(
  207. `${unitBase}.scope`,
  208. systemctl,
  209. runSync,
  210. query,
  211. launcherRunning,
  212. () => {},
  213. ),
  214. }
  215. }
  216. /**
  217. * Launch one direct command inside a transient user scope.
  218. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings.
  219. * @param internals - injected command runners used by platform tests.
  220. * @returns wrapper streams, target outcome, and the bound scope owner.
  221. */
  222. export function launchLinuxScope(
  223. spec: SubprocessSpawnSpec,
  224. internals: LinuxScopeInternals = {},
  225. ): ManagedProcessLaunch {
  226. const run = internals.spawn ?? spawn
  227. const runSync = internals.spawnSync ?? spawnSync
  228. const query = internals.systemctlQuery ?? querySystemctl
  229. const systemdRun = internals.systemdRun ?? 'systemd-run'
  230. const systemctl = internals.systemctl ?? 'systemctl'
  231. const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
  232. const files = runnerFiles(spec)
  233. const unitBase = unitStem('dsh-subprocess')
  234. let child: ReturnType<typeof spawn>
  235. try {
  236. child = run(systemdRun, [
  237. '--user',
  238. '--scope',
  239. '--quiet',
  240. '--collect',
  241. '--expand-environment=no',
  242. `--unit=${unitBase}`,
  243. '--',
  244. ...invocation,
  245. '--mode',
  246. 'node',
  247. '--request',
  248. files.requestPath,
  249. '--events',
  250. files.eventsPath,
  251. ], {
  252. env: childEnv(),
  253. stdio: runnerStdio(spec),
  254. })
  255. } catch (error) {
  256. cleanupRunnerFiles(files)
  257. throw error
  258. }
  259. const lifecycle = observeChildLifecycle(child)
  260. let forceKillAttempted = false
  261. const owner = new SystemdScopeOwner(
  262. `${unitBase}.scope`,
  263. systemctl,
  264. runSync,
  265. query,
  266. () => child.pid !== undefined && child.exitCode === null && child.signalCode === null,
  267. () => { forceKillAttempted = true },
  268. )
  269. const result = runnerDirectResult(child, files, lifecycle.exited)
  270. const direct = result.direct.catch(async (error: unknown): Promise<SubprocessOutcome> => {
  271. if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error
  272. await owner.waitForExit()
  273. return { exitCode: null, signal: 'SIGKILL' }
  274. })
  275. cleanupAfterRunner(files, direct, lifecycle.closed)
  276. return {
  277. stdin: child.stdin,
  278. stdout: child.stdout,
  279. stderr: child.stderr,
  280. pid: result.pid,
  281. direct,
  282. owner,
  283. }
  284. }