linux-scope.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  4. import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts'
  5. import { spawnRunnerInvocation } from '../src/runner-launch.ts'
  6. function spec(argv: string[]): SubprocessSpawnSpec {
  7. return {
  8. argv,
  9. cwd: process.cwd(),
  10. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
  11. graceMs: 100,
  12. env: { LITERAL_VALUE: '$HOME ${UNCHANGED}' },
  13. }
  14. }
  15. function asyncQuery(runSync: typeof spawnSync) {
  16. return async (command: string, args: readonly string[]) => {
  17. const result = runSync(command, [...args], { encoding: 'utf8', timeout: 5_000 })
  18. return {
  19. status: result.status,
  20. stdout: typeof result.stdout === 'string' ? result.stdout : '',
  21. stderr: typeof result.stderr === 'string' ? result.stderr : '',
  22. ...result.error === undefined ? {} : { error: result.error },
  23. }
  24. }
  25. }
  26. describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => {
  27. it('requires a readable user manager and literal-argument systemd support', () => {
  28. const calls: string[][] = []
  29. const environments: Array<NodeJS.ProcessEnv | undefined> = []
  30. const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => {
  31. calls.push([command, ...args])
  32. environments.push(options?.env)
  33. return { status: 0, error: undefined }
  34. }) as unknown as typeof spawnSync
  35. const runnerInvocation = ['node-runtime', 'runner-entry.js']
  36. expect(probeLinuxScope({
  37. spawnSync: runSync,
  38. systemdRun: 'systemd-run',
  39. systemctl: 'systemctl',
  40. runnerInvocation,
  41. })).toBe(true)
  42. expect(calls[1]).toContain('--expand-environment=no')
  43. expect(calls[1]).not.toContain('--pipe')
  44. expect(calls[1]).not.toContain('--wait')
  45. const separator = calls[1]?.indexOf('--') ?? -1
  46. expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node'])
  47. expect(environments[0]?.LC_ALL).toBe('C')
  48. const oldSystemd = vi.fn((command: string) => ({
  49. status: command === 'systemctl' ? 0 : 1,
  50. error: undefined,
  51. })) as unknown as typeof spawnSync
  52. expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false)
  53. const managerError = new Error('missing user manager')
  54. expect(probeLinuxScope({
  55. spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync,
  56. })).toBe(false)
  57. expect(probeLinuxScope({
  58. spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
  59. })).toBe(false)
  60. const emptyInvocation = vi.fn() as unknown as typeof spawnSync
  61. expect(probeLinuxScope({ spawnSync: emptyInvocation, runnerInvocation: [] })).toBe(false)
  62. expect(emptyInvocation).not.toHaveBeenCalled()
  63. })
  64. it('keeps user argv out of systemd-run and reports the direct target outcome', async () => {
  65. let wrapper: ReturnType<typeof spawn> | undefined
  66. let systemdArgs: readonly string[] = []
  67. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  68. systemdArgs = args
  69. const separator = args.indexOf('--')
  70. const command = args[separator + 1] as string
  71. wrapper = spawn(command, args.slice(separator + 2), options)
  72. return wrapper
  73. }) as unknown as typeof spawn
  74. const runSyncMock = vi.fn((command: string, args: readonly string[]) => {
  75. if (command === 'systemctl' && args[1] === 'show') {
  76. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  77. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  78. }
  79. return { status: 0, stdout: '', stderr: '', error: undefined }
  80. })
  81. const runSync = runSyncMock as unknown as typeof spawnSync
  82. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(9)', 'literal $VALUE']), {
  83. spawn: run,
  84. spawnSync: runSync,
  85. systemctlQuery: asyncQuery(runSync),
  86. runnerInvocation: spawnRunnerInvocation(),
  87. })
  88. await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null })
  89. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  90. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  91. const callsBeforeStaleSignal = runSyncMock.mock.calls.length
  92. launch.owner.signal('SIGKILL')
  93. expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal)
  94. expect(systemdArgs).toContain('--expand-environment=no')
  95. expect(systemdArgs).not.toContain('--pipe')
  96. expect(systemdArgs).not.toContain('--wait')
  97. expect(systemdArgs).not.toContain('literal $VALUE')
  98. })
  99. it('still escalates after a missing-unit TERM response and uses the authoritative scope KILL', async () => {
  100. let wrapper: ReturnType<typeof spawn> | undefined
  101. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  102. const separator = args.indexOf('--')
  103. const command = args[separator + 1] as string
  104. wrapper = spawn(command, args.slice(separator + 2), { ...options, detached: true })
  105. return wrapper
  106. }) as unknown as typeof spawn
  107. const runSync = vi.fn((command: string, args: readonly string[]) => {
  108. if (command === 'systemctl' && args[1] === 'kill') {
  109. if (args.includes('--signal=SIGTERM')) {
  110. return { status: 1, stdout: '', stderr: 'Unit could not be found', error: undefined }
  111. }
  112. if (wrapper?.pid !== undefined) process.kill(-wrapper.pid, 'SIGKILL')
  113. }
  114. if (command === 'systemctl' && args[1] === 'show') {
  115. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  116. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  117. }
  118. return { status: 0, stdout: '', stderr: '', error: undefined }
  119. }) as unknown as typeof spawnSync
  120. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  121. spawn: run,
  122. spawnSync: runSync,
  123. systemctlQuery: asyncQuery(runSync),
  124. runnerInvocation: spawnRunnerInvocation(),
  125. })
  126. launch.owner.signal('SIGTERM')
  127. launch.owner.signal('SIGKILL')
  128. await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  129. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  130. })
  131. it('rejects wait when the selected native owner becomes unreadable', async () => {
  132. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  133. const separator = args.indexOf('--')
  134. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  135. }) as unknown as typeof spawn
  136. const runSync = vi.fn(() => ({
  137. status: 1,
  138. stdout: '',
  139. stderr: 'Failed to connect to bus: No such file or directory',
  140. error: undefined,
  141. })) as unknown as typeof spawnSync
  142. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  143. spawn: run,
  144. spawnSync: runSync,
  145. systemctlQuery: asyncQuery(runSync),
  146. runnerInvocation: spawnRunnerInvocation(),
  147. })
  148. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  149. await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus')
  150. })
  151. it('propagates systemctl execution failures and unknown active states', async () => {
  152. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  153. const separator = args.indexOf('--')
  154. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  155. }) as unknown as typeof spawn
  156. const failure = new Error('systemctl execution failed')
  157. const failedRead = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  158. spawn: run,
  159. spawnSync: vi.fn(() => ({ error: failure })) as unknown as typeof spawnSync,
  160. systemctlQuery: async () => ({ status: null, stdout: '', stderr: '', error: failure }),
  161. runnerInvocation: spawnRunnerInvocation(),
  162. })
  163. await expect(failedRead.owner.waitForExit()).rejects.toBe(failure)
  164. await expect(failedRead.direct).resolves.toEqual({ exitCode: 0, signal: null })
  165. const unknownState = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  166. spawn: run,
  167. spawnSync: vi.fn(() => ({ status: 0, stdout: 'reloading\n', stderr: '', error: undefined })) as unknown as typeof spawnSync,
  168. systemctlQuery: async () => ({ status: 0, stdout: 'reloading\n', stderr: '' }),
  169. runnerInvocation: spawnRunnerInvocation(),
  170. })
  171. await expect(unknownState.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
  172. await expect(unknownState.direct).resolves.toEqual({ exitCode: 0, signal: null })
  173. const blankFailure = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  174. spawn: run,
  175. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync,
  176. systemctlQuery: async () => ({ status: 1, stdout: '', stderr: '' }),
  177. runnerInvocation: spawnRunnerInvocation(),
  178. })
  179. await expect(blankFailure.owner.waitForExit()).rejects.toThrow('exit 1')
  180. await expect(blankFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  181. })
  182. it.each(['activating', 'deactivating', 'failed'])(
  183. 'recognizes the %s scope state',
  184. async (initialState) => {
  185. let wrapper: ReturnType<typeof spawn> | undefined
  186. let reads = 0
  187. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  188. const separator = args.indexOf('--')
  189. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  190. return wrapper
  191. }) as unknown as typeof spawn
  192. const runSync = vi.fn(() => {
  193. reads += 1
  194. return {
  195. status: 0,
  196. stdout: reads === 1 ? `${initialState}\n` : 'inactive\n',
  197. stderr: '',
  198. error: undefined,
  199. }
  200. }) as unknown as typeof spawnSync
  201. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  202. spawn: run,
  203. spawnSync: runSync,
  204. systemctlQuery: asyncQuery(runSync),
  205. runnerInvocation: spawnRunnerInvocation(),
  206. })
  207. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  208. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  209. },
  210. )
  211. it('uses runner liveness when systemd has already forgotten the scope', async () => {
  212. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  213. const separator = args.indexOf('--')
  214. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  215. }) as unknown as typeof spawn
  216. const runSyncMock = vi.fn(() => ({
  217. status: 1,
  218. stdout: '',
  219. stderr: 'Unit could not be found',
  220. error: undefined,
  221. }))
  222. const runSync = runSyncMock as unknown as typeof spawnSync
  223. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setTimeout(() => {}, 40)']), {
  224. spawn: run,
  225. spawnSync: runSync,
  226. systemctlQuery: asyncQuery(runSync),
  227. runnerInvocation: spawnRunnerInvocation(),
  228. })
  229. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  230. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  231. expect(runSyncMock.mock.calls.length).toBeGreaterThan(1)
  232. })
  233. it('settles a missing scope immediately when the wrapper never started', async () => {
  234. const launch = launchLinuxScope(spec([process.execPath, '-e', '']), {
  235. systemdRun: `missing-systemd-run-${String(process.pid)}-${String(Date.now())}`,
  236. systemctlQuery: async () => ({
  237. status: 1,
  238. stdout: '',
  239. stderr: 'Unit dsh-subprocess-missing.scope could not be found',
  240. }),
  241. runnerInvocation: spawnRunnerInvocation(),
  242. })
  243. expect(launch.child.pid).toBeUndefined()
  244. await expect(launch.direct).rejects.toThrow('runner failed to start')
  245. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  246. await launch.closed
  247. })
  248. it('does not fabricate a direct outcome after a non-forced scope signal', async () => {
  249. let wrapper: ReturnType<typeof spawn> | undefined
  250. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  251. const separator = args.indexOf('--')
  252. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), { ...options, detached: true })
  253. return wrapper
  254. }) as unknown as typeof spawn
  255. const runSync = vi.fn((command: string, args: readonly string[]) => {
  256. if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) {
  257. process.kill(-wrapper.pid, 'SIGKILL')
  258. }
  259. if (command === 'systemctl' && args[1] === 'show') {
  260. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  261. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  262. }
  263. return { status: 0, stdout: '', stderr: '', error: undefined }
  264. }) as unknown as typeof spawnSync
  265. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  266. spawn: run,
  267. spawnSync: runSync,
  268. systemctlQuery: asyncQuery(runSync),
  269. runnerInvocation: spawnRunnerInvocation(),
  270. })
  271. launch.owner.signal('SIGTERM')
  272. await expect(launch.direct).rejects.toThrow('exited without a direct-command result')
  273. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  274. })
  275. it.each([
  276. [
  277. 'execution error',
  278. { status: null, stdout: '', stderr: '', error: new Error('systemctl execution failed') },
  279. 'systemctl execution failed',
  280. ],
  281. [
  282. 'stderr',
  283. { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined },
  284. 'Failed to connect to bus',
  285. ],
  286. [
  287. 'exit status',
  288. { status: 1, stdout: '', stderr: '', error: undefined },
  289. 'exit 1',
  290. ],
  291. ])('reports a failed scope KILL through the shared wait: %s', async (_label, failure, message) => {
  292. let wrapper: ReturnType<typeof spawn> | undefined
  293. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  294. const separator = args.indexOf('--')
  295. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  296. return wrapper
  297. }) as unknown as typeof spawn
  298. const runSyncMock = vi.fn((
  299. command: string,
  300. args: readonly string[],
  301. _options?: { env?: NodeJS.ProcessEnv },
  302. ) => {
  303. if (command === 'systemctl' && args[1] === 'kill') {
  304. return failure
  305. }
  306. return { status: 0, stdout: 'active\n', stderr: '', error: undefined }
  307. })
  308. const runSync = runSyncMock as unknown as typeof spawnSync
  309. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  310. spawn: run,
  311. spawnSync: runSync,
  312. systemctlQuery: asyncQuery(runSync),
  313. runnerInvocation: spawnRunnerInvocation(),
  314. })
  315. void launch.direct.catch(() => {})
  316. try {
  317. launch.owner.signal('SIGKILL')
  318. await expect(launch.owner.waitForExit()).rejects.toThrow(message)
  319. const killCall = runSyncMock.mock.calls.find(([, args]) => args.includes('--signal=SIGKILL'))
  320. expect(killCall?.[0]).toBe('systemctl')
  321. expect(killCall?.[1]).toContain('kill')
  322. expect(killCall?.[1]).toContain('--kill-whom=all')
  323. expect(killCall?.[2]?.env?.LC_ALL).toBe('C')
  324. } finally {
  325. wrapper?.kill('SIGKILL')
  326. }
  327. })
  328. it('uses the production command defaults when no Linux internals are supplied', async () => {
  329. let wrapper: ReturnType<typeof spawn> | undefined
  330. let queryFailure: (Error & { code?: string | number }) | undefined
  331. const run = vi.fn()
  332. const runSync = vi.fn()
  333. const runAsync = vi.fn()
  334. const queryEnvironments: Array<NodeJS.ProcessEnv | undefined> = []
  335. vi.resetModules()
  336. vi.doMock('node:child_process', async (importOriginal) => {
  337. const actual = await importOriginal<typeof import('node:child_process')>()
  338. run.mockImplementation((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  339. const separator = args.indexOf('--')
  340. wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  341. return wrapper
  342. })
  343. runSync.mockImplementation((_command: string, _args: readonly string[]) => {
  344. return { status: 0, stdout: '', stderr: '', error: undefined }
  345. })
  346. runAsync.mockImplementation((
  347. _command: string,
  348. args: readonly string[],
  349. options: { env?: NodeJS.ProcessEnv },
  350. callback: (error: Error | null, stdout: string, stderr: string) => void,
  351. ) => {
  352. queryEnvironments.push(options.env)
  353. if (queryFailure !== undefined) {
  354. callback(queryFailure, '', '')
  355. return
  356. }
  357. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  358. callback(null, args[1] === 'show' && active ? 'active\n' : 'inactive\n', '')
  359. })
  360. return { ...actual, execFile: runAsync, spawn: run, spawnSync: runSync }
  361. })
  362. try {
  363. const defaults = await import('../src/linux-scope.ts')
  364. expect(defaults.probeLinuxScope()).toBe(true)
  365. const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  366. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  367. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  368. expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
  369. expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object))
  370. expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function))
  371. expect(queryEnvironments[0]?.LC_ALL).toBe('C')
  372. queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 })
  373. const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  374. await expect(numericFailure.owner.waitForExit()).rejects.toBe(queryFailure)
  375. await expect(numericFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  376. queryFailure = Object.assign(new Error('named systemctl failure'), { code: 'EQUERY' })
  377. const namedFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  378. await expect(namedFailure.owner.waitForExit()).rejects.toBe(queryFailure)
  379. await expect(namedFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  380. } finally {
  381. vi.doUnmock('node:child_process')
  382. vi.resetModules()
  383. }
  384. })
  385. })