linux-scope.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import { EventEmitter } from 'node:events'
  2. import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
  3. import { PassThrough } from 'node:stream'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import {
  6. launchLinuxScope,
  7. prepareLinuxTerminalScope,
  8. probeLinuxBootstrap,
  9. probeLinuxNative,
  10. probeLinuxScope,
  11. } from '../src/linux-scope.ts'
  12. import type { LinuxScopeInternals } from '../src/linux-scope.ts'
  13. import {
  14. consumeLinuxLaunchRequest,
  15. linuxLaunchFilesFromLocator,
  16. writeLinuxStartupError,
  17. } from '../src/runner-protocol.ts'
  18. import { SUBPROCESS_RUNNER_ENV } from '../src/runner-launch.ts'
  19. const childProcessMocks = vi.hoisted(() => ({
  20. execFile: vi.fn(),
  21. spawn: vi.fn(),
  22. spawnSync: vi.fn(),
  23. }))
  24. vi.mock('node:child_process', async (importOriginal) => {
  25. const actual = await importOriginal<typeof import('node:child_process')>()
  26. return {
  27. ...actual,
  28. execFile: childProcessMocks.execFile as unknown as typeof actual.execFile,
  29. spawn: childProcessMocks.spawn as typeof actual.spawn,
  30. spawnSync: childProcessMocks.spawnSync as typeof actual.spawnSync,
  31. }
  32. })
  33. class FakeChild extends EventEmitter {
  34. pid: number | undefined = 321
  35. exitCode: number | null = null
  36. signalCode: NodeJS.Signals | null = null
  37. stdin = new PassThrough()
  38. stdout = new PassThrough()
  39. stderr = new PassThrough()
  40. kills: NodeJS.Signals[] = []
  41. kill(signal: NodeJS.Signals): boolean {
  42. this.kills.push(signal)
  43. return true
  44. }
  45. exit(exitCode: number | null, signal: NodeJS.Signals | null): void {
  46. this.exitCode = exitCode
  47. this.signalCode = signal
  48. this.emit('exit', exitCode, signal)
  49. }
  50. }
  51. const directories: string[] = []
  52. afterEach(() => {
  53. for (const directory of directories.splice(0)) {
  54. rmSync(directory, { recursive: true, force: true })
  55. }
  56. vi.restoreAllMocks()
  57. childProcessMocks.execFile.mockReset()
  58. childProcessMocks.spawn.mockReset()
  59. childProcessMocks.spawnSync.mockReset()
  60. })
  61. function missingUnit() {
  62. return { status: 1, stdout: '', stderr: 'Unit dsh.scope could not be found.' }
  63. }
  64. function activeUnit(state = 'active') {
  65. return { status: 0, stdout: `${state}\n`, stderr: '' }
  66. }
  67. function spec() {
  68. return {
  69. argv: ['tool', 'literal arg'],
  70. cwd: '/target',
  71. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  72. graceMs: 100,
  73. env: { TARGET: 'yes' },
  74. } as const
  75. }
  76. function launch(
  77. query: LinuxScopeInternals['systemctlQuery'],
  78. overrides: LinuxScopeInternals = {},
  79. ) {
  80. const child = new FakeChild()
  81. let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined
  82. const spawn = vi.fn((_command: string, _args: readonly string[], received: typeof options) => {
  83. options = received
  84. return child
  85. })
  86. const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' }))
  87. const systemctlQuery = overrides.systemctlQuery ?? query
  88. const result = launchLinuxScope(spec(), { TARGET: 'yes' }, {
  89. spawn: overrides.spawn ?? spawn as never,
  90. spawnSync: overrides.spawnSync ?? spawnSync as never,
  91. ...systemctlQuery === undefined ? {} : { systemctlQuery },
  92. systemdRun: overrides.systemdRun ?? '/bin/systemd-run',
  93. systemctl: overrides.systemctl ?? '/bin/systemctl',
  94. runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'],
  95. ...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable },
  96. ...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve },
  97. ...overrides.sleep === undefined ? {} : { sleep: overrides.sleep },
  98. })
  99. const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
  100. if (requestPath === undefined) throw new Error('launch did not publish a request locator')
  101. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  102. return { child, result, requestPath, spawn, spawnSync, options }
  103. }
  104. describe('Linux native capability selection', () => {
  105. it('rechecks bootstrap and literal transient-scope support', () => {
  106. const spawnSync = vi.fn(() => ({ status: 0, error: undefined }))
  107. const runnerAvailable = vi.fn(() => true)
  108. const loadLinuxExecve = vi.fn(() => vi.fn() as never)
  109. const inputs = {
  110. spawnSync: spawnSync as never,
  111. runnerAvailable,
  112. runnerInvocation: ['/usr/bin/node', '/runner.js'] as [string, ...string[]],
  113. loadLinuxExecve,
  114. systemdRun: '/bin/systemd-run',
  115. systemctl: '/bin/systemctl',
  116. }
  117. expect(probeLinuxNative(inputs)).toBe(true)
  118. expect(probeLinuxNative(inputs)).toBe(true)
  119. expect(runnerAvailable).toHaveBeenCalledTimes(2)
  120. expect(loadLinuxExecve).toHaveBeenCalledTimes(2)
  121. expect(spawnSync).toHaveBeenCalledTimes(2)
  122. expect(probeLinuxBootstrap({
  123. ...inputs,
  124. loadLinuxExecve: () => { throw new Error('libc execve missing') },
  125. })).toBe(false)
  126. })
  127. it('reports each failed dynamic prerequisite without executing a target', () => {
  128. expect(probeLinuxScope({
  129. spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
  130. })).toBe(false)
  131. expect(probeLinuxBootstrap({
  132. loadLinuxExecve: () => vi.fn() as never,
  133. runnerInvocation: ['/missing'],
  134. runnerAvailable: () => false,
  135. })).toBe(false)
  136. expect(probeLinuxBootstrap({
  137. loadLinuxExecve: () => vi.fn() as never,
  138. resolveRunnerInvocation: () => { throw new Error('runner resolution failed') },
  139. })).toBe(false)
  140. })
  141. it('uses the default command adapters and runner resolution', () => {
  142. childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined })
  143. expect(probeLinuxScope()).toBe(true)
  144. expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(1)
  145. expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true)
  146. expect(probeLinuxBootstrap({
  147. runnerInvocation: [process.execPath],
  148. runnerAvailable: () => true,
  149. })).toBe(process.platform !== 'win32')
  150. })
  151. })
  152. describe('Linux scope establishment and quiescence', () => {
  153. it('does not mistake pre-establishment unit absence for quiescence and rejects after cancellation', async () => {
  154. const { child, result, requestPath, spawnSync } = launch(async () => missingUnit())
  155. const waiting = result.owner.waitForExit()
  156. result.owner.signal('SIGTERM')
  157. expect(child.kills).toEqual(['SIGTERM'])
  158. expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([
  159. 'kill', '--kill-whom=all', '--signal=SIGTERM',
  160. ]), expect.anything())
  161. const direct = expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  162. child.exit(null, 'SIGTERM')
  163. await direct
  164. await expect(waiting).rejects.toThrow('ended before consuming its launch request')
  165. expect(existsSync(requestPath)).toBe(true)
  166. result.owner.cleanup?.()
  167. })
  168. it('accepts request consumption followed by rapid --collect unload as stopped', async () => {
  169. const states = [activeUnit(), missingUnit()]
  170. const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit())
  171. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  172. const waiting = result.owner.waitForExit()
  173. child.exit(0, null)
  174. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  175. await expect(waiting).resolves.toBeUndefined()
  176. result.owner.signal('SIGKILL')
  177. result.owner.cleanup?.()
  178. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  179. })
  180. it('uses the scope alone after establishment and the direct range only when scope signalling fails', async () => {
  181. const spawnSync = vi.fn()
  182. .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
  183. .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'scope signal failed' })
  184. const { child, result, requestPath } = launch(async () => activeUnit(), {
  185. spawnSync: spawnSync as never,
  186. })
  187. consumeLinuxLaunchRequest(requestPath)
  188. const processKill = vi.spyOn(process, 'kill').mockReturnValue(true)
  189. result.owner.signal('SIGTERM')
  190. expect(processKill).not.toHaveBeenCalled()
  191. result.owner.signal('SIGKILL')
  192. expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, 'SIGKILL')
  193. expect(spawnSync).toHaveBeenCalledTimes(2)
  194. child.exit(null, 'SIGKILL')
  195. await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  196. result.owner.cleanup?.()
  197. })
  198. it('uses manager-observed unit existence as establishment proof', async () => {
  199. const { child, result } = launch(async () => activeUnit('inactive'))
  200. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  201. child.exit(1, null)
  202. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  203. result.owner.cleanup?.()
  204. })
  205. it('keeps waiting while the unit is absent and the direct launcher is still running', async () => {
  206. const states = [missingUnit(), activeUnit('inactive')]
  207. const { child, result } = launch(async () => states.shift() ?? activeUnit('inactive'))
  208. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  209. child.exit(1, null)
  210. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  211. result.owner.cleanup?.()
  212. })
  213. it('polls promptly before establishment and backs off established active scopes', async () => {
  214. const delays: number[] = []
  215. const states = [
  216. missingUnit(),
  217. activeUnit(),
  218. activeUnit(),
  219. activeUnit(),
  220. activeUnit(),
  221. activeUnit(),
  222. activeUnit(),
  223. activeUnit(),
  224. activeUnit(),
  225. activeUnit('inactive'),
  226. ]
  227. const launched = launch(
  228. async () => states.shift() ?? activeUnit('inactive'),
  229. { sleep: async (delayMs) => { delays.push(delayMs) } },
  230. )
  231. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  232. expect(delays).toEqual([50, 50, 100, 200, 400, 800, 1_600, 3_200, 5_000])
  233. launched.result.owner.cleanup?.()
  234. })
  235. it('reports child termination before request consumption to both result and wait', async () => {
  236. const { child, result } = launch(async () => missingUnit())
  237. child.exit(127, null)
  238. await expect(result.direct).rejects.toThrow('before its bootstrap consumed')
  239. await expect(result.owner.waitForExit()).rejects.toThrow('ended before consuming its launch request')
  240. result.owner.cleanup?.()
  241. })
  242. it('reconstructs a pre-exec startup error instead of exposing bootstrap exit 127', async () => {
  243. const { child, result, requestPath } = launch(async () => missingUnit())
  244. const files = linuxLaunchFilesFromLocator(requestPath)
  245. unlinkSync(requestPath)
  246. writeLinuxStartupError(files, {
  247. type: 'error',
  248. error: { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT' },
  249. })
  250. child.exit(127, null)
  251. await expect(result.direct).rejects.toMatchObject({ code: 'ENOENT' })
  252. result.owner.cleanup?.()
  253. })
  254. it('retries a failed state query and rejects unknown states or failed final kills', async () => {
  255. const query = vi.fn()
  256. .mockResolvedValueOnce({ status: null, stdout: '', stderr: '', error: new Error('query failed') })
  257. .mockResolvedValueOnce(missingUnit())
  258. const { result, requestPath } = launch(query)
  259. unlinkSync(requestPath)
  260. await expect(result.owner.waitForExit()).rejects.toThrow('query failed')
  261. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  262. result.owner.cleanup?.()
  263. const unknown = launch(async () => activeUnit('mystery'))
  264. await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
  265. unknown.result.owner.cleanup?.()
  266. const killFailed = launch(async () => activeUnit(), {
  267. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
  268. })
  269. killFailed.result.owner.signal('SIGKILL')
  270. await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
  271. killFailed.result.owner.cleanup?.()
  272. })
  273. it('reports command-query failures from the default systemctl adapter', async () => {
  274. childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
  275. const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
  276. callback(null, 'inactive\n', '')
  277. return new EventEmitter()
  278. })
  279. const stopped = launch(undefined)
  280. await expect(stopped.result.owner.waitForExit()).resolves.toBeUndefined()
  281. stopped.result.owner.cleanup?.()
  282. const queryError = Object.assign(new Error('systemctl execution failed'), { code: 'ENOENT' })
  283. childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
  284. const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void
  285. callback(queryError, '', '')
  286. return new EventEmitter()
  287. })
  288. const failed = launch(undefined)
  289. await expect(failed.result.owner.waitForExit()).rejects.toBe(queryError)
  290. failed.result.owner.cleanup?.()
  291. })
  292. it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => {
  293. const spawnSync = vi.fn()
  294. .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
  295. .mockReturnValueOnce({ status: 1, stderr: 'Unit dsh.scope could not be found.' })
  296. .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' })
  297. .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
  298. const states = [activeUnit(), activeUnit('failed')]
  299. const launched = launch(async () => states.shift() ?? missingUnit(), {
  300. spawnSync: spawnSync as never,
  301. })
  302. launched.child.pid = undefined
  303. unlinkSync(launched.requestPath)
  304. launched.result.owner.signal('SIGTERM')
  305. launched.result.owner.signal('SIGKILL')
  306. launched.result.owner.signal('SIGKILL')
  307. launched.result.owner.signal('SIGKILL')
  308. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  309. await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
  310. launched.result.owner.terminateForHostExit()
  311. expect(spawnSync).toHaveBeenCalledTimes(4)
  312. launched.result.owner.cleanup?.()
  313. })
  314. it('reports unreadable manager output and a failed kill before establishment', async () => {
  315. const withOutput = launch(async () => ({
  316. status: 5, stdout: '', stderr: 'permission denied',
  317. }))
  318. await expect(withOutput.result.owner.waitForExit()).rejects.toThrow('permission denied')
  319. withOutput.result.owner.cleanup?.()
  320. const withoutOutput = launch(async () => ({ status: null, stdout: '', stderr: '' }))
  321. await expect(withoutOutput.result.owner.waitForExit()).rejects.toThrow('exit null')
  322. withoutOutput.result.owner.cleanup?.()
  323. const killFailed = launch(async () => missingUnit(), {
  324. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'kill denied' })) as never,
  325. })
  326. vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
  327. killFailed.result.owner.signal('SIGKILL')
  328. await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('kill denied')
  329. killFailed.result.owner.cleanup?.()
  330. })
  331. it('settles direct outcomes once and reports malformed startup errors', async () => {
  332. const childError = launch(async () => missingUnit())
  333. const spawnError = new Error('systemd-run failed')
  334. childError.child.emit('error', spawnError)
  335. childError.child.exit(1, null)
  336. await expect(childError.result.direct).rejects.toBe(spawnError)
  337. childError.result.owner.cleanup?.()
  338. const lateError = launch(async () => missingUnit())
  339. consumeLinuxLaunchRequest(lateError.requestPath)
  340. lateError.child.exit(0, null)
  341. lateError.child.emit('error', new Error('late child error'))
  342. await expect(lateError.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  343. lateError.result.owner.cleanup?.()
  344. const malformed = launch(async () => missingUnit())
  345. const files = linuxLaunchFilesFromLocator(malformed.requestPath)
  346. unlinkSync(malformed.requestPath)
  347. writeFileSync(files.startupErrorPath, '{', { mode: 0o600 })
  348. malformed.child.exit(127, null)
  349. await expect(malformed.result.direct).rejects.toBeInstanceOf(SyntaxError)
  350. malformed.result.owner.cleanup?.()
  351. })
  352. it('does not signal a direct group before the launcher publishes a pid', async () => {
  353. const launched = launch(async () => activeUnit('inactive'))
  354. launched.child.pid = undefined
  355. const processKill = vi.spyOn(process, 'kill')
  356. launched.result.owner.signal('SIGTERM')
  357. expect(processKill).not.toHaveBeenCalled()
  358. expect(launched.child.kills).toEqual([])
  359. consumeLinuxLaunchRequest(launched.requestPath)
  360. launched.child.exit(0, null)
  361. await expect(launched.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  362. launched.result.owner.cleanup?.()
  363. })
  364. it('does not signal the direct group after the launcher exits', async () => {
  365. const { child, result, requestPath, spawnSync } = launch(async () => activeUnit())
  366. consumeLinuxLaunchRequest(requestPath)
  367. child.exit(0, null)
  368. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  369. const processKill = vi.spyOn(process, 'kill')
  370. result.owner.signal('SIGTERM')
  371. result.owner.terminateForHostExit()
  372. expect(processKill).not.toHaveBeenCalled()
  373. expect(child.kills).toEqual([])
  374. expect(spawnSync).toHaveBeenCalledTimes(2)
  375. result.owner.cleanup?.()
  376. })
  377. it('runs direct fallback before the exact synchronous scope kill on host exit', () => {
  378. const events: string[] = []
  379. const { child, result } = launch(async () => missingUnit(), {
  380. spawnSync: vi.fn(() => { events.push('scope'); return { status: 0 } }) as never,
  381. })
  382. child.kill = vi.fn(() => { events.push('direct'); return true })
  383. vi.spyOn(process, 'kill').mockImplementation(() => { events.push('direct'); return true })
  384. result.owner.terminateForHostExit()
  385. expect(events).toEqual(['direct', 'scope'])
  386. result.owner.cleanup?.()
  387. })
  388. })
  389. describe('Linux PTY bootstrap reuse', () => {
  390. const terminalSpec = {
  391. argv: ['bash', '--noprofile'],
  392. cwd: '/target',
  393. env: { TARGET: 'yes' },
  394. rows: 24,
  395. cols: 80,
  396. graceMs: 100,
  397. } as const
  398. it('uses the same request/bootstrap, preserves argv, and cleans after owner settlement', async () => {
  399. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, {
  400. systemdRun: '/bin/systemd-run',
  401. systemctl: '/bin/systemctl',
  402. runnerInvocation: ['/usr/bin/node', '/runner.js'],
  403. spawnSync: vi.fn(() => ({ status: 0 })) as never,
  404. systemctlQuery: async () => missingUnit(),
  405. })
  406. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  407. if (requestPath === undefined) throw new Error('missing PTY request')
  408. expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile'])
  409. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  410. const owner = scope.bindOwner({ running: () => false, signal: vi.fn() })
  411. await expect(owner.waitForExit()).resolves.toBeUndefined()
  412. expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null })
  413. scope.cleanup()
  414. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  415. })
  416. it('surfaces PTY pre-exec errors instead of launcher outcomes', () => {
  417. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
  418. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  419. if (requestPath === undefined) throw new Error('missing PTY request')
  420. const files = linuxLaunchFilesFromLocator(requestPath)
  421. unlinkSync(requestPath)
  422. writeLinuxStartupError(files, {
  423. type: 'error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' },
  424. })
  425. expect(() => scope.resolveOutcome({ exitCode: 127, signal: null })).toThrow('bad cwd')
  426. scope.cleanup()
  427. })
  428. it('uses default owner dependencies and rejects an unconsumed request', () => {
  429. const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
  430. const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
  431. if (requestPath === undefined) throw new Error('missing PTY request')
  432. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  433. scope.bindOwner({ running: () => true, signal: vi.fn() })
  434. expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow(
  435. 'before its bootstrap consumed',
  436. )
  437. scope.cleanup()
  438. })
  439. })
  440. describe('Linux ordinary launch adapters', () => {
  441. it('uses the default launch dependencies without changing the target request', async () => {
  442. const child = new FakeChild()
  443. childProcessMocks.spawn.mockReturnValue(child)
  444. const result = launchLinuxScope(spec(), { TARGET: 'yes' })
  445. const call = childProcessMocks.spawn.mock.calls[0]
  446. const options = call?.[2] as { env?: NodeJS.ProcessEnv } | undefined
  447. const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
  448. if (requestPath === undefined) throw new Error('launch did not publish a request locator')
  449. directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
  450. expect(call?.[0]).toBe('systemd-run')
  451. expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
  452. child.exit(0, null)
  453. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  454. result.owner.cleanup?.()
  455. })
  456. it('removes the private launch directory when spawn throws synchronously', () => {
  457. const spawnError = new Error('synchronous spawn failure')
  458. let requestPath: string | undefined
  459. expect(() => launchLinuxScope(spec(), { TARGET: 'yes' }, {
  460. runnerInvocation: ['/usr/bin/node', '/runner.js'],
  461. spawn: vi.fn((_command: string, _args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
  462. requestPath = options.env?.[SUBPROCESS_RUNNER_ENV]
  463. throw spawnError
  464. }) as never,
  465. })).toThrow(spawnError)
  466. if (requestPath === undefined) throw new Error('spawn did not receive a request locator')
  467. expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
  468. })
  469. })