linux-scope.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  1. import { EventEmitter } from 'node:events'
  2. import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
  3. import { PassThrough } from 'node:stream'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import {
  6. launchLinuxScope,
  7. prepareLinuxTerminalScope,
  8. probeLinuxBootstrap,
  9. probeLinuxManager,
  10. probeLinuxNative,
  11. probeLinuxScope,
  12. } from '../src/linux-scope.ts'
  13. import type { LinuxScopeInternals } from '../src/linux-scope.ts'
  14. import {
  15. consumeLinuxLaunchRequest,
  16. linuxLaunchFilesFromLocator,
  17. writeLinuxStartupError,
  18. } from '../src/runner-protocol.ts'
  19. import { SUBPROCESS_RUNNER_ENV } from '../src/runner-launch.ts'
  20. import { bindManagedProcess } from '../src/spawn.ts'
  21. const childProcessMocks = vi.hoisted(() => ({
  22. execFile: vi.fn(),
  23. spawn: vi.fn(),
  24. spawnSync: vi.fn(),
  25. }))
  26. vi.mock('node:child_process', async (importOriginal) => {
  27. const actual = await importOriginal<typeof import('node:child_process')>()
  28. return {
  29. ...actual,
  30. execFile: childProcessMocks.execFile as unknown as typeof actual.execFile,
  31. spawn: childProcessMocks.spawn as typeof actual.spawn,
  32. spawnSync: childProcessMocks.spawnSync as typeof actual.spawnSync,
  33. }
  34. })
  35. class FakeChild extends EventEmitter {
  36. pid: number | undefined = 321
  37. exitCode: number | null = null
  38. signalCode: NodeJS.Signals | null = null
  39. stdin = new PassThrough()
  40. stdout = new PassThrough()
  41. stderr = new PassThrough()
  42. kills: NodeJS.Signals[] = []
  43. kill(signal: NodeJS.Signals): boolean {
  44. this.kills.push(signal)
  45. return true
  46. }
  47. exit(exitCode: number | null, signal: NodeJS.Signals | null): void {
  48. this.exitCode = exitCode
  49. this.signalCode = signal
  50. this.emit('exit', exitCode, signal)
  51. }
  52. }
  53. const directories: string[] = []
  54. afterEach(() => {
  55. for (const directory of directories.splice(0)) {
  56. rmSync(directory, { recursive: true, force: true })
  57. }
  58. vi.restoreAllMocks()
  59. childProcessMocks.execFile.mockReset()
  60. childProcessMocks.spawn.mockReset()
  61. childProcessMocks.spawnSync.mockReset()
  62. })
  63. function missingUnit() {
  64. return { status: 1, stdout: '', stderr: 'Unit dsh.scope could not be found.' }
  65. }
  66. function activeUnit(state = 'active') {
  67. return { status: 0, stdout: `LoadState=loaded\nActiveState=${state}\n`, stderr: '' }
  68. }
  69. function unloadedUnit() {
  70. return { status: 0, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: '' }
  71. }
  72. function unitState(loadState: string, activeState: string) {
  73. return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' }
  74. }
  75. function activeUnitWithTasks(tasks: string) {
  76. return { status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n`, stderr: '' }
  77. }
  78. /** Deny every real process-group signal so a fake child never reaches a live host group. */
  79. function denyProcessGroups(): void {
  80. vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
  81. }
  82. /** Record the systemctl invocations a scope owner makes, succeeding unless a case overrides it. */
  83. function recordingSystemctl() {
  84. return vi.fn((_command: string, _args: readonly string[]) => ({ status: 0, stdout: '', stderr: '' }))
  85. }
  86. function spec() {
  87. return {
  88. argv: ['tool', 'literal arg'],
  89. cwd: '/target',
  90. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  91. graceMs: 100,
  92. env: { TARGET: 'yes' },
  93. } as const
  94. }
  95. function launch(
  96. query: LinuxScopeInternals['systemctlQuery'],
  97. overrides: LinuxScopeInternals = {},
  98. ) {
  99. const child = new FakeChild()
  100. let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined
  101. const spawn = vi.fn((_command: string, _args: readonly string[], received: typeof options) => {
  102. options = received
  103. return child
  104. })
  105. const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' }))
  106. const systemctlQuery = overrides.systemctlQuery ?? query
  107. const result = launchLinuxScope(spec(), { TARGET: 'yes' }, {
  108. spawn: overrides.spawn ?? spawn as never,
  109. spawnSync: overrides.spawnSync ?? spawnSync as never,
  110. ...systemctlQuery === undefined ? {} : { systemctlQuery },
  111. systemdRun: overrides.systemdRun ?? '/bin/systemd-run',
  112. systemctl: overrides.systemctl ?? '/bin/systemctl',
  113. runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'],
  114. ...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable },
  115. ...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve },
  116. ...overrides.sleep === undefined ? {} : { sleep: overrides.sleep },
  117. })
  118. const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
  119. if (requestPath === undefined) throw new Error('launch did not publish a request locator')
  120. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  121. return { child, result, requestPath, spawn, spawnSync, options }
  122. }
  123. describe('Linux native capability selection', () => {
  124. it('rechecks bootstrap and literal transient-scope support', () => {
  125. const spawnSync = vi.fn(() => ({ status: 0, error: undefined }))
  126. const runnerAvailable = vi.fn(() => true)
  127. const loadLinuxExecve = vi.fn(() => vi.fn() as never)
  128. const inputs = {
  129. spawnSync: spawnSync as never,
  130. runnerAvailable,
  131. runnerInvocation: ['/usr/bin/node', '/runner.js'] as [string, ...string[]],
  132. loadLinuxExecve,
  133. systemdRun: '/bin/systemd-run',
  134. systemctl: '/bin/systemctl',
  135. }
  136. expect(probeLinuxNative(inputs)).toBe(true)
  137. expect(probeLinuxNative(inputs)).toBe(true)
  138. expect(runnerAvailable).toHaveBeenCalledTimes(2)
  139. expect(loadLinuxExecve).toHaveBeenCalledTimes(2)
  140. expect(spawnSync).toHaveBeenCalledTimes(2)
  141. expect(probeLinuxBootstrap({
  142. ...inputs,
  143. loadLinuxExecve: () => { throw new Error('libc execve missing') },
  144. })).toBe(false)
  145. })
  146. it('reports each failed dynamic prerequisite without executing a target', () => {
  147. expect(probeLinuxScope({
  148. spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
  149. })).toBe(false)
  150. expect(probeLinuxBootstrap({
  151. loadLinuxExecve: () => vi.fn() as never,
  152. runnerInvocation: ['/missing'],
  153. runnerAvailable: () => false,
  154. })).toBe(false)
  155. expect(probeLinuxBootstrap({
  156. loadLinuxExecve: () => vi.fn() as never,
  157. resolveRunnerInvocation: () => { throw new Error('runner resolution failed') },
  158. })).toBe(false)
  159. })
  160. it('uses the default command adapters and runner resolution', () => {
  161. childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined })
  162. expect(probeLinuxScope()).toBe(true)
  163. expect(probeLinuxManager()).toBe(true)
  164. expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2)
  165. expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true)
  166. expect(probeLinuxBootstrap({
  167. runnerInvocation: [process.execPath],
  168. runnerAvailable: () => true,
  169. })).toBe(process.platform !== 'win32')
  170. })
  171. it('keeps quieting on the transient-scope probe but preserves manager diagnostics', () => {
  172. const spawnSync = vi.fn((
  173. _command: string,
  174. _args: readonly string[],
  175. _options: unknown,
  176. ) => ({ status: 0, error: undefined }))
  177. expect(probeLinuxScope({ spawnSync: spawnSync as never })).toBe(true)
  178. expect(probeLinuxManager({ spawnSync: spawnSync as never })).toBe(true)
  179. const scopeOptions = spawnSync.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }
  180. const managerOptions = spawnSync.mock.calls[1]?.[2] as { env: NodeJS.ProcessEnv }
  181. expect(scopeOptions.env).toMatchObject({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' })
  182. expect(managerOptions.env).toMatchObject({ LC_ALL: 'C' })
  183. expect(managerOptions.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
  184. expect(probeLinuxManager({
  185. spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never,
  186. })).toBe(false)
  187. expect(probeLinuxManager({
  188. spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
  189. })).toBe(false)
  190. })
  191. })
  192. describe('Linux scope establishment and quiescence', () => {
  193. it.each(['abort', 'terminate'] as const)('settles and cleans a managed handle after early %s', async (action) => {
  194. const { child, result, requestPath } = launch(async () => missingUnit())
  195. const controller = new AbortController()
  196. const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, result)
  197. if (action === 'abort') controller.abort(new Error('cancelled'))
  198. else handle.terminate()
  199. expect(child.kills).toEqual(['SIGTERM'])
  200. child.exit(null, 'SIGTERM')
  201. child.stdout.end()
  202. child.stderr.end()
  203. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  204. await expect(handle.waitForExit()).resolves.toBe(true)
  205. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  206. })
  207. it.each([
  208. { exitCode: 1, signal: null },
  209. { exitCode: null, signal: 'SIGSEGV' as const },
  210. ])('retains unrelated bootstrap failure $exitCode / $signal after a termination request', async (outcome) => {
  211. const { child, result } = launch(async () => missingUnit())
  212. result.owner.signal('SIGTERM')
  213. child.exit(outcome.exitCode, outcome.signal)
  214. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  215. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  216. result.owner.cleanup?.()
  217. })
  218. it('does not mistake pre-establishment unit absence for quiescence and settles an empty range after cancellation', async () => {
  219. const { child, result, requestPath, spawnSync } = launch(async () => missingUnit())
  220. const waiting = result.owner.waitForExit()
  221. result.owner.signal('SIGTERM')
  222. expect(child.kills).toEqual(['SIGTERM'])
  223. expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([
  224. 'kill', '--kill-whom=all', '--signal=SIGTERM',
  225. ]), expect.anything())
  226. const direct = expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  227. child.exit(null, 'SIGTERM')
  228. await direct
  229. await expect(waiting).resolves.toBeUndefined()
  230. expect(existsSync(requestPath)).toBe(true)
  231. result.owner.cleanup?.()
  232. })
  233. it('accepts request consumption followed by rapid --collect unload as stopped', async () => {
  234. const states = [activeUnit(), unloadedUnit()]
  235. const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit())
  236. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  237. const waiting = result.owner.waitForExit()
  238. child.exit(0, null)
  239. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  240. await expect(waiting).resolves.toBeUndefined()
  241. result.owner.signal('SIGKILL')
  242. result.owner.cleanup?.()
  243. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  244. })
  245. it('uses the scope alone after establishment and the direct range only when scope signalling fails', async () => {
  246. const spawnSync = vi.fn()
  247. .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
  248. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'scope signal failed' })
  249. const { child, result, requestPath } = launch(async () => activeUnit(), {
  250. spawnSync: spawnSync as never,
  251. })
  252. consumeLinuxLaunchRequest(requestPath)
  253. const processKill = vi.spyOn(process, 'kill').mockReturnValue(true)
  254. result.owner.signal('SIGTERM')
  255. expect(processKill).not.toHaveBeenCalled()
  256. result.owner.signal('SIGKILL')
  257. expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, 'SIGKILL')
  258. expect(spawnSync).toHaveBeenCalledTimes(2)
  259. child.exit(null, 'SIGKILL')
  260. await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  261. result.owner.cleanup?.()
  262. })
  263. it('uses manager-observed unit existence as establishment proof', async () => {
  264. const { child, result } = launch(async () => activeUnit('inactive'))
  265. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  266. child.exit(1, null)
  267. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  268. result.owner.cleanup?.()
  269. })
  270. it('keeps waiting while the unit is absent and the direct launcher is still running', async () => {
  271. const states = [missingUnit(), activeUnit('inactive')]
  272. const { child, result } = launch(async () => states.shift() ?? activeUnit('inactive'))
  273. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  274. child.exit(1, null)
  275. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  276. result.owner.cleanup?.()
  277. })
  278. it('treats status-zero not-found as pending until the direct launcher proves the range was never created', async () => {
  279. const state: { child?: FakeChild } = {}
  280. const launched = launch(async () => unloadedUnit(), {
  281. sleep: async () => { state.child?.exit(127, null) },
  282. })
  283. state.child = launched.child
  284. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  285. await expect(launched.result.direct).rejects.toThrow('before its bootstrap consumed')
  286. launched.result.owner.cleanup?.()
  287. })
  288. it('polls promptly before establishment and backs off established active scopes', async () => {
  289. const delays: number[] = []
  290. const states = [
  291. missingUnit(),
  292. activeUnit(),
  293. activeUnit(),
  294. activeUnit(),
  295. activeUnit(),
  296. activeUnit(),
  297. activeUnit(),
  298. activeUnit(),
  299. activeUnit(),
  300. activeUnit('inactive'),
  301. ]
  302. const launched = launch(
  303. async () => states.shift() ?? activeUnit('inactive'),
  304. { sleep: async (delayMs) => { delays.push(delayMs) } },
  305. )
  306. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  307. expect(delays).toEqual([50, 50, 100, 200, 400, 800, 1_600, 3_200, 5_000])
  308. launched.result.owner.cleanup?.()
  309. })
  310. it('keeps reloading scopes active and lets terminate wake a backed-off observation', async () => {
  311. const states = [activeUnit('reloading'), activeUnit('inactive')]
  312. const sleeping = Promise.withResolvers<undefined>()
  313. const sleep = vi.fn(async (_delayMs: number, signal?: AbortSignal) => {
  314. sleeping.resolve(undefined)
  315. if (signal === undefined) throw new Error('missing sleep cancellation signal')
  316. await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
  317. })
  318. const launched = launch(async () => states.shift() ?? activeUnit('inactive'), { sleep })
  319. consumeLinuxLaunchRequest(launched.requestPath)
  320. const waiting = launched.result.owner.waitForExit()
  321. await sleeping.promise
  322. launched.result.owner.signal('SIGTERM')
  323. await expect(waiting).resolves.toBeUndefined()
  324. expect(sleep).toHaveBeenCalledOnce()
  325. expect(sleep.mock.calls[0]?.[0]).toBe(50)
  326. expect(sleep.mock.calls[0]?.[1]?.aborted).toBe(true)
  327. expect(launched.spawnSync).toHaveBeenCalledOnce()
  328. launched.result.owner.cleanup?.()
  329. })
  330. it('skips the next poll delay when terminate arrives during a manager query', async () => {
  331. const firstQuery = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  332. const query = vi.fn()
  333. .mockImplementationOnce(async () => await firstQuery.promise)
  334. .mockResolvedValueOnce(activeUnit('inactive'))
  335. const sleep = vi.fn(async () => {})
  336. const launched = launch(query, { sleep })
  337. consumeLinuxLaunchRequest(launched.requestPath)
  338. const waiting = launched.result.owner.waitForExit()
  339. launched.result.owner.signal('SIGTERM')
  340. firstQuery.resolve(activeUnit())
  341. await expect(waiting).resolves.toBeUndefined()
  342. expect(sleep).not.toHaveBeenCalled()
  343. launched.result.owner.cleanup?.()
  344. })
  345. it.each([
  346. { exitCode: 127, signal: null },
  347. { exitCode: null, signal: 'SIGTERM' as const },
  348. ])('rejects unexpected bootstrap exit $exitCode / $signal and settles the empty range', async (outcome) => {
  349. const { child, result } = launch(async () => missingUnit())
  350. child.exit(outcome.exitCode, outcome.signal)
  351. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  352. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  353. result.owner.cleanup?.()
  354. })
  355. it('preserves a recorded pre-exec failure even when cancellation also terminates the bootstrap', async () => {
  356. const { child, result, requestPath } = launch(async () => missingUnit())
  357. const files = linuxLaunchFilesFromLocator(requestPath)
  358. result.owner.signal('SIGTERM')
  359. unlinkSync(requestPath)
  360. writeLinuxStartupError(files, {
  361. type: 'error',
  362. error: { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT' },
  363. })
  364. child.exit(null, 'SIGTERM')
  365. await expect(result.direct).rejects.toMatchObject({ code: 'ENOENT' })
  366. result.owner.cleanup?.()
  367. })
  368. it('retries a failed state query and rejects unknown states or failed final kills', async () => {
  369. const query = vi.fn()
  370. .mockResolvedValueOnce({ status: null, stdout: '', stderr: '', error: new Error('query failed') })
  371. .mockResolvedValueOnce(missingUnit())
  372. const { result, requestPath } = launch(query)
  373. unlinkSync(requestPath)
  374. await expect(result.owner.waitForExit()).rejects.toThrow('query failed')
  375. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  376. result.owner.cleanup?.()
  377. const unknown = launch(async () => activeUnit('mystery'))
  378. await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
  379. unknown.result.owner.cleanup?.()
  380. const unknownLoad = launch(async () => unitState('masked', 'inactive'))
  381. await expect(unknownLoad.result.owner.waitForExit()).rejects.toThrow('unknown state')
  382. unknownLoad.result.owner.cleanup?.()
  383. const killFailed = launch(async () => activeUnit(), {
  384. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
  385. })
  386. killFailed.result.owner.signal('SIGKILL')
  387. await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
  388. killFailed.result.owner.cleanup?.()
  389. })
  390. it('rechecks a pre-signal observation before reporting a failed final kill', async () => {
  391. denyProcessGroups()
  392. const beforeKill = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  393. const query = vi.fn()
  394. .mockImplementationOnce(() => beforeKill.promise)
  395. .mockResolvedValueOnce(activeUnit('inactive'))
  396. const sleep = vi.fn(async () => {})
  397. const launched = launch(query, {
  398. sleep,
  399. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  400. })
  401. consumeLinuxLaunchRequest(launched.requestPath)
  402. const waiting = launched.result.owner.waitForExit()
  403. launched.result.owner.signal('SIGKILL')
  404. launched.child.exit(null, 'SIGKILL')
  405. beforeKill.resolve(activeUnit())
  406. await expect(waiting).resolves.toBeUndefined()
  407. await expect(launched.result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  408. expect(query).toHaveBeenCalledTimes(2)
  409. expect(sleep).not.toHaveBeenCalled()
  410. launched.result.owner.cleanup?.()
  411. })
  412. it('does not accept a pre-signal empty observation when the fresh range remains populated', async () => {
  413. denyProcessGroups()
  414. const beforeKill = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  415. const query = vi.fn()
  416. .mockImplementationOnce(() => beforeKill.promise)
  417. .mockResolvedValueOnce(activeUnitWithTasks('1'))
  418. const launched = launch(query, {
  419. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  420. })
  421. consumeLinuxLaunchRequest(launched.requestPath)
  422. const waiting = launched.result.owner.waitForExit()
  423. launched.result.owner.signal('SIGKILL')
  424. launched.child.exit(null, 'SIGKILL')
  425. beforeKill.resolve(activeUnit('inactive'))
  426. await expect(waiting).rejects.toThrow('Invalid argument')
  427. await launched.result.direct
  428. expect(query).toHaveBeenCalledTimes(2)
  429. launched.result.owner.cleanup?.()
  430. })
  431. it('accepts a confirmed empty range after a failed final kill', async () => {
  432. denyProcessGroups()
  433. const spawnSync = recordingSystemctl()
  434. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  435. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  436. consumeLinuxLaunchRequest(launched.requestPath)
  437. launched.result.owner.signal('SIGKILL')
  438. launched.child.exit(null, 'SIGKILL')
  439. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  440. await expect(launched.result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  441. expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill', 'stop'])
  442. launched.result.owner.cleanup?.()
  443. })
  444. it('settles a consumed empty scope before its launcher reports exit after a failed kill', async () => {
  445. denyProcessGroups()
  446. const spawnSync = recordingSystemctl()
  447. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  448. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  449. consumeLinuxLaunchRequest(launched.requestPath)
  450. launched.result.owner.signal('SIGKILL')
  451. try {
  452. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  453. expect(launched.child.exitCode).toBeNull()
  454. expect(launched.child.signalCode).toBeNull()
  455. expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill', 'stop'])
  456. } finally {
  457. launched.child.exit(null, 'SIGKILL')
  458. await launched.result.direct
  459. launched.result.owner.cleanup?.()
  460. }
  461. })
  462. it.each([
  463. { tasks: '1', clientRunning: false },
  464. { tasks: '[not set]', clientRunning: false },
  465. { tasks: '0', clientRunning: true },
  466. ])('retains a failed kill with tasks=$tasks and clientRunning=$clientRunning', async ({ tasks, clientRunning }) => {
  467. denyProcessGroups()
  468. const spawnSync = recordingSystemctl()
  469. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  470. const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
  471. if (!clientRunning) consumeLinuxLaunchRequest(launched.requestPath)
  472. launched.result.owner.signal('SIGKILL')
  473. if (!clientRunning) launched.child.exit(null, 'SIGKILL')
  474. await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')
  475. expect(spawnSync).toHaveBeenCalledOnce()
  476. if (clientRunning) launched.child.exit(null, 'SIGKILL')
  477. await launched.result.direct
  478. launched.result.owner.cleanup?.()
  479. })
  480. it('reports command-query failures from the default systemctl adapter', async () => {
  481. childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
  482. const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
  483. const options = args[2] as { env: NodeJS.ProcessEnv }
  484. expect(options.env).toMatchObject({ LC_ALL: 'C' })
  485. expect(options.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
  486. callback(null, 'LoadState=loaded\nActiveState=inactive\n', 'manager diagnostic remains readable')
  487. return new EventEmitter()
  488. })
  489. const stopped = launch(undefined)
  490. await expect(stopped.result.owner.waitForExit()).resolves.toBeUndefined()
  491. stopped.result.owner.cleanup?.()
  492. const queryError = Object.assign(new Error('systemctl execution failed'), { code: 'ENOENT' })
  493. childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
  494. const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
  495. callback(queryError, '', '')
  496. return new EventEmitter()
  497. })
  498. const failed = launch(undefined)
  499. await expect(failed.result.owner.waitForExit()).rejects.toBe(queryError)
  500. failed.result.owner.cleanup?.()
  501. })
  502. it('rejects malformed, duplicate, incomplete, and extra manager state fields', async () => {
  503. for (const [stdout, message] of [
  504. ['loaded\nActiveState=active\n', 'malformed state'],
  505. ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'],
  506. ['LoadState=loaded\n', 'incomplete state'],
  507. ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'],
  508. ['LoadState=loaded\nActiveState=active\nTasksCurrent=0\nOther=value\n', 'incomplete state'],
  509. ] as const) {
  510. const launched = launch(async () => ({ status: 0, stdout, stderr: '' }))
  511. await expect(launched.result.owner.waitForExit()).rejects.toThrow(message)
  512. launched.result.owner.cleanup?.()
  513. }
  514. })
  515. it('rejects a manager process count that is neither numeric nor the unset sentinel', async () => {
  516. const launched = launch(async () => activeUnitWithTasks('many'))
  517. await expect(launched.result.owner.waitForExit()).rejects.toThrow('non-numeric TasksCurrent')
  518. launched.result.owner.cleanup?.()
  519. })
  520. it('releases an active scope left with no processes once its client has gone', async () => {
  521. // Regression: the manager's empty cgroup never ends this unit on its own.
  522. denyProcessGroups()
  523. const spawnSync = recordingSystemctl()
  524. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  525. launched.result.owner.signal('SIGKILL')
  526. launched.child.exit(null, 'SIGKILL')
  527. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  528. expect(spawnSync.mock.calls.map(call => call[1])).toEqual([
  529. ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', expect.stringMatching(/\.scope$/u)],
  530. ['--user', 'stop', expect.stringMatching(/\.scope$/u)],
  531. ])
  532. launched.result.owner.cleanup?.()
  533. })
  534. it('concludes the empty range even when releasing the leftover scope fails', async () => {
  535. denyProcessGroups()
  536. const spawnSync = recordingSystemctl()
  537. .mockImplementationOnce(() => ({ status: 0, stdout: '', stderr: '' }))
  538. .mockImplementationOnce(() => { throw new Error('systemctl is gone') })
  539. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  540. launched.result.owner.signal('SIGKILL')
  541. launched.child.exit(null, 'SIGKILL')
  542. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  543. launched.result.owner.cleanup?.()
  544. })
  545. it('keeps waiting while the client still owns an active scope with no processes', async () => {
  546. denyProcessGroups()
  547. const spawnSync = recordingSystemctl()
  548. const states = [activeUnitWithTasks('0'), unloadedUnit()]
  549. const launched = launch(async () => states.shift() ?? unloadedUnit(), {
  550. spawnSync: spawnSync as never,
  551. })
  552. launched.result.owner.signal('SIGTERM')
  553. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  554. expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill'])
  555. launched.result.owner.cleanup?.()
  556. })
  557. it('keeps waiting for an active empty scope no termination has requested', async () => {
  558. const states = [activeUnitWithTasks('0'), unloadedUnit()]
  559. const launched = launch(async () => states.shift() ?? unloadedUnit())
  560. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  561. expect(launched.spawnSync).not.toHaveBeenCalled()
  562. launched.result.owner.cleanup?.()
  563. })
  564. it('treats an unset process count as unknown and keeps waiting', async () => {
  565. const states = [activeUnitWithTasks('[not set]'), unloadedUnit()]
  566. const launched = launch(async () => states.shift() ?? unloadedUnit())
  567. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  568. expect(launched.spawnSync).not.toHaveBeenCalled()
  569. launched.result.owner.cleanup?.()
  570. })
  571. it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => {
  572. const spawnSync = vi.fn()
  573. .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
  574. .mockReturnValueOnce({ status: 1, stderr: 'Unit dsh.scope could not be found.' })
  575. .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
  576. .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
  577. const states = [activeUnit(), activeUnit('failed')]
  578. const launched = launch(async () => states.shift() ?? missingUnit(), {
  579. spawnSync: spawnSync as never,
  580. })
  581. launched.child.pid = undefined
  582. unlinkSync(launched.requestPath)
  583. launched.result.owner.signal('SIGTERM')
  584. launched.result.owner.signal('SIGKILL')
  585. launched.result.owner.signal('SIGKILL')
  586. launched.result.owner.signal('SIGKILL')
  587. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  588. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  589. launched.result.owner.terminateForHostExit()
  590. expect(spawnSync).toHaveBeenCalledTimes(4)
  591. launched.result.owner.cleanup?.()
  592. })
  593. it('reports unreadable manager output and a failed kill before establishment', async () => {
  594. const withOutput = launch(async () => ({
  595. status: 5, stdout: '', stderr: 'permission denied',
  596. }))
  597. await expect(withOutput.result.owner.waitForExit()).rejects.toThrow('permission denied')
  598. withOutput.result.owner.cleanup?.()
  599. const withoutOutput = launch(async () => ({ status: null, stdout: '', stderr: '' }))
  600. await expect(withoutOutput.result.owner.waitForExit()).rejects.toThrow('exit null')
  601. withoutOutput.result.owner.cleanup?.()
  602. const killFailed = launch(async () => missingUnit(), {
  603. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'kill denied' })) as never,
  604. })
  605. vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
  606. killFailed.result.owner.signal('SIGKILL')
  607. await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('kill denied')
  608. killFailed.result.owner.cleanup?.()
  609. })
  610. it('settles direct outcomes once and reports malformed startup errors', async () => {
  611. const childError = launch(async () => missingUnit())
  612. const spawnError = new Error('systemd-run failed')
  613. childError.child.emit('error', spawnError)
  614. childError.child.exit(1, null)
  615. await expect(childError.result.direct).rejects.toBe(spawnError)
  616. childError.result.owner.cleanup?.()
  617. const lateError = launch(async () => missingUnit())
  618. consumeLinuxLaunchRequest(lateError.requestPath)
  619. lateError.child.exit(0, null)
  620. lateError.child.emit('error', new Error('late child error'))
  621. await expect(lateError.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  622. lateError.result.owner.cleanup?.()
  623. const malformed = launch(async () => missingUnit())
  624. const files = linuxLaunchFilesFromLocator(malformed.requestPath)
  625. unlinkSync(malformed.requestPath)
  626. writeFileSync(files.startupErrorPath, '{', { mode: 0o600 })
  627. malformed.child.exit(127, null)
  628. await expect(malformed.result.direct).rejects.toBeInstanceOf(SyntaxError)
  629. malformed.result.owner.cleanup?.()
  630. })
  631. it('does not signal a direct group before the launcher publishes a pid', async () => {
  632. const launched = launch(async () => activeUnit('inactive'))
  633. launched.child.pid = undefined
  634. const processKill = vi.spyOn(process, 'kill')
  635. launched.result.owner.signal('SIGTERM')
  636. expect(processKill).not.toHaveBeenCalled()
  637. expect(launched.child.kills).toEqual([])
  638. consumeLinuxLaunchRequest(launched.requestPath)
  639. launched.child.exit(0, null)
  640. await expect(launched.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  641. launched.result.owner.cleanup?.()
  642. })
  643. it('does not signal the direct group after the launcher exits', async () => {
  644. const { child, result, requestPath, spawnSync } = launch(async () => activeUnit())
  645. consumeLinuxLaunchRequest(requestPath)
  646. child.exit(0, null)
  647. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  648. const processKill = vi.spyOn(process, 'kill')
  649. result.owner.signal('SIGTERM')
  650. result.owner.terminateForHostExit()
  651. expect(processKill).not.toHaveBeenCalled()
  652. expect(child.kills).toEqual([])
  653. expect(spawnSync).toHaveBeenCalledTimes(2)
  654. result.owner.cleanup?.()
  655. })
  656. it('runs direct fallback before the exact synchronous scope kill on host exit', () => {
  657. const events: string[] = []
  658. const { child, result } = launch(async () => missingUnit(), {
  659. spawnSync: vi.fn(() => { events.push('scope'); return { status: 0 } }) as never,
  660. })
  661. child.kill = vi.fn(() => { events.push('direct'); return true })
  662. vi.spyOn(process, 'kill').mockImplementation(() => { events.push('direct'); return true })
  663. result.owner.terminateForHostExit()
  664. expect(events).toEqual(['direct', 'scope'])
  665. result.owner.cleanup?.()
  666. })
  667. })
  668. describe('Linux PTY bootstrap reuse', () => {
  669. const terminalSpec = {
  670. argv: ['bash', '--noprofile'],
  671. cwd: '/target',
  672. env: { TARGET: 'yes' },
  673. rows: 24,
  674. cols: 80,
  675. graceMs: 100,
  676. } as const
  677. it.each(['SIGTERM', 'SIGKILL'] as const)('preserves %s before bootstrap consumption and joins the empty scope', async (signal) => {
  678. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
  679. spawnSync: vi.fn(() => missingUnit()) as never,
  680. systemctlQuery: async () => missingUnit(),
  681. })
  682. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  683. if (requestPath === undefined) throw new Error('missing PTY request')
  684. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  685. let running = true
  686. const kill = vi.fn()
  687. const owner = scope.bindOwner({ running: () => running, signal: kill })
  688. owner.signal(signal)
  689. expect(kill).toHaveBeenCalledExactlyOnceWith(signal)
  690. running = false
  691. expect(existsSync(requestPath)).toBe(true)
  692. expect(scope.resolveOutcome({ exitCode: 0, signal })).toEqual({ exitCode: 0, signal })
  693. await expect(owner.waitForExit()).resolves.toBeUndefined()
  694. scope.cleanup()
  695. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  696. })
  697. it('uses the same request/bootstrap, preserves argv, and cleans after owner settlement', async () => {
  698. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
  699. systemdRun: '/bin/systemd-run',
  700. systemctl: '/bin/systemctl',
  701. runnerInvocation: ['/usr/bin/node', '/runner.js'],
  702. spawnSync: vi.fn(() => ({ status: 0 })) as never,
  703. systemctlQuery: async () => missingUnit(),
  704. })
  705. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  706. if (requestPath === undefined) throw new Error('missing PTY request')
  707. expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile'])
  708. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  709. const owner = scope.bindOwner({ running: () => false, signal: vi.fn() })
  710. await expect(owner.waitForExit()).resolves.toBeUndefined()
  711. expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null })
  712. scope.cleanup()
  713. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  714. })
  715. it('surfaces PTY pre-exec errors instead of launcher outcomes', () => {
  716. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
  717. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  718. if (requestPath === undefined) throw new Error('missing PTY request')
  719. const files = linuxLaunchFilesFromLocator(requestPath)
  720. unlinkSync(requestPath)
  721. writeLinuxStartupError(files, {
  722. type: 'error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' },
  723. })
  724. expect(() => scope.resolveOutcome({ exitCode: 127, signal: null })).toThrow('bad cwd')
  725. scope.cleanup()
  726. })
  727. it('uses default owner dependencies and rejects an unconsumed request', () => {
  728. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
  729. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  730. if (requestPath === undefined) throw new Error('missing PTY request')
  731. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  732. scope.bindOwner({ running: () => true, signal: vi.fn() })
  733. expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow(
  734. 'before its bootstrap consumed',
  735. )
  736. scope.cleanup()
  737. })
  738. })
  739. describe('Linux ordinary launch adapters', () => {
  740. it('uses the default launch dependencies without changing the target request', async () => {
  741. const child = new FakeChild()
  742. childProcessMocks.spawn.mockReturnValue(child)
  743. const result = launchLinuxScope(spec(), { TARGET: 'yes' })
  744. const call = childProcessMocks.spawn.mock.calls[0]
  745. const options = call?.[2] as { env?: NodeJS.ProcessEnv } | undefined
  746. const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
  747. if (requestPath === undefined) throw new Error('launch did not publish a request locator')
  748. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  749. expect(call?.[0]).toBe('systemd-run')
  750. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  751. child.exit(0, null)
  752. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  753. result.owner.cleanup?.()
  754. })
  755. it('removes the private launch directory when spawn throws synchronously', () => {
  756. const spawnError = new Error('synchronous spawn failure')
  757. let requestPath: string | undefined
  758. expect(() => launchLinuxScope(spec(), { TARGET: 'yes' }, {
  759. runnerInvocation: ['/usr/bin/node', '/runner.js'],
  760. spawn: vi.fn((_command: string, _args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
  761. requestPath = options.env?.[SUBPROCESS_RUNNER_ENV]
  762. throw spawnError
  763. }) as never,
  764. })).toThrow(spawnError)
  765. if (requestPath === undefined) throw new Error('spawn did not receive a request locator')
  766. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  767. })
  768. })