1
0

linux-scope.spec.ts 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  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('accepts request consumption followed by rapid --collect unload as stopped', async () => {
  264. const states = [activeUnit(), unloadedUnit()]
  265. const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit())
  266. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  267. const waiting = result.owner.waitForExit()
  268. child.exit(0, null)
  269. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  270. await expect(waiting).resolves.toBeUndefined()
  271. result.owner.signal('SIGKILL')
  272. result.owner.cleanup?.()
  273. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  274. })
  275. it.each(['SIGTERM', 'SIGKILL'] as const)('uses the scope alone after establishment and the direct range only when scope %s fails', async (signal) => {
  276. const spawnSync = vi.fn()
  277. .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
  278. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'scope signal failed' })
  279. const { child, result, requestPath } = launch(async () => activeUnit(), {
  280. spawnSync: spawnSync as never,
  281. })
  282. consumeLinuxLaunchRequest(requestPath)
  283. const processKill = vi.spyOn(process, 'kill').mockReturnValue(true)
  284. result.owner.signal('SIGTERM')
  285. expect(processKill).not.toHaveBeenCalled()
  286. result.owner.signal(signal)
  287. expect(processKill.mock.calls).toEqual(signal === 'SIGKILL'
  288. ? [[-321, signal], [321, signal]]
  289. : [[-321, signal]])
  290. expect(child.kills).toEqual([])
  291. expect(spawnSync).toHaveBeenCalledTimes(2)
  292. child.exit(null, signal)
  293. await expect(result.direct).resolves.toEqual({ exitCode: null, signal })
  294. result.owner.cleanup?.()
  295. })
  296. it('uses manager-observed unit existence as establishment proof', async () => {
  297. const { child, result } = launch(async () => activeUnit('inactive'))
  298. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  299. child.exit(1, null)
  300. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  301. result.owner.cleanup?.()
  302. })
  303. it('keeps waiting while the unit is absent and the direct launcher is still running', async () => {
  304. const states = [missingUnit(), activeUnit('inactive')]
  305. const { child, result } = launch(async () => states.shift() ?? activeUnit('inactive'))
  306. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  307. child.exit(1, null)
  308. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  309. result.owner.cleanup?.()
  310. })
  311. it('treats status-zero not-found as pending until the direct launcher proves the range was never created', async () => {
  312. const state: { child?: FakeChild } = {}
  313. const launched = launch(async () => unloadedUnit(), {
  314. sleep: async () => { state.child?.exit(127, null) },
  315. })
  316. state.child = launched.child
  317. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  318. await expect(launched.result.direct).rejects.toThrow('before its bootstrap consumed')
  319. launched.result.owner.cleanup?.()
  320. })
  321. it('polls promptly before establishment and backs off established active scopes', async () => {
  322. const delays: number[] = []
  323. const states = [
  324. missingUnit(),
  325. activeUnit(),
  326. activeUnit(),
  327. activeUnit(),
  328. activeUnit(),
  329. activeUnit(),
  330. activeUnit(),
  331. activeUnit(),
  332. activeUnit(),
  333. activeUnit('inactive'),
  334. ]
  335. const launched = launch(
  336. async () => states.shift() ?? activeUnit('inactive'),
  337. { sleep: async (delayMs) => { delays.push(delayMs) } },
  338. )
  339. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  340. expect(delays).toEqual([50, 50, 100, 200, 400, 800, 1_600, 3_200, 5_000])
  341. launched.result.owner.cleanup?.()
  342. })
  343. it('keeps reloading scopes active and lets terminate wake a backed-off observation', async () => {
  344. const states = [activeUnit('reloading'), activeUnit('inactive')]
  345. const sleeping = Promise.withResolvers<undefined>()
  346. const sleep = vi.fn(async (_delayMs: number, signal?: AbortSignal) => {
  347. sleeping.resolve(undefined)
  348. if (signal === undefined) throw new Error('missing sleep cancellation signal')
  349. await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
  350. })
  351. const launched = launch(async () => states.shift() ?? activeUnit('inactive'), { sleep })
  352. consumeLinuxLaunchRequest(launched.requestPath)
  353. const waiting = launched.result.owner.waitForExit()
  354. await sleeping.promise
  355. launched.result.owner.signal('SIGTERM')
  356. await expect(waiting).resolves.toBeUndefined()
  357. expect(sleep).toHaveBeenCalledOnce()
  358. expect(sleep.mock.calls[0]?.[0]).toBe(50)
  359. expect(sleep.mock.calls[0]?.[1]?.aborted).toBe(true)
  360. expect(launched.spawnSync).toHaveBeenCalledOnce()
  361. launched.result.owner.cleanup?.()
  362. })
  363. it('skips the next poll delay when terminate arrives during a manager query', async () => {
  364. const firstQuery = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  365. const query = vi.fn()
  366. .mockImplementationOnce(async () => await firstQuery.promise)
  367. .mockResolvedValueOnce(activeUnit('inactive'))
  368. const sleep = vi.fn(async () => {})
  369. const launched = launch(query, { sleep })
  370. consumeLinuxLaunchRequest(launched.requestPath)
  371. const waiting = launched.result.owner.waitForExit()
  372. launched.result.owner.signal('SIGTERM')
  373. firstQuery.resolve(activeUnit())
  374. await expect(waiting).resolves.toBeUndefined()
  375. expect(sleep).not.toHaveBeenCalled()
  376. launched.result.owner.cleanup?.()
  377. })
  378. it.each([
  379. { exitCode: 127, signal: null },
  380. { exitCode: null, signal: 'SIGTERM' as const },
  381. ])('rejects unexpected bootstrap exit $exitCode / $signal and settles the empty range', async (outcome) => {
  382. const { child, result } = launch(async () => missingUnit())
  383. child.exit(outcome.exitCode, outcome.signal)
  384. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  385. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  386. result.owner.cleanup?.()
  387. })
  388. it('preserves a recorded pre-exec failure even when cancellation also terminates the bootstrap', async () => {
  389. const { child, result, requestPath } = launch(async () => missingUnit())
  390. const files = linuxLaunchFilesFromLocator(requestPath)
  391. result.owner.signal('SIGTERM')
  392. unlinkSync(requestPath)
  393. writeLinuxStartupError(files, {
  394. type: 'error',
  395. error: { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT' },
  396. })
  397. child.exit(null, 'SIGTERM')
  398. await expect(result.direct).rejects.toMatchObject({ code: 'ENOENT' })
  399. result.owner.cleanup?.()
  400. })
  401. it('retries a failed state query and rejects unknown states or failed final kills', async () => {
  402. const query = vi.fn()
  403. .mockResolvedValueOnce({ status: null, stdout: '', stderr: '', error: new Error('query failed') })
  404. .mockResolvedValueOnce(missingUnit())
  405. const { result, requestPath } = launch(query)
  406. unlinkSync(requestPath)
  407. await expect(result.owner.waitForExit()).rejects.toThrow('query failed')
  408. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  409. result.owner.cleanup?.()
  410. const unknown = launch(async () => activeUnit('mystery'))
  411. await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
  412. unknown.result.owner.cleanup?.()
  413. const unknownLoad = launch(async () => unitState('masked', 'inactive'))
  414. await expect(unknownLoad.result.owner.waitForExit()).rejects.toThrow('unknown state')
  415. unknownLoad.result.owner.cleanup?.()
  416. const killFailed = launch(async () => activeUnit(), {
  417. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
  418. })
  419. vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) })
  420. killFailed.result.owner.signal('SIGKILL')
  421. await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
  422. killFailed.result.owner.cleanup?.()
  423. })
  424. it('rechecks a pre-signal observation before reporting a failed final kill', async () => {
  425. denyProcessGroups()
  426. const beforeKill = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  427. const query = vi.fn()
  428. .mockImplementationOnce(() => beforeKill.promise)
  429. .mockResolvedValueOnce(activeUnit('inactive'))
  430. const sleep = vi.fn(async () => {})
  431. const launched = launch(query, {
  432. sleep,
  433. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  434. })
  435. consumeLinuxLaunchRequest(launched.requestPath)
  436. const waiting = launched.result.owner.waitForExit()
  437. launched.result.owner.signal('SIGKILL')
  438. launched.child.exit(null, 'SIGKILL')
  439. beforeKill.resolve(activeUnit())
  440. await expect(waiting).resolves.toBeUndefined()
  441. await expect(launched.result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  442. expect(query).toHaveBeenCalledTimes(2)
  443. expect(sleep).not.toHaveBeenCalled()
  444. launched.result.owner.cleanup?.()
  445. })
  446. it('does not accept a pre-signal empty observation when the fresh range remains populated', async () => {
  447. denyProcessGroups()
  448. const beforeKill = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  449. const query = vi.fn()
  450. .mockImplementationOnce(() => beforeKill.promise)
  451. .mockResolvedValueOnce(activeUnitWithTasks('1'))
  452. const launched = launch(query, {
  453. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  454. })
  455. consumeLinuxLaunchRequest(launched.requestPath)
  456. const waiting = launched.result.owner.waitForExit()
  457. launched.result.owner.signal('SIGKILL')
  458. launched.child.exit(null, 'SIGKILL')
  459. beforeKill.resolve(activeUnit('inactive'))
  460. await expect(waiting).rejects.toThrow('Invalid argument')
  461. await launched.result.direct
  462. expect(query).toHaveBeenCalledTimes(2)
  463. launched.result.owner.cleanup?.()
  464. })
  465. it('accepts a confirmed empty range after a failed final kill', async () => {
  466. denyProcessGroups()
  467. const spawnSync = recordingSystemctl()
  468. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  469. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  470. consumeLinuxLaunchRequest(launched.requestPath)
  471. launched.result.owner.signal('SIGKILL')
  472. launched.child.exit(null, 'SIGKILL')
  473. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  474. await expect(launched.result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  475. expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill', 'stop'])
  476. launched.result.owner.cleanup?.()
  477. })
  478. it.each([
  479. { state: 'empty', fresh: activeUnitWithTasks('0'), settles: true },
  480. { state: 'inactive', fresh: activeUnit('inactive'), settles: true },
  481. { state: 'populated', fresh: activeUnitWithTasks('1'), settles: false },
  482. { state: 'unknown', fresh: activeUnitWithTasks('[not set]'), settles: false },
  483. ].flatMap(value => ['delivered', 'already absent'].map(delivery => ({ ...value, delivery })))
  484. .flatMap(value => [false, true].map(groupAccepted => ({ ...value, groupAccepted }))))(
  485. 'joins a $delivery direct kill with groupAccepted=$groupAccepted before deciding a $state scope', async ({ fresh, settles, delivery, groupAccepted }) => {
  486. const processKill = vi.spyOn(process, 'kill').mockImplementation((pid) => {
  487. if ((pid < 0 && groupAccepted) || (pid > 0 && delivery === 'delivered')) return true
  488. throw Object.assign(new Error('absent'), { code: 'ESRCH' })
  489. })
  490. const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  491. const queried = Promise.withResolvers<undefined>()
  492. const query = vi.fn()
  493. .mockImplementationOnce(() => { queried.resolve(undefined); return firstRead.promise })
  494. .mockResolvedValueOnce(fresh)
  495. const sleep = vi.fn(async () => { throw new Error('unexpected poll delay') })
  496. const spawnSync = recordingSystemctl()
  497. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  498. const launched = launch(query, { sleep, spawnSync: spawnSync as never }, {
  499. ...spec(), stdio: { ...spec().stdio, control: 'pipe' },
  500. })
  501. consumeLinuxLaunchRequest(launched.requestPath)
  502. launched.result.owner.signal('SIGKILL')
  503. let completed = false
  504. const waiting = launched.result.owner.waitForExit().finally(() => { completed = true })
  505. void waiting.catch(() => {})
  506. try {
  507. await queried.promise
  508. firstRead.resolve(activeUnitWithTasks('1'))
  509. await new Promise<void>(resolve => setImmediate(resolve))
  510. expect(completed).toBe(false)
  511. expect(processKill).toHaveBeenCalledWith(321, 'SIGKILL')
  512. expect(query).toHaveBeenCalledOnce()
  513. expect(sleep).not.toHaveBeenCalled()
  514. launched.child.exit(null, 'SIGKILL')
  515. if (settles) await expect(waiting).resolves.toBeUndefined()
  516. else await expect(waiting).rejects.toThrow('Invalid argument')
  517. expect(query).toHaveBeenCalledTimes(2)
  518. expect(sleep).not.toHaveBeenCalled()
  519. expect(launched.result.control).toBe(launched.child.control)
  520. expect(launched.child.stdout.destroyed).toBe(false)
  521. expect(launched.child.control.destroyed).toBe(false)
  522. } finally {
  523. launched.child.exit(null, 'SIGKILL')
  524. await launched.result.direct
  525. launched.child.stdout.destroy()
  526. launched.child.stderr.destroy()
  527. launched.child.control.destroy()
  528. launched.result.owner.cleanup?.()
  529. }
  530. },
  531. )
  532. it('reports failed scope and direct kill submission without awaiting direct exit', async () => {
  533. vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) })
  534. const query = vi.fn(async () => activeUnitWithTasks('1'))
  535. const launched = launch(query, {
  536. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
  537. })
  538. consumeLinuxLaunchRequest(launched.requestPath)
  539. launched.result.owner.signal('SIGKILL')
  540. try {
  541. await expect(launched.result.owner.waitForExit()).rejects.toThrow('permission denied')
  542. expect(launched.child.signalCode).toBeNull()
  543. expect(query).toHaveBeenCalledOnce()
  544. } finally {
  545. launched.child.exit(null, 'SIGKILL')
  546. await launched.result.direct
  547. launched.result.owner.cleanup?.()
  548. }
  549. })
  550. it.each(['live', 'EPERM', 'ESRCH'].flatMap(probe => [
  551. { probe, exitCode: 23, signal: null },
  552. { probe, exitCode: null, signal: 'SIGKILL' as const },
  553. ]))('preserves the eventual direct exit $exitCode / $signal after a denied kill and a $probe PID probe', async ({ probe, exitCode, signal }) => {
  554. const signalFailure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
  555. const processKill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
  556. if (pid < 0 || (signal === 0 && probe === 'live')) return true
  557. if (signal === 0) throw Object.assign(new Error(probe), { code: probe })
  558. throw signalFailure
  559. })
  560. const query = vi.fn(async () => activeUnitWithTasks('1'))
  561. const launched = launch(query, {
  562. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'scope permission denied' })) as never,
  563. })
  564. vi.spyOn(launched.child, 'kill').mockImplementation(() => {
  565. launched.child.emit('error', signalFailure)
  566. return false
  567. })
  568. let directSettled = false
  569. const direct = launched.result.direct.finally(() => { directSettled = true })
  570. void direct.catch(() => {})
  571. consumeLinuxLaunchRequest(launched.requestPath)
  572. launched.result.owner.signal('SIGKILL')
  573. let failure: unknown
  574. const waiting = launched.result.owner.waitForExit().catch((error: unknown) => { failure = error })
  575. try {
  576. await new Promise<void>(resolve => setImmediate(resolve))
  577. expect(directSettled).toBe(false)
  578. if (probe === 'ESRCH') expect(failure).toBeUndefined()
  579. else expect(failure).toHaveProperty('message', expect.stringContaining('scope permission denied'))
  580. expect(launched.child.signalCode).toBeNull()
  581. expect(query).toHaveBeenCalledOnce()
  582. expect(processKill.mock.calls).toEqual([[-321, 'SIGKILL'], [321, 'SIGKILL'], [321, 0]])
  583. launched.child.exit(exitCode, signal)
  584. await expect(direct).resolves.toEqual({ exitCode, signal })
  585. await waiting
  586. expect(failure).toHaveProperty('message', expect.stringContaining('scope permission denied'))
  587. expect(query).toHaveBeenCalledTimes(probe === 'ESRCH' ? 2 : 1)
  588. } finally {
  589. launched.child.exit(exitCode, signal)
  590. await direct.catch(() => {})
  591. await waiting
  592. launched.result.owner.cleanup?.()
  593. }
  594. })
  595. it('reports a fresh surviving range immediately when direct exit precedes its query', async () => {
  596. denyProcessGroups()
  597. const query = vi.fn(async () => activeUnitWithTasks('1'))
  598. const launched = launch(query, {
  599. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  600. })
  601. consumeLinuxLaunchRequest(launched.requestPath)
  602. launched.result.owner.signal('SIGKILL')
  603. launched.child.exit(null, 'SIGKILL')
  604. await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')
  605. expect(query).toHaveBeenCalledOnce()
  606. await launched.result.direct
  607. launched.result.owner.cleanup?.()
  608. })
  609. it('keeps a direct launch error observable while joining its settlement', async () => {
  610. denyProcessGroups()
  611. const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
  612. const query = vi.fn()
  613. .mockImplementationOnce(() => firstRead.promise)
  614. .mockResolvedValueOnce(activeUnit('inactive'))
  615. const launched = launch(query, {
  616. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  617. })
  618. consumeLinuxLaunchRequest(launched.requestPath)
  619. launched.result.owner.signal('SIGKILL')
  620. const waiting = launched.result.owner.waitForExit()
  621. firstRead.resolve(activeUnitWithTasks('1'))
  622. await new Promise<void>(resolve => setImmediate(resolve))
  623. const failure = new Error('direct process error')
  624. const directFailure = expect(launched.result.direct).rejects.toBe(failure)
  625. launched.child.emit('error', failure)
  626. await directFailure
  627. await expect(waiting).resolves.toBeUndefined()
  628. expect(query).toHaveBeenCalledTimes(2)
  629. launched.result.owner.cleanup?.()
  630. })
  631. it('retains state-query errors without awaiting direct settlement', async () => {
  632. denyProcessGroups()
  633. const failure = new Error('manager unreachable')
  634. const launched = launch(async () => { throw failure }, {
  635. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
  636. })
  637. consumeLinuxLaunchRequest(launched.requestPath)
  638. launched.result.owner.signal('SIGKILL')
  639. await expect(launched.result.owner.waitForExit()).rejects.toBe(failure)
  640. expect(launched.child.signalCode).toBeNull()
  641. launched.child.exit(null, 'SIGKILL')
  642. await launched.result.direct
  643. launched.result.owner.cleanup?.()
  644. })
  645. it('settles a consumed empty scope before its launcher reports exit after a failed kill', async () => {
  646. denyProcessGroups()
  647. const spawnSync = recordingSystemctl()
  648. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  649. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  650. consumeLinuxLaunchRequest(launched.requestPath)
  651. launched.result.owner.signal('SIGKILL')
  652. try {
  653. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  654. expect(launched.child.exitCode).toBeNull()
  655. expect(launched.child.signalCode).toBeNull()
  656. expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill', 'stop'])
  657. } finally {
  658. launched.child.exit(null, 'SIGKILL')
  659. await launched.result.direct
  660. launched.result.owner.cleanup?.()
  661. }
  662. })
  663. it.each([
  664. { tasks: '1', clientRunning: false },
  665. { tasks: '[not set]', clientRunning: false },
  666. { tasks: '0', clientRunning: true },
  667. ])('retains a failed kill with tasks=$tasks and clientRunning=$clientRunning', async ({ tasks, clientRunning }) => {
  668. denyProcessGroups()
  669. const spawnSync = recordingSystemctl()
  670. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
  671. const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
  672. if (clientRunning) {
  673. vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) })
  674. }
  675. if (!clientRunning) consumeLinuxLaunchRequest(launched.requestPath)
  676. launched.result.owner.signal('SIGKILL')
  677. if (!clientRunning) launched.child.exit(null, 'SIGKILL')
  678. await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')
  679. expect(spawnSync).toHaveBeenCalledOnce()
  680. if (clientRunning) launched.child.exit(null, 'SIGKILL')
  681. await launched.result.direct
  682. launched.result.owner.cleanup?.()
  683. })
  684. it('reports command-query failures from the default systemctl adapter', async () => {
  685. childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
  686. const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
  687. const options = args[2] as { env: NodeJS.ProcessEnv }
  688. expect(options.env).toMatchObject({ LC_ALL: 'C' })
  689. expect(options.env).not.toHaveProperty('SYSTEMD_LOG_TARGET')
  690. callback(null, 'LoadState=loaded\nActiveState=inactive\n', 'manager diagnostic remains readable')
  691. return new EventEmitter()
  692. })
  693. const stopped = launch(undefined)
  694. await expect(stopped.result.owner.waitForExit()).resolves.toBeUndefined()
  695. stopped.result.owner.cleanup?.()
  696. const queryError = Object.assign(new Error('systemctl execution failed'), { code: 'ENOENT' })
  697. childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
  698. const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
  699. callback(queryError, '', '')
  700. return new EventEmitter()
  701. })
  702. const failed = launch(undefined)
  703. await expect(failed.result.owner.waitForExit()).rejects.toBe(queryError)
  704. failed.result.owner.cleanup?.()
  705. })
  706. it('rejects malformed, duplicate, incomplete, and extra manager state fields', async () => {
  707. for (const [stdout, message] of [
  708. ['loaded\nActiveState=active\n', 'malformed state'],
  709. ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'],
  710. ['LoadState=loaded\n', 'incomplete state'],
  711. ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'],
  712. ['LoadState=loaded\nActiveState=active\nTasksCurrent=0\nOther=value\n', 'incomplete state'],
  713. ] as const) {
  714. const launched = launch(async () => ({ status: 0, stdout, stderr: '' }))
  715. await expect(launched.result.owner.waitForExit()).rejects.toThrow(message)
  716. launched.result.owner.cleanup?.()
  717. }
  718. })
  719. it('rejects a manager process count that is neither numeric nor the unset sentinel', async () => {
  720. const launched = launch(async () => activeUnitWithTasks('many'))
  721. await expect(launched.result.owner.waitForExit()).rejects.toThrow('non-numeric TasksCurrent')
  722. launched.result.owner.cleanup?.()
  723. })
  724. it('releases an active scope left with no processes once its client has gone', async () => {
  725. // Regression: the manager's empty cgroup never ends this unit on its own.
  726. denyProcessGroups()
  727. const spawnSync = recordingSystemctl()
  728. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  729. launched.result.owner.signal('SIGKILL')
  730. launched.child.exit(null, 'SIGKILL')
  731. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  732. expect(spawnSync.mock.calls.map(call => call[1])).toEqual([
  733. ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', expect.stringMatching(/\.scope$/u)],
  734. ['--user', 'stop', expect.stringMatching(/\.scope$/u)],
  735. ])
  736. launched.result.owner.cleanup?.()
  737. })
  738. it('concludes the empty range even when releasing the leftover scope fails', async () => {
  739. denyProcessGroups()
  740. const spawnSync = recordingSystemctl()
  741. .mockImplementationOnce(() => ({ status: 0, stdout: '', stderr: '' }))
  742. .mockImplementationOnce(() => { throw new Error('systemctl is gone') })
  743. const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
  744. launched.result.owner.signal('SIGKILL')
  745. launched.child.exit(null, 'SIGKILL')
  746. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  747. launched.result.owner.cleanup?.()
  748. })
  749. it('keeps waiting while the client still owns an active scope with no processes', async () => {
  750. denyProcessGroups()
  751. const spawnSync = recordingSystemctl()
  752. const states = [activeUnitWithTasks('0'), unloadedUnit()]
  753. const launched = launch(async () => states.shift() ?? unloadedUnit(), {
  754. spawnSync: spawnSync as never,
  755. })
  756. launched.result.owner.signal('SIGTERM')
  757. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  758. expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill'])
  759. launched.result.owner.cleanup?.()
  760. })
  761. it('keeps waiting for an active empty scope no termination has requested', async () => {
  762. const states = [activeUnitWithTasks('0'), unloadedUnit()]
  763. const launched = launch(async () => states.shift() ?? unloadedUnit())
  764. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  765. expect(launched.spawnSync).not.toHaveBeenCalled()
  766. launched.result.owner.cleanup?.()
  767. })
  768. it('treats an unset process count as unknown and keeps waiting', async () => {
  769. const states = [activeUnitWithTasks('[not set]'), unloadedUnit()]
  770. const launched = launch(async () => states.shift() ?? unloadedUnit())
  771. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  772. expect(launched.spawnSync).not.toHaveBeenCalled()
  773. launched.result.owner.cleanup?.()
  774. })
  775. it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => {
  776. const spawnSync = vi.fn()
  777. .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
  778. .mockReturnValueOnce({ status: 1, stderr: 'Unit dsh.scope could not be found.' })
  779. .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
  780. .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
  781. const states = [activeUnit(), activeUnit('failed')]
  782. const launched = launch(async () => states.shift() ?? missingUnit(), {
  783. spawnSync: spawnSync as never,
  784. })
  785. launched.child.pid = undefined
  786. unlinkSync(launched.requestPath)
  787. launched.result.owner.signal('SIGTERM')
  788. launched.result.owner.signal('SIGKILL')
  789. launched.result.owner.signal('SIGKILL')
  790. launched.result.owner.signal('SIGKILL')
  791. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  792. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  793. launched.result.owner.terminateForHostExit()
  794. expect(spawnSync).toHaveBeenCalledTimes(4)
  795. launched.result.owner.cleanup?.()
  796. })
  797. it('reports unreadable manager output and a failed kill before establishment', async () => {
  798. const withOutput = launch(async () => ({
  799. status: 5, stdout: '', stderr: 'permission denied',
  800. }))
  801. await expect(withOutput.result.owner.waitForExit()).rejects.toThrow('permission denied')
  802. withOutput.result.owner.cleanup?.()
  803. const withoutOutput = launch(async () => ({ status: null, stdout: '', stderr: '' }))
  804. await expect(withoutOutput.result.owner.waitForExit()).rejects.toThrow('exit null')
  805. withoutOutput.result.owner.cleanup?.()
  806. const killFailed = launch(async () => missingUnit(), {
  807. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'kill denied' })) as never,
  808. })
  809. vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
  810. killFailed.result.owner.signal('SIGKILL')
  811. await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('kill denied')
  812. killFailed.result.owner.cleanup?.()
  813. })
  814. it('settles direct outcomes once and reports malformed startup errors', async () => {
  815. const childError = launch(async () => missingUnit())
  816. const spawnError = new Error('systemd-run failed')
  817. childError.child.emit('error', spawnError)
  818. childError.child.exit(1, null)
  819. await expect(childError.result.direct).rejects.toBe(spawnError)
  820. childError.result.owner.cleanup?.()
  821. const lateError = launch(async () => missingUnit())
  822. consumeLinuxLaunchRequest(lateError.requestPath)
  823. lateError.child.exit(0, null)
  824. lateError.child.emit('error', new Error('late child error'))
  825. await expect(lateError.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  826. lateError.result.owner.cleanup?.()
  827. const malformed = launch(async () => missingUnit())
  828. const files = linuxLaunchFilesFromLocator(malformed.requestPath)
  829. unlinkSync(malformed.requestPath)
  830. writeFileSync(files.startupErrorPath, '{', { mode: 0o600 })
  831. malformed.child.exit(127, null)
  832. await expect(malformed.result.direct).rejects.toBeInstanceOf(SyntaxError)
  833. malformed.result.owner.cleanup?.()
  834. })
  835. it('does not signal a direct group before the launcher publishes a pid', async () => {
  836. const launched = launch(async () => activeUnit('inactive'))
  837. launched.child.pid = undefined
  838. const processKill = vi.spyOn(process, 'kill')
  839. launched.result.owner.signal('SIGTERM')
  840. expect(processKill).not.toHaveBeenCalled()
  841. expect(launched.child.kills).toEqual([])
  842. consumeLinuxLaunchRequest(launched.requestPath)
  843. launched.child.exit(0, null)
  844. await expect(launched.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  845. launched.result.owner.cleanup?.()
  846. })
  847. it('does not signal the direct group after the launcher exits', async () => {
  848. const { child, result, requestPath, spawnSync } = launch(async () => activeUnit())
  849. consumeLinuxLaunchRequest(requestPath)
  850. child.exit(0, null)
  851. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  852. const processKill = vi.spyOn(process, 'kill')
  853. result.owner.signal('SIGTERM')
  854. result.owner.terminateForHostExit()
  855. expect(processKill).not.toHaveBeenCalled()
  856. expect(child.kills).toEqual([])
  857. expect(spawnSync).toHaveBeenCalledTimes(2)
  858. result.owner.cleanup?.()
  859. })
  860. it('signals the group and direct process before the exact synchronous scope kill on host exit', () => {
  861. const events: string[] = []
  862. const { result } = launch(async () => missingUnit(), {
  863. spawnSync: vi.fn(() => { events.push('scope'); return { status: 0 } }) as never,
  864. })
  865. vi.spyOn(process, 'kill').mockImplementation((pid) => { events.push(pid < 0 ? 'group' : 'direct'); return true })
  866. result.owner.terminateForHostExit()
  867. expect(events).toEqual(['group', 'direct', 'scope'])
  868. result.owner.cleanup?.()
  869. })
  870. })
  871. describe('Linux PTY bootstrap reuse', () => {
  872. const terminalSpec = {
  873. argv: ['bash', '--noprofile'],
  874. cwd: '/target',
  875. env: { TARGET: 'yes' },
  876. rows: 24,
  877. cols: 80,
  878. terminalType: 'dumb',
  879. graceMs: 100,
  880. } as const
  881. it.each(['SIGTERM', 'SIGKILL'] as const)('preserves %s before bootstrap consumption and joins the empty scope', async (signal) => {
  882. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
  883. spawnSync: vi.fn(() => missingUnit()) as never,
  884. systemctlQuery: async () => missingUnit(),
  885. })
  886. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  887. if (requestPath === undefined) throw new Error('missing PTY request')
  888. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  889. let running = true
  890. const kill = vi.fn()
  891. const owner = scope.bindOwner({ running: () => running, signal: kill.mockReturnValue(true), settled: Promise.resolve() })
  892. owner.signal(signal)
  893. expect(kill).toHaveBeenCalledExactlyOnceWith(signal)
  894. running = false
  895. expect(existsSync(requestPath)).toBe(true)
  896. expect(scope.resolveOutcome({ exitCode: 0, signal })).toEqual({ exitCode: 0, signal })
  897. await expect(owner.waitForExit()).resolves.toBeUndefined()
  898. scope.cleanup()
  899. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  900. })
  901. it('uses the same request/bootstrap, preserves argv, and cleans after owner settlement', async () => {
  902. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
  903. systemdRun: '/bin/systemd-run',
  904. systemctl: '/bin/systemctl',
  905. runnerInvocation: ['/usr/bin/node', '/runner.js'],
  906. spawnSync: vi.fn(() => ({ status: 0 })) as never,
  907. systemctlQuery: async () => missingUnit(),
  908. })
  909. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  910. if (requestPath === undefined) throw new Error('missing PTY request')
  911. expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile'])
  912. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  913. const owner = scope.bindOwner({ running: () => false, signal: vi.fn(() => true), settled: Promise.resolve() })
  914. await expect(owner.waitForExit()).resolves.toBeUndefined()
  915. expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null })
  916. scope.cleanup()
  917. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  918. })
  919. it('surfaces PTY pre-exec errors instead of launcher outcomes', () => {
  920. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
  921. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  922. if (requestPath === undefined) throw new Error('missing PTY request')
  923. const files = linuxLaunchFilesFromLocator(requestPath)
  924. unlinkSync(requestPath)
  925. writeLinuxStartupError(files, {
  926. type: 'error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' },
  927. })
  928. expect(() => scope.resolveOutcome({ exitCode: 127, signal: null })).toThrow('bad cwd')
  929. scope.cleanup()
  930. })
  931. it('uses default owner dependencies and rejects an unconsumed request', () => {
  932. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
  933. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  934. if (requestPath === undefined) throw new Error('missing PTY request')
  935. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  936. scope.bindOwner({ running: () => true, signal: vi.fn(() => true), settled: Promise.resolve() })
  937. expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow(
  938. 'before its bootstrap consumed',
  939. )
  940. scope.cleanup()
  941. })
  942. })
  943. describe('Linux ordinary launch adapters', () => {
  944. it('uses the default launch dependencies without changing the target request', async () => {
  945. const child = new FakeChild()
  946. childProcessMocks.spawn.mockReturnValue(child)
  947. const result = launchLinuxScope(spec(), { TARGET: 'yes' })
  948. const call = childProcessMocks.spawn.mock.calls[0]
  949. const options = call?.[2] as { env?: NodeJS.ProcessEnv } | undefined
  950. const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
  951. if (requestPath === undefined) throw new Error('launch did not publish a request locator')
  952. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  953. expect(call?.[0]).toBe('systemd-run')
  954. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  955. child.exit(0, null)
  956. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  957. result.owner.cleanup?.()
  958. })
  959. it('removes the private launch directory when spawn throws synchronously', () => {
  960. const spawnError = new Error('synchronous spawn failure')
  961. let requestPath: string | undefined
  962. expect(() => launchLinuxScope(spec(), { TARGET: 'yes' }, {
  963. runnerInvocation: ['/usr/bin/node', '/runner.js'],
  964. spawn: vi.fn((_command: string, _args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
  965. requestPath = options.env?.[SUBPROCESS_RUNNER_ENV]
  966. throw spawnError
  967. }) as never,
  968. })).toThrow(spawnError)
  969. if (requestPath === undefined) throw new Error('spawn did not receive a request locator')
  970. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  971. })
  972. })