linux-scope.spec.ts 45 KB

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