linux-scope.spec.ts 19 KB

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