linux-scope.spec.ts 48 KB

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