linux-scope.spec.ts 26 KB

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