local.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  1. import { PassThrough } from 'node:stream'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { basename, dirname, relative, resolve } from 'node:path'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  6. import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  7. import { childEnv } from '../src/spawn.ts'
  8. function mockWin32ForIsolatedRuntime(): void {
  9. vi.doMock('@deepseek-ai/dsh-win32-process', () => ({
  10. loadWin32ProcessBindings: vi.fn(),
  11. probeCurrentTokenJobSupport: vi.fn(),
  12. }))
  13. }
  14. function unmockWin32ForIsolatedRuntime(): void {
  15. vi.doUnmock('@deepseek-ai/dsh-win32-process')
  16. }
  17. function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  18. // Windows has no bash; the suite's simple commands translate to node one-liners.
  19. const argv = process.platform === 'win32'
  20. ? [process.execPath, '-e', {
  21. 'echo managed': 'console.log("managed")',
  22. 'sleep 60': 'setTimeout(() => {}, 60000)',
  23. 'true': '',
  24. }[command] ?? command]
  25. : ['bash', '-c', command]
  26. return {
  27. argv,
  28. cwd: process.cwd(),
  29. stdio: {
  30. stdin: 'ignore',
  31. stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
  32. stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
  33. },
  34. graceMs: 200,
  35. ...overrides,
  36. }
  37. }
  38. describe('LocalSubprocessRuntime', () => {
  39. it('places the host-exit finalizer before listeners that predate the service', async () => {
  40. const baseline = new Set(process.listeners('exit'))
  41. const prior = vi.fn()
  42. process.on('exit', prior)
  43. const ctx = new Context()
  44. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  45. try {
  46. const listeners = process.listeners('exit')
  47. const finalizer = listeners.find(candidate => !baseline.has(candidate) && candidate !== prior)
  48. expect(finalizer).toBeTypeOf('function')
  49. expect(listeners.indexOf(finalizer!)).toBeLessThan(listeners.indexOf(prior))
  50. } finally {
  51. process.off('exit', prior)
  52. await fiber.dispose()
  53. }
  54. })
  55. it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => {
  56. const before = new Set(process.listeners('exit'))
  57. const ctx = new Context()
  58. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  59. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  60. expect(listener).toBeTypeOf('function')
  61. let finishExit!: () => void
  62. const exited = new Promise<void>((resolve) => { finishExit = resolve })
  63. const terminate = vi.fn()
  64. const terminateForHostExit = vi.fn()
  65. const live = (ctx.subprocess as unknown as {
  66. live: Set<{
  67. done: Promise<{ exitCode: number; signal: null }>
  68. terminate(): void
  69. terminateForHostExit(): void
  70. waitForExit(): Promise<boolean>
  71. }>
  72. }).live
  73. live.add({
  74. done: Promise.resolve({ exitCode: 0, signal: null }),
  75. terminate,
  76. terminateForHostExit,
  77. waitForExit: async () => { await exited; return true },
  78. })
  79. let disposed = false
  80. const disposing = fiber.dispose().then(() => { disposed = true })
  81. await new Promise(resolve => setImmediate(resolve))
  82. expect(disposed).toBe(false)
  83. expect(live.size).toBe(1)
  84. listener?.(0)
  85. expect(terminate).toHaveBeenCalledOnce()
  86. expect(terminateForHostExit).toHaveBeenCalledOnce()
  87. finishExit()
  88. await disposing
  89. expect(live.size).toBe(0)
  90. expect(process.listeners('exit')).not.toContain(listener)
  91. })
  92. it('observes range failure without waiting for a stuck direct result', async () => {
  93. const before = new Set(process.listeners('exit'))
  94. const ctx = new Context()
  95. const disposalErrors: unknown[] = []
  96. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  97. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  98. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  99. const rangeFailure = new Error('managed range became unreadable')
  100. const terminate = vi.fn()
  101. const terminateForHostExit = vi.fn()
  102. const live = (ctx.subprocess as unknown as {
  103. live: Set<{
  104. done: Promise<never>
  105. terminate(): void
  106. terminateForHostExit(): void
  107. waitForExit(): Promise<boolean>
  108. }>
  109. }).live
  110. live.add({
  111. done: new Promise<never>(() => {}),
  112. terminate,
  113. terminateForHostExit,
  114. waitForExit: async () => { throw rangeFailure },
  115. })
  116. await expect(Promise.race([
  117. fiber.dispose().then(() => 'disposed'),
  118. new Promise<string>(resolve => setTimeout(() => { resolve('timeout') }, 100)),
  119. ])).resolves.toBe('disposed')
  120. expect(terminate).toHaveBeenCalledOnce()
  121. expect(terminateForHostExit).toHaveBeenCalledOnce()
  122. expect(disposalErrors).toEqual([rangeFailure])
  123. expect(live.size).toBe(1)
  124. expect(process.listeners('exit')).toContain(listener)
  125. listener?.(0)
  126. expect(terminateForHostExit).toHaveBeenCalledTimes(2)
  127. if (listener !== undefined) process.off('exit', listener)
  128. })
  129. it('contains each host-exit termination failure and continues with the other targets', async () => {
  130. const before = new Set(process.listeners('exit'))
  131. const ctx = new Context()
  132. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  133. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  134. expect(listener).toBeTypeOf('function')
  135. const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') })
  136. const ordinarySuccess = vi.fn()
  137. const terminalFailure = vi.fn(() => { throw new Error('terminal failed') })
  138. const terminalSuccess = vi.fn()
  139. const service = ctx.subprocess as unknown as {
  140. live: Set<{ terminateForHostExit(): void }>
  141. terminals: Set<{ terminateForHostExit(): void }>
  142. }
  143. service.live.add({ terminateForHostExit: ordinaryFailure })
  144. service.live.add({ terminateForHostExit: ordinarySuccess })
  145. service.terminals.add({ terminateForHostExit: terminalFailure })
  146. service.terminals.add({ terminateForHostExit: terminalSuccess })
  147. expect(() => { listener?.(0) }).not.toThrow()
  148. expect(ordinaryFailure).toHaveBeenCalledOnce()
  149. expect(ordinarySuccess).toHaveBeenCalledOnce()
  150. expect(terminalFailure).toHaveBeenCalledOnce()
  151. expect(terminalSuccess).toHaveBeenCalledOnce()
  152. service.live.clear()
  153. service.terminals.clear()
  154. await fiber.dispose()
  155. })
  156. it('resolves absolute and PATH executables and honors lookup cancellation', async () => {
  157. const ctx = new Context()
  158. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  159. expect(await ctx.subprocess.resolveExecutable(process.execPath)).toBe(process.execPath)
  160. expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
  161. PATH: dirname(process.execPath),
  162. })).toBe(process.execPath)
  163. expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
  164. PATH: relative(process.cwd(), dirname(process.execPath)) || '.',
  165. })).toBe(process.execPath)
  166. await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
  167. await expect(ctx.subprocess.resolveExecutable('./bin/tsserver'))
  168. .rejects.toThrow('is a relative path')
  169. await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server'))
  170. .rejects.toThrow('is a relative path')
  171. await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
  172. .rejects.toThrow('was not found on PATH')
  173. await expect(ctx.subprocess.resolveExecutable('/dsh-absolute-command-that-does-not-exist'))
  174. .rejects.toThrow('is not an executable file')
  175. await expect(ctx.subprocess.resolveExecutable(process.cwd()))
  176. .rejects.toThrow('is not an executable file')
  177. await expect(ctx.subprocess.resolveExecutable(process.execPath, {}, AbortSignal.abort('stop')))
  178. .rejects.toBe('stop')
  179. await fiber.dispose()
  180. })
  181. it('builds Windows executable candidates with case-insensitive overrides', async () => {
  182. const ctx = new Context()
  183. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  184. const service = ctx.subprocess as LocalSubprocessRuntime
  185. const candidates = (service as unknown as {
  186. executableCandidates(command: string, env: NodeJS.ProcessEnv): string[]
  187. }).executableCandidates.bind(service)
  188. const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  189. try {
  190. expect(Object.keys(childEnv()).filter(key => key.toUpperCase() === 'PATH')).toHaveLength(1)
  191. const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' })
  192. expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path'])
  193. expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt'])
  194. expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')])
  195. expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' }))
  196. .toEqual([resolve('/explicit', 'tool.EXE')])
  197. expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')])
  198. expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
  199. await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`))
  200. .rejects.toThrow('is a relative path')
  201. } finally {
  202. platform.mockRestore()
  203. await fiber.dispose()
  204. }
  205. })
  206. it('validates terminal allocation inputs before allocating a PTY', async () => {
  207. const ctx = new Context()
  208. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  209. const base: SubprocessTerminalSpawnSpec = {
  210. argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
  211. }
  212. await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
  213. await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
  214. await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
  215. await fiber.dispose()
  216. })
  217. it('terminates and joins an owned terminal during disposal', async () => {
  218. const ctx = new Context()
  219. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  220. const terminate = vi.fn(async () => {})
  221. const terminal: SubprocessTerminalHandle = {
  222. pid: 1,
  223. output: new PassThrough(),
  224. done: Promise.resolve({ exitCode: 0, signal: null }),
  225. write: async () => {},
  226. inspectForeground: async () => undefined,
  227. signalForeground: async () => 1,
  228. terminate,
  229. }
  230. const terminals = (ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  231. terminals.add(terminal)
  232. await fiber.dispose()
  233. expect(terminate).toHaveBeenCalledOnce()
  234. expect(terminals.size).toBe(0)
  235. })
  236. it('waits for every terminal cleanup and aggregates teardown failures', async () => {
  237. const before = new Set(process.listeners('exit'))
  238. const ctx = new Context()
  239. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  240. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  241. expect(listener).toBeTypeOf('function')
  242. const service = ctx.subprocess
  243. const firstFailure = new Error('first cleanup failure')
  244. const secondFailure = new Error('second cleanup failure')
  245. const disposalErrors: unknown[] = []
  246. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  247. const failedTerminal: SubprocessTerminalHandle = {
  248. pid: 1,
  249. output: new PassThrough(),
  250. done: Promise.resolve({ exitCode: 0, signal: null }),
  251. write: async () => {},
  252. inspectForeground: async () => undefined,
  253. signalForeground: async () => 1,
  254. terminate: vi.fn(async () => { throw firstFailure }),
  255. }
  256. const secondFailedTerminal: SubprocessTerminalHandle = {
  257. ...failedTerminal,
  258. terminate: vi.fn(async () => { throw secondFailure }),
  259. }
  260. let finishCleanup!: () => void
  261. const cleanup = new Promise<void>((resolve) => {
  262. finishCleanup = resolve
  263. })
  264. const drainingTerminal: SubprocessTerminalHandle = {
  265. ...failedTerminal,
  266. terminate: vi.fn(() => cleanup),
  267. }
  268. const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  269. terminals.add(failedTerminal)
  270. terminals.add(secondFailedTerminal)
  271. terminals.add(drainingTerminal)
  272. let disposed = false
  273. const disposing = fiber.dispose().then(() => { disposed = true })
  274. await new Promise(resolve => setImmediate(resolve))
  275. expect(disposed).toBe(false)
  276. finishCleanup()
  277. await disposing
  278. expect(terminals).toEqual(new Set([failedTerminal, secondFailedTerminal]))
  279. expect(disposalErrors).toHaveLength(1)
  280. expect(disposalErrors[0]).toMatchObject({
  281. errors: [firstFailure, secondFailure],
  282. message: 'local subprocess teardown failed',
  283. })
  284. expect(process.listeners('exit')).toContain(listener)
  285. if (listener !== undefined) process.off('exit', listener)
  286. })
  287. it('reports one cleanup failure without wrapping it', async () => {
  288. const before = new Set(process.listeners('exit'))
  289. const ctx = new Context()
  290. const failure = new Error('single cleanup failure')
  291. const disposalErrors: unknown[] = []
  292. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  293. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  294. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  295. expect(listener).toBeTypeOf('function')
  296. const service = ctx.subprocess
  297. const terminal: SubprocessTerminalHandle = {
  298. pid: 1,
  299. output: new PassThrough(),
  300. done: Promise.resolve({ exitCode: 0, signal: null }),
  301. write: async () => {},
  302. inspectForeground: async () => undefined,
  303. signalForeground: async () => 1,
  304. terminate: vi.fn(async () => { throw failure }),
  305. }
  306. const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  307. terminals.add(terminal)
  308. await fiber.dispose()
  309. expect(disposalErrors).toEqual([failure])
  310. expect(terminals.has(terminal)).toBe(true)
  311. expect(process.listeners('exit')).toContain(listener)
  312. if (listener !== undefined) process.off('exit', listener)
  313. })
  314. it('force-terminates and retains failed disposal targets for host exit', async () => {
  315. const before = new Set(process.listeners('exit'))
  316. const ctx = new Context()
  317. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  318. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  319. expect(listener).toBeTypeOf('function')
  320. const failure = new Error('cleanup failed')
  321. const terminateForHostExit = vi.fn(() => {
  322. expect(process.listeners('exit')).toContain(listener)
  323. })
  324. const terminal = {
  325. terminate: vi.fn(async () => { throw failure }),
  326. terminateForHostExit,
  327. }
  328. const terminals = (ctx.subprocess as unknown as { terminals: Set<typeof terminal> }).terminals
  329. terminals.add(terminal)
  330. await fiber.dispose()
  331. expect(terminateForHostExit).toHaveBeenCalledOnce()
  332. expect(terminals.size).toBe(1)
  333. expect(process.listeners('exit')).toContain(listener)
  334. listener?.(0)
  335. expect(terminateForHostExit).toHaveBeenCalledTimes(2)
  336. if (listener !== undefined) process.off('exit', listener)
  337. })
  338. it('releases a terminal after top-level exit reaches quiescence', async () => {
  339. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  340. const inspector = {
  341. foregroundPgid: () => undefined,
  342. isStdinWaiting: () => false,
  343. snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }),
  344. isAlive: () => false,
  345. signalGroup: () => {},
  346. signalProcess: () => {},
  347. }
  348. const terminal = {
  349. pid: 123,
  350. onData: () => ({ dispose: () => {} }),
  351. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  352. exitListener = listener
  353. return { dispose: () => {} }
  354. },
  355. write: () => {},
  356. kill: () => {},
  357. }
  358. vi.resetModules()
  359. mockWin32ForIsolatedRuntime()
  360. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  361. vi.doMock('../src/process-inspector.ts', async importOriginal => ({
  362. ...await importOriginal<typeof import('../src/process-inspector.ts')>(),
  363. createProcessInspector: () => inspector,
  364. }))
  365. try {
  366. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  367. const ctx = new Context()
  368. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  369. const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  370. const handle = await ctx.subprocess.spawnTerminal({
  371. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  372. })
  373. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  374. exitListener?.({ exitCode: 0 })
  375. await handle.done
  376. await new Promise(resolve => setImmediate(resolve))
  377. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(0)
  378. await fiber.dispose()
  379. } finally {
  380. vi.doUnmock('node-pty')
  381. vi.doUnmock('../src/process-inspector.ts')
  382. unmockWin32ForIsolatedRuntime()
  383. vi.resetModules()
  384. }
  385. })
  386. it('wraps Linux terminals in the selected scope and binds owner liveness', async () => {
  387. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  388. let launcherRunning: (() => boolean) | undefined
  389. let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => void) | undefined
  390. const terminalKill = vi.fn(() => { throw new Error('terminal already exited') })
  391. const terminal = {
  392. pid: 123,
  393. onData: () => ({ dispose: () => {} }),
  394. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  395. exitListener = listener
  396. return { dispose: () => {} }
  397. },
  398. write: () => {},
  399. kill: terminalKill,
  400. }
  401. const nodePtySpawn = vi.fn(() => terminal)
  402. const owner = {
  403. signal: vi.fn(),
  404. waitForExit: vi.fn(async () => {}),
  405. terminateForHostExit: vi.fn(),
  406. }
  407. const launcherStates: boolean[] = []
  408. const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): void }) => {
  409. launcherRunning = () => direct.running()
  410. launcherSignal = (signal) => { direct.signal(signal) }
  411. launcherStates.push(direct.running())
  412. return owner
  413. })
  414. const prepareLinuxTerminalScope = vi.fn(() => ({
  415. command: '/usr/bin/systemd-run',
  416. args: ['--user', '--scope', '--quiet', '--collect', '--', '/usr/bin/node', '/runner.js', '--', 'shell', '--literal'],
  417. cwd: '/bootstrap',
  418. env: { BOOTSTRAP: 'yes' },
  419. bindOwner,
  420. resolveOutcome: (outcome: unknown) => outcome,
  421. cleanup: vi.fn(),
  422. }))
  423. const probeLinuxNative = vi.fn(() => true)
  424. const probeLinuxManager = vi.fn(() => true)
  425. const inspector = {
  426. foregroundPgid: () => undefined,
  427. isStdinWaiting: () => false,
  428. snapshot: () => ({
  429. tree: () => [{ pid: 123, started: 'shell' }],
  430. session: () => [],
  431. alive: () => false,
  432. }),
  433. isAlive: () => false,
  434. signalGroup: () => {},
  435. signalProcess: () => {},
  436. }
  437. vi.resetModules()
  438. mockWin32ForIsolatedRuntime()
  439. vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
  440. vi.doMock('../src/linux-scope.ts', () => ({
  441. launchLinuxScope: vi.fn(),
  442. prepareLinuxTerminalScope,
  443. probeLinuxManager,
  444. probeLinuxNative,
  445. }))
  446. let fiber: { dispose(): Promise<void> } | undefined
  447. try {
  448. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  449. const ctx = new Context()
  450. fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  451. const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  452. runtime.internals = { platform: 'linux' }
  453. runtime.terminalInspector = inspector
  454. const targetCwd = process.cwd()
  455. const handle = await runtime.spawnTerminal({
  456. argv: ['shell', '--literal'],
  457. cwd: targetCwd,
  458. rows: 24,
  459. cols: 80,
  460. graceMs: 10,
  461. env: { PWD: '/stale-parent-cwd', TERM: 'xterm-256color', TARGET_VALUE: 'preserved' },
  462. })
  463. expect(probeLinuxNative).toHaveBeenCalledOnce()
  464. expect(prepareLinuxTerminalScope).toHaveBeenCalledWith(
  465. expect.objectContaining({ argv: ['shell', '--literal'] }),
  466. expect.objectContaining({ PWD: targetCwd, TERM: 'dumb', TARGET_VALUE: 'preserved' }),
  467. )
  468. expect(nodePtySpawn).toHaveBeenCalledWith(
  469. '/usr/bin/systemd-run',
  470. ['--user', '--scope', '--quiet', '--collect', '--', '/usr/bin/node', '/runner.js', '--', 'shell', '--literal'],
  471. expect.objectContaining({ rows: 24, cols: 80, cwd: '/bootstrap', env: { BOOTSTRAP: 'yes' } }),
  472. )
  473. expect(bindOwner).toHaveBeenCalledOnce()
  474. expect(launcherStates).toEqual([true])
  475. expect(launcherRunning?.()).toBe(true)
  476. expect(() => { launcherSignal?.('SIGTERM') }).not.toThrow()
  477. expect(terminalKill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  478. exitListener?.({ exitCode: 0 })
  479. expect(launcherRunning?.()).toBe(false)
  480. await handle.done
  481. await new Promise(resolve => setImmediate(resolve))
  482. expect(owner.signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  483. expect(owner.waitForExit).toHaveBeenCalledOnce()
  484. } finally {
  485. await fiber?.dispose()
  486. vi.doUnmock('node-pty')
  487. vi.doUnmock('../src/linux-scope.ts')
  488. unmockWin32ForIsolatedRuntime()
  489. vi.resetModules()
  490. }
  491. })
  492. it('cleans the Linux terminal launch protocol when node-pty throws synchronously', async () => {
  493. const launchFailure = new Error('node-pty launch failed')
  494. const cleanup = vi.fn()
  495. const nodePtySpawn = vi.fn(() => { throw launchFailure })
  496. const prepareLinuxTerminalScope = vi.fn(() => ({
  497. command: '/usr/bin/systemd-run',
  498. args: ['--user', '--scope', '--', 'shell'],
  499. cwd: '/bootstrap',
  500. env: { BOOTSTRAP: 'yes' },
  501. bindOwner: vi.fn(),
  502. resolveOutcome: (outcome: unknown) => outcome,
  503. cleanup,
  504. }))
  505. const inspector = {
  506. foregroundPgid: () => undefined,
  507. isStdinWaiting: () => false,
  508. snapshot: () => ({
  509. tree: () => [{ pid: 123, started: 'shell' }],
  510. session: () => [],
  511. alive: () => false,
  512. }),
  513. isAlive: () => false,
  514. signalGroup: () => {},
  515. signalProcess: () => {},
  516. }
  517. vi.resetModules()
  518. mockWin32ForIsolatedRuntime()
  519. vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
  520. vi.doMock('../src/linux-scope.ts', () => ({
  521. launchLinuxScope: vi.fn(),
  522. prepareLinuxTerminalScope,
  523. probeLinuxManager: () => true,
  524. probeLinuxNative: () => true,
  525. }))
  526. let fiber: { dispose(): Promise<void> } | undefined
  527. try {
  528. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  529. const ctx = new Context()
  530. fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  531. const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  532. runtime.internals = { platform: 'linux' }
  533. runtime.terminalInspector = inspector
  534. await expect(runtime.spawnTerminal({
  535. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
  536. })).rejects.toBe(launchFailure)
  537. expect(cleanup).toHaveBeenCalledOnce()
  538. } finally {
  539. await fiber?.dispose()
  540. vi.doUnmock('node-pty')
  541. vi.doUnmock('../src/linux-scope.ts')
  542. unmockWin32ForIsolatedRuntime()
  543. vi.resetModules()
  544. }
  545. })
  546. it('retains a terminal whose automatic cleanup fails', async () => {
  547. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  548. const terminal = {
  549. pid: 123,
  550. onData: () => ({ dispose: () => {} }),
  551. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  552. exitListener = listener
  553. return { dispose: () => {} }
  554. },
  555. write: () => {},
  556. kill: () => {},
  557. }
  558. vi.resetModules()
  559. mockWin32ForIsolatedRuntime()
  560. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  561. try {
  562. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  563. const ctx = new Context()
  564. const disposalErrors: unknown[] = []
  565. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  566. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  567. const alive = new Set([124])
  568. ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
  569. foregroundPgid: () => 123,
  570. isStdinWaiting: () => false,
  571. snapshot: () => ({
  572. tree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
  573. session: () => [],
  574. alive: identity => alive.has(identity.pid),
  575. }),
  576. isAlive: identity => alive.has(identity.pid),
  577. signalGroup: () => {},
  578. signalProcess: () => {},
  579. }
  580. const handle = await ctx.subprocess.spawnTerminal({
  581. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  582. })
  583. exitListener?.({ exitCode: 0 })
  584. await handle.done
  585. await new Promise(resolve => setTimeout(resolve, 10))
  586. expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  587. await fiber.dispose()
  588. expect(disposalErrors).toHaveLength(1)
  589. } finally {
  590. vi.doUnmock('node-pty')
  591. unmockWin32ForIsolatedRuntime()
  592. vi.resetModules()
  593. }
  594. })
  595. it('registers as ctx.subprocess and spawns managed handles', async () => {
  596. const ctx = new Context()
  597. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  598. const handle = ctx.subprocess.spawn(spec('echo managed'))
  599. expect(handle).not.toHaveProperty('pid')
  600. const result = await handle.done
  601. expect(result.exitCode).toBe(0)
  602. expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
  603. await fiber.dispose()
  604. })
  605. it('warns once when ordinary spawns use the weaker macOS fallback', async () => {
  606. const ctx = new Context()
  607. const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  608. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  609. const runtime = ctx.subprocess as LocalSubprocessRuntime
  610. runtime.internals = { platform: 'darwin' }
  611. try {
  612. const first = runtime.spawn(spec('true'))
  613. const second = runtime.spawn(spec('true'))
  614. await Promise.all([first.done, second.done])
  615. expect(warning).toHaveBeenCalledOnce()
  616. expect(warning).toHaveBeenCalledWith(
  617. expect.stringContaining('descendants that escape the process group'),
  618. )
  619. } finally {
  620. warning.mockRestore()
  621. await fiber.dispose()
  622. }
  623. })
  624. it('reports the platform-specific reason for every fallback mode', async () => {
  625. for (const [platform, kind, reason, selectedReason] of [
  626. ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner', undefined],
  627. ['linux', 'ordinary', 'the private Linux subprocess runner is unavailable', 'the private Linux subprocess runner is unavailable'],
  628. ['win32', 'ordinary', 'the Win32 Job runner is unavailable', undefined],
  629. ['win32', 'terminal', 'Windows ConPTY remains outside Job containment', undefined],
  630. ['freebsd', 'ordinary', 'platform freebsd has no native managed range', undefined],
  631. ] as const) {
  632. const ctx = new Context()
  633. const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  634. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  635. const runtime = ctx.subprocess as unknown as {
  636. warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal', selectedReason?: string): void
  637. }
  638. try {
  639. runtime.warnFallback(platform, kind, selectedReason)
  640. expect(warning).toHaveBeenLastCalledWith(
  641. expect.stringContaining(reason),
  642. )
  643. } finally {
  644. warning.mockRestore()
  645. await fiber.dispose()
  646. }
  647. }
  648. })
  649. it('rechecks native prerequisites for every eligible spawn and prepares storage before launch', async () => {
  650. const linuxLaunch = { kind: 'linux' }
  651. const windowsLaunch = { kind: 'windows' }
  652. const launchLinuxScope = vi.fn(() => linuxLaunch)
  653. const launchWindowsJob = vi.fn(() => windowsLaunch)
  654. const probeLinuxNative = vi.fn(() => true)
  655. const probeLinuxManager = vi.fn(() => true)
  656. const probeWindowsJob = vi.fn(() => true)
  657. const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' }))
  658. const handles = [true, false, false].map((failFirstWait) => {
  659. let waits = 0
  660. return {
  661. collected: {},
  662. done: Promise.resolve({ exitCode: 0, signal: null }),
  663. terminate: vi.fn(),
  664. terminateForHostExit: vi.fn(),
  665. waitForExit: vi.fn(async () => {
  666. waits += 1
  667. if (failFirstWait && waits === 1) throw new Error('release observation failed')
  668. return true
  669. }),
  670. }
  671. })
  672. const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown, _binding: unknown) => {
  673. const handle = handles.shift()
  674. if (handle === undefined) throw new Error('missing fake handle')
  675. return handle
  676. })
  677. const spawnSubprocess = vi.fn()
  678. vi.resetModules()
  679. mockWin32ForIsolatedRuntime()
  680. vi.doMock('../src/linux-scope.ts', () => ({
  681. launchLinuxScope,
  682. prepareLinuxTerminalScope: vi.fn(),
  683. probeLinuxManager,
  684. probeLinuxNative,
  685. }))
  686. vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob }))
  687. vi.doMock('../src/spawn.ts', async importOriginal => ({
  688. ...await importOriginal<typeof import('../src/spawn.ts')>(),
  689. bindManagedProcess,
  690. prepareManagedProcessBinding,
  691. spawnSubprocess,
  692. }))
  693. const fibers: Array<{ dispose(): Promise<void> }> = []
  694. try {
  695. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  696. const linuxContext = new Context()
  697. const linuxFiber = await linuxContext.plugin(IsolatedLocalSubprocessRuntime)
  698. fibers.push(linuxFiber)
  699. const linuxRuntime = linuxContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  700. linuxRuntime.internals = { platform: 'linux' }
  701. const preparationFailure = new Error('spill directory unavailable')
  702. prepareManagedProcessBinding.mockImplementationOnce(() => { throw preparationFailure })
  703. expect(() => linuxRuntime.spawn(spec('true'))).toThrow(preparationFailure)
  704. expect(launchLinuxScope).not.toHaveBeenCalled()
  705. await linuxRuntime.spawn(spec('true')).done
  706. await new Promise(resolve => setImmediate(resolve))
  707. await linuxRuntime.spawn(spec('true')).done
  708. await new Promise(resolve => setImmediate(resolve))
  709. expect(probeLinuxNative).toHaveBeenCalledOnce()
  710. expect(probeLinuxManager).toHaveBeenCalledTimes(2)
  711. expect(launchLinuxScope).toHaveBeenCalledTimes(2)
  712. const windowsContext = new Context()
  713. const windowsFiber = await windowsContext.plugin(IsolatedLocalSubprocessRuntime)
  714. fibers.push(windowsFiber)
  715. const windowsRuntime = windowsContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  716. windowsRuntime.internals = { platform: 'win32' }
  717. await windowsRuntime.spawn(spec('true')).done
  718. await new Promise(resolve => setImmediate(resolve))
  719. expect(probeWindowsJob).toHaveBeenCalledOnce()
  720. expect(launchWindowsJob).toHaveBeenCalledOnce()
  721. expect(bindManagedProcess.mock.calls.map(([, launch]) => launch)).toEqual([
  722. linuxLaunch,
  723. linuxLaunch,
  724. windowsLaunch,
  725. ])
  726. expect(prepareManagedProcessBinding).toHaveBeenCalledTimes(4)
  727. expect(spawnSubprocess).not.toHaveBeenCalled()
  728. } finally {
  729. for (const fiber of fibers.reverse()) await fiber.dispose()
  730. vi.doUnmock('../src/linux-scope.ts')
  731. vi.doUnmock('../src/windows-job.ts')
  732. vi.doUnmock('../src/spawn.ts')
  733. unmockWin32ForIsolatedRuntime()
  734. vi.resetModules()
  735. }
  736. })
  737. it('retries failed Linux deep probes, caches the first success, and rechecks the manager', async () => {
  738. const probeLinuxNative = vi.fn()
  739. .mockReturnValueOnce(false)
  740. .mockReturnValueOnce(false)
  741. .mockReturnValueOnce(false)
  742. .mockReturnValueOnce(true)
  743. const probeLinuxManager = vi.fn()
  744. .mockReturnValueOnce(false)
  745. .mockReturnValueOnce(true)
  746. const probeWindowsJob = vi.fn()
  747. .mockReturnValueOnce(false)
  748. .mockReturnValueOnce(true)
  749. .mockReturnValueOnce(true)
  750. vi.resetModules()
  751. mockWin32ForIsolatedRuntime()
  752. vi.doMock('../src/linux-scope.ts', () => ({
  753. launchLinuxScope: vi.fn(),
  754. prepareLinuxTerminalScope: vi.fn(),
  755. probeLinuxManager,
  756. probeLinuxNative,
  757. }))
  758. vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob }))
  759. const fibers: Array<{ dispose(): Promise<void> }> = []
  760. try {
  761. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  762. const linuxContext = new Context()
  763. vi.spyOn(linuxContext.logger, 'warn').mockImplementation(() => {})
  764. const linuxFiber = await linuxContext.plugin(IsolatedLocalSubprocessRuntime)
  765. fibers.push(linuxFiber)
  766. const linuxRuntime = linuxContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  767. linuxRuntime.internals = { platform: 'linux' }
  768. const linuxSelect = (linuxRuntime as unknown as {
  769. selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback'
  770. }).selectContainmentMode.bind(linuxRuntime)
  771. expect(linuxSelect('ordinary')).toBe('fallback')
  772. expect(linuxSelect('ordinary')).toBe('fallback')
  773. expect(linuxSelect('ordinary')).toBe('fallback')
  774. expect(linuxSelect('ordinary')).toBe('linux-scope')
  775. expect(linuxSelect('ordinary')).toBe('fallback')
  776. expect(linuxSelect('ordinary')).toBe('linux-scope')
  777. expect(probeLinuxNative).toHaveBeenCalledTimes(4)
  778. expect(probeLinuxManager).toHaveBeenCalledTimes(2)
  779. const windowsContext = new Context()
  780. vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {})
  781. const windowsFiber = await windowsContext.plugin(IsolatedLocalSubprocessRuntime)
  782. fibers.push(windowsFiber)
  783. const windowsRuntime = windowsContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  784. windowsRuntime.internals = { platform: 'win32' }
  785. const windowsSelect = (windowsRuntime as unknown as {
  786. selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback'
  787. }).selectContainmentMode.bind(windowsRuntime)
  788. expect(windowsSelect('ordinary')).toBe('fallback')
  789. expect(windowsSelect('ordinary')).toBe('windows-job')
  790. expect(windowsSelect('ordinary')).toBe('windows-job')
  791. expect(probeWindowsJob).toHaveBeenCalledTimes(3)
  792. } finally {
  793. for (const fiber of fibers.reverse()) await fiber.dispose()
  794. vi.doUnmock('../src/linux-scope.ts')
  795. vi.doUnmock('../src/windows-job.ts')
  796. unmockWin32ForIsolatedRuntime()
  797. vi.resetModules()
  798. }
  799. })
  800. it('disposal kills still-running processes and awaits their exit', async () => {
  801. const ctx = new Context()
  802. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  803. const handle = ctx.subprocess.spawn(spec('sleep 60'))
  804. await fiber.dispose()
  805. const outcome = await handle.done
  806. // Windows teardown terminates through taskkill, which reports no signal.
  807. expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
  808. })
  809. it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
  810. const ctx = new Context()
  811. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  812. const handle = ctx.subprocess.spawn(spec('true'))
  813. const outcome = await handle.done
  814. expect(outcome.exitCode).toBe(0)
  815. await fiber.dispose()
  816. })
  817. it('disposal tolerates a handle whose spawn already failed', async () => {
  818. const ctx = new Context()
  819. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  820. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  821. await expect(handle.done).rejects.toThrow()
  822. await fiber.dispose()
  823. })
  824. it('disposal contains a spawn-failure rejection that races teardown', async () => {
  825. const ctx = new Context()
  826. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  827. // Dispose before the rejection continuation removes the handle from the
  828. // live set, so teardown itself must swallow the rejected done.
  829. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  830. await fiber.dispose()
  831. await expect(handle.done).rejects.toThrow()
  832. })
  833. it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
  834. const ctx = new Context()
  835. await ctx.plugin(LocalSubprocessRuntime)
  836. class SecondManager extends LocalSubprocessRuntime {}
  837. await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
  838. })
  839. })