linux-scope.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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. describe('Linux systemd scope adapter', () => {
  16. it('requires a readable user manager and literal-argument systemd support', () => {
  17. const calls: string[][] = []
  18. const runSync = vi.fn((command: string, args: readonly string[]) => {
  19. calls.push([command, ...args])
  20. return { status: 0, error: undefined }
  21. }) as unknown as typeof spawnSync
  22. expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true)
  23. expect(calls[1]).toContain('--expand-environment=no')
  24. expect(calls[1]).not.toContain('--wait')
  25. const oldSystemd = vi.fn((command: string) => ({
  26. status: command === 'systemctl' ? 0 : 1,
  27. error: undefined,
  28. })) as unknown as typeof spawnSync
  29. expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false)
  30. const managerError = new Error('missing user manager')
  31. expect(probeLinuxScope({
  32. spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync,
  33. })).toBe(false)
  34. expect(probeLinuxScope({
  35. spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
  36. })).toBe(false)
  37. })
  38. it('keeps user argv out of systemd-run and reports the direct target outcome', async () => {
  39. let wrapper: ReturnType<typeof spawn> | undefined
  40. let systemdArgs: readonly string[] = []
  41. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  42. systemdArgs = args
  43. const separator = args.indexOf('--')
  44. const command = args[separator + 1] as string
  45. wrapper = spawn(command, args.slice(separator + 2), options)
  46. return wrapper
  47. }) as unknown as typeof spawn
  48. const runSyncMock = vi.fn((command: string, args: readonly string[]) => {
  49. if (command === 'systemctl' && args[1] === 'show') {
  50. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  51. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  52. }
  53. return { status: 0, stdout: '', stderr: '', error: undefined }
  54. })
  55. const runSync = runSyncMock as unknown as typeof spawnSync
  56. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(9)', 'literal $VALUE']), {
  57. spawn: run,
  58. spawnSync: runSync,
  59. runnerInvocation: spawnRunnerInvocation(),
  60. })
  61. await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null })
  62. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  63. const callsBeforeStaleSignal = runSyncMock.mock.calls.length
  64. launch.owner.signal('SIGKILL')
  65. expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal)
  66. expect(systemdArgs).toContain('--expand-environment=no')
  67. expect(systemdArgs).not.toContain('--wait')
  68. expect(systemdArgs).not.toContain('literal $VALUE')
  69. })
  70. it('uses the authoritative SIGKILL scope signal when the runner cannot report after force kill', async () => {
  71. let wrapper: ReturnType<typeof spawn> | undefined
  72. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  73. const separator = args.indexOf('--')
  74. const command = args[separator + 1] as string
  75. wrapper = spawn(command, args.slice(separator + 2), { ...options, detached: true })
  76. return wrapper
  77. }) as unknown as typeof spawn
  78. const runSync = vi.fn((command: string, args: readonly string[]) => {
  79. if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) {
  80. process.kill(-wrapper.pid, 'SIGKILL')
  81. }
  82. if (command === 'systemctl' && args[1] === 'show') {
  83. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  84. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  85. }
  86. return { status: 0, stdout: '', stderr: '', error: undefined }
  87. }) as unknown as typeof spawnSync
  88. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  89. spawn: run,
  90. spawnSync: runSync,
  91. runnerInvocation: spawnRunnerInvocation(),
  92. })
  93. launch.owner.signal('SIGKILL')
  94. await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  95. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  96. })
  97. it('rejects wait when the selected native owner becomes unreadable', async () => {
  98. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  99. const separator = args.indexOf('--')
  100. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  101. }) as unknown as typeof spawn
  102. const runSync = vi.fn(() => ({
  103. status: 1,
  104. stdout: '',
  105. stderr: 'Failed to connect to bus',
  106. error: undefined,
  107. })) as unknown as typeof spawnSync
  108. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  109. spawn: run,
  110. spawnSync: runSync,
  111. runnerInvocation: spawnRunnerInvocation(),
  112. })
  113. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  114. await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus')
  115. })
  116. it('propagates systemctl execution failures and unknown active states', async () => {
  117. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  118. const separator = args.indexOf('--')
  119. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  120. }) as unknown as typeof spawn
  121. const failure = new Error('systemctl execution failed')
  122. const failedRead = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  123. spawn: run,
  124. spawnSync: vi.fn(() => ({ error: failure })) as unknown as typeof spawnSync,
  125. runnerInvocation: spawnRunnerInvocation(),
  126. })
  127. await expect(failedRead.owner.waitForExit()).rejects.toBe(failure)
  128. await expect(failedRead.direct).resolves.toEqual({ exitCode: 0, signal: null })
  129. const unknownState = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  130. spawn: run,
  131. spawnSync: vi.fn(() => ({ status: 0, stdout: 'reloading\n', stderr: '', error: undefined })) as unknown as typeof spawnSync,
  132. runnerInvocation: spawnRunnerInvocation(),
  133. })
  134. await expect(unknownState.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
  135. await expect(unknownState.direct).resolves.toEqual({ exitCode: 0, signal: null })
  136. const blankFailure = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  137. spawn: run,
  138. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync,
  139. runnerInvocation: spawnRunnerInvocation(),
  140. })
  141. await expect(blankFailure.owner.waitForExit()).rejects.toThrow('exit 1')
  142. await expect(blankFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  143. })
  144. it.each(['activating', 'deactivating', 'failed'])(
  145. 'recognizes the %s scope state',
  146. async (initialState) => {
  147. let wrapper: ReturnType<typeof spawn> | undefined
  148. let reads = 0
  149. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  150. const separator = args.indexOf('--')
  151. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  152. return wrapper
  153. }) as unknown as typeof spawn
  154. const runSync = vi.fn(() => {
  155. reads += 1
  156. return {
  157. status: 0,
  158. stdout: reads === 1 ? `${initialState}\n` : 'inactive\n',
  159. stderr: '',
  160. error: undefined,
  161. }
  162. }) as unknown as typeof spawnSync
  163. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  164. spawn: run,
  165. spawnSync: runSync,
  166. runnerInvocation: spawnRunnerInvocation(),
  167. })
  168. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  169. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  170. },
  171. )
  172. it('uses runner liveness when systemd has already forgotten the scope', async () => {
  173. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  174. const separator = args.indexOf('--')
  175. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  176. }) as unknown as typeof spawn
  177. const runSyncMock = vi.fn(() => ({
  178. status: 1,
  179. stdout: '',
  180. stderr: 'Unit could not be found',
  181. error: undefined,
  182. }))
  183. const runSync = runSyncMock as unknown as typeof spawnSync
  184. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setTimeout(() => {}, 40)']), {
  185. spawn: run,
  186. spawnSync: runSync,
  187. runnerInvocation: spawnRunnerInvocation(),
  188. })
  189. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  190. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  191. expect(runSyncMock.mock.calls.length).toBeGreaterThan(1)
  192. })
  193. it('does not fabricate a direct outcome after a non-forced scope signal', async () => {
  194. let wrapper: ReturnType<typeof spawn> | undefined
  195. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  196. const separator = args.indexOf('--')
  197. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), { ...options, detached: true })
  198. return wrapper
  199. }) as unknown as typeof spawn
  200. const runSync = vi.fn((command: string, args: readonly string[]) => {
  201. if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) {
  202. process.kill(-wrapper.pid, 'SIGKILL')
  203. }
  204. if (command === 'systemctl' && args[1] === 'show') {
  205. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  206. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  207. }
  208. return { status: 0, stdout: '', stderr: '', error: undefined }
  209. }) as unknown as typeof spawnSync
  210. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  211. spawn: run,
  212. spawnSync: runSync,
  213. runnerInvocation: spawnRunnerInvocation(),
  214. })
  215. launch.owner.signal('SIGTERM')
  216. await expect(launch.direct).rejects.toThrow('exited without a direct-command result')
  217. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  218. })
  219. it('uses the production command defaults when no Linux internals are supplied', async () => {
  220. let wrapper: ReturnType<typeof spawn> | undefined
  221. const run = vi.fn()
  222. const runSync = vi.fn()
  223. vi.resetModules()
  224. vi.doMock('node:child_process', async (importOriginal) => {
  225. const actual = await importOriginal<typeof import('node:child_process')>()
  226. run.mockImplementation((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  227. const separator = args.indexOf('--')
  228. wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  229. return wrapper
  230. })
  231. runSync.mockImplementation((command: string, args: readonly string[]) => {
  232. if (command === 'systemctl' && args[1] === 'show') {
  233. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  234. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  235. }
  236. return { status: 0, stdout: '', stderr: '', error: undefined }
  237. })
  238. return { ...actual, spawn: run, spawnSync: runSync }
  239. })
  240. try {
  241. const defaults = await import('../src/linux-scope.ts')
  242. expect(defaults.probeLinuxScope()).toBe(true)
  243. const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  244. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  245. await expect(launch.owner.waitForExit()).resolves.toBe(true)
  246. expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
  247. expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object))
  248. } finally {
  249. vi.doUnmock('node:child_process')
  250. vi.resetModules()
  251. }
  252. })
  253. })