local.spec.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913
  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. // Pins the containment choice: with the host's native scope a mocked PTY
  371. // exit races the scope bootstrap.
  372. service.internals = { platform: 'darwin' }
  373. const handle = await ctx.subprocess.spawnTerminal({
  374. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  375. })
  376. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  377. exitListener?.({ exitCode: 0 })
  378. await handle.done
  379. await expect.poll(() => (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(0)
  380. await fiber.dispose()
  381. } finally {
  382. vi.doUnmock('node-pty')
  383. vi.doUnmock('../src/process-inspector.ts')
  384. unmockWin32ForIsolatedRuntime()
  385. vi.resetModules()
  386. }
  387. })
  388. it('wraps Linux terminals in the selected scope and binds owner liveness', async () => {
  389. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  390. let launcherRunning: (() => boolean) | undefined
  391. let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => void) | undefined
  392. const terminalKill = vi.fn(() => { throw new Error('terminal already exited') })
  393. const terminal = {
  394. pid: 123,
  395. onData: () => ({ dispose: () => {} }),
  396. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  397. exitListener = listener
  398. return { dispose: () => {} }
  399. },
  400. write: () => {},
  401. kill: terminalKill,
  402. }
  403. const nodePtySpawn = vi.fn(() => terminal)
  404. const owner = {
  405. signal: vi.fn(),
  406. waitForExit: vi.fn(async () => {}),
  407. terminateForHostExit: vi.fn(),
  408. }
  409. const launcherStates: boolean[] = []
  410. const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): void }) => {
  411. launcherRunning = () => direct.running()
  412. launcherSignal = (signal) => { direct.signal(signal) }
  413. launcherStates.push(direct.running())
  414. return owner
  415. })
  416. const prepareLinuxTerminalScope = vi.fn(() => ({
  417. command: '/usr/bin/systemd-run',
  418. args: ['--user', '--scope', '--quiet', '--collect', '--', '/usr/bin/node', '/runner.js', '--', 'shell', '--literal'],
  419. cwd: '/bootstrap',
  420. env: { BOOTSTRAP: 'yes' },
  421. bindOwner,
  422. resolveOutcome: (outcome: unknown) => outcome,
  423. cleanup: vi.fn(),
  424. }))
  425. const probeLinuxNative = vi.fn(() => true)
  426. const probeLinuxManager = vi.fn(() => true)
  427. const inspector = {
  428. foregroundPgid: () => undefined,
  429. isStdinWaiting: () => false,
  430. snapshot: () => ({
  431. tree: () => [{ pid: 123, started: 'shell' }],
  432. session: () => [],
  433. alive: () => false,
  434. }),
  435. isAlive: () => false,
  436. signalGroup: () => {},
  437. signalProcess: () => {},
  438. }
  439. vi.resetModules()
  440. mockWin32ForIsolatedRuntime()
  441. vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
  442. vi.doMock('../src/linux-scope.ts', () => ({
  443. launchLinuxScope: vi.fn(),
  444. prepareLinuxTerminalScope,
  445. probeLinuxManager,
  446. probeLinuxNative,
  447. }))
  448. let fiber: { dispose(): Promise<void> } | undefined
  449. try {
  450. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  451. const ctx = new Context()
  452. fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  453. const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  454. runtime.internals = { platform: 'linux' }
  455. runtime.terminalInspector = inspector
  456. const targetCwd = process.cwd()
  457. const handle = await runtime.spawnTerminal({
  458. argv: ['shell', '--literal'],
  459. cwd: targetCwd,
  460. rows: 24,
  461. cols: 80,
  462. graceMs: 10,
  463. env: { PWD: '/stale-parent-cwd', TERM: 'xterm-256color', TARGET_VALUE: 'preserved' },
  464. })
  465. expect(probeLinuxNative).toHaveBeenCalledOnce()
  466. expect(prepareLinuxTerminalScope).toHaveBeenCalledWith(
  467. expect.objectContaining({ argv: ['shell', '--literal'] }),
  468. expect.objectContaining({ PWD: targetCwd, TERM: 'dumb', TARGET_VALUE: 'preserved' }),
  469. )
  470. expect(nodePtySpawn).toHaveBeenCalledWith(
  471. '/usr/bin/systemd-run',
  472. ['--user', '--scope', '--quiet', '--collect', '--', '/usr/bin/node', '/runner.js', '--', 'shell', '--literal'],
  473. expect.objectContaining({ rows: 24, cols: 80, cwd: '/bootstrap', env: { BOOTSTRAP: 'yes' } }),
  474. )
  475. expect(bindOwner).toHaveBeenCalledOnce()
  476. expect(launcherStates).toEqual([true])
  477. expect(launcherRunning?.()).toBe(true)
  478. expect(() => { launcherSignal?.('SIGTERM') }).not.toThrow()
  479. expect(terminalKill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  480. exitListener?.({ exitCode: 0 })
  481. expect(launcherRunning?.()).toBe(false)
  482. await handle.done
  483. await new Promise(resolve => setImmediate(resolve))
  484. expect(owner.signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  485. expect(owner.waitForExit).toHaveBeenCalledOnce()
  486. } finally {
  487. await fiber?.dispose()
  488. vi.doUnmock('node-pty')
  489. vi.doUnmock('../src/linux-scope.ts')
  490. unmockWin32ForIsolatedRuntime()
  491. vi.resetModules()
  492. }
  493. })
  494. it('cleans the Linux terminal launch protocol when node-pty throws synchronously', async () => {
  495. const launchFailure = new Error('node-pty launch failed')
  496. const cleanup = vi.fn()
  497. const nodePtySpawn = vi.fn(() => { throw launchFailure })
  498. const prepareLinuxTerminalScope = vi.fn(() => ({
  499. command: '/usr/bin/systemd-run',
  500. args: ['--user', '--scope', '--', 'shell'],
  501. cwd: '/bootstrap',
  502. env: { BOOTSTRAP: 'yes' },
  503. bindOwner: vi.fn(),
  504. resolveOutcome: (outcome: unknown) => outcome,
  505. cleanup,
  506. }))
  507. const inspector = {
  508. foregroundPgid: () => undefined,
  509. isStdinWaiting: () => false,
  510. snapshot: () => ({
  511. tree: () => [{ pid: 123, started: 'shell' }],
  512. session: () => [],
  513. alive: () => false,
  514. }),
  515. isAlive: () => false,
  516. signalGroup: () => {},
  517. signalProcess: () => {},
  518. }
  519. vi.resetModules()
  520. mockWin32ForIsolatedRuntime()
  521. vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
  522. vi.doMock('../src/linux-scope.ts', () => ({
  523. launchLinuxScope: vi.fn(),
  524. prepareLinuxTerminalScope,
  525. probeLinuxManager: () => true,
  526. probeLinuxNative: () => true,
  527. }))
  528. let fiber: { dispose(): Promise<void> } | undefined
  529. try {
  530. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  531. const ctx = new Context()
  532. fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  533. const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  534. runtime.internals = { platform: 'linux' }
  535. runtime.terminalInspector = inspector
  536. await expect(runtime.spawnTerminal({
  537. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
  538. })).rejects.toBe(launchFailure)
  539. expect(cleanup).toHaveBeenCalledOnce()
  540. } finally {
  541. await fiber?.dispose()
  542. vi.doUnmock('node-pty')
  543. vi.doUnmock('../src/linux-scope.ts')
  544. unmockWin32ForIsolatedRuntime()
  545. vi.resetModules()
  546. }
  547. })
  548. it('retains a terminal whose automatic cleanup fails', async () => {
  549. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  550. const terminal = {
  551. pid: 123,
  552. onData: () => ({ dispose: () => {} }),
  553. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  554. exitListener = listener
  555. return { dispose: () => {} }
  556. },
  557. write: () => {},
  558. kill: () => {},
  559. }
  560. vi.resetModules()
  561. mockWin32ForIsolatedRuntime()
  562. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  563. try {
  564. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  565. const ctx = new Context()
  566. const disposalErrors: unknown[] = []
  567. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  568. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  569. const alive = new Set([124])
  570. // Pins the containment choice: with the host's native scope a mocked PTY
  571. // exit races the scope bootstrap.
  572. ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).internals = { platform: 'darwin' }
  573. ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
  574. foregroundPgid: () => 123,
  575. isStdinWaiting: () => false,
  576. snapshot: () => ({
  577. tree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
  578. session: () => [],
  579. alive: identity => alive.has(identity.pid),
  580. }),
  581. isAlive: identity => alive.has(identity.pid),
  582. signalGroup: () => {},
  583. signalProcess: () => {},
  584. }
  585. const handle = await ctx.subprocess.spawnTerminal({
  586. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  587. })
  588. const terminate = vi.spyOn(handle, 'terminate')
  589. exitListener?.({ exitCode: 0 })
  590. await handle.done
  591. await expect.poll(() => terminate.mock.calls.length).toBe(1)
  592. await expect(terminate.mock.results[0]?.value).rejects.toThrow('surviving pids: 124')
  593. expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  594. await fiber.dispose()
  595. expect(disposalErrors).toHaveLength(1)
  596. } finally {
  597. vi.doUnmock('node-pty')
  598. unmockWin32ForIsolatedRuntime()
  599. vi.resetModules()
  600. }
  601. })
  602. it('registers as ctx.subprocess and spawns managed handles', async () => {
  603. const ctx = new Context()
  604. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  605. const handle = ctx.subprocess.spawn(spec('echo managed'))
  606. expect(handle).not.toHaveProperty('pid')
  607. const result = await handle.done
  608. expect(result.exitCode).toBe(0)
  609. expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
  610. await fiber.dispose()
  611. })
  612. it('warns once when ordinary spawns use the weaker macOS fallback', async () => {
  613. const ctx = new Context()
  614. const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  615. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  616. const runtime = ctx.subprocess as LocalSubprocessRuntime
  617. runtime.internals = { platform: 'darwin' }
  618. try {
  619. const first = runtime.spawn(spec('true'))
  620. const second = runtime.spawn(spec('true'))
  621. await Promise.all([first.done, second.done])
  622. expect(warning).toHaveBeenCalledOnce()
  623. expect(warning).toHaveBeenCalledWith(
  624. expect.stringContaining('descendants that escape the process group'),
  625. )
  626. } finally {
  627. warning.mockRestore()
  628. await fiber.dispose()
  629. }
  630. })
  631. it('reports the platform-specific reason for every fallback mode', async () => {
  632. for (const [platform, kind, reason, selectedReason] of [
  633. ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner', undefined],
  634. ['linux', 'ordinary', 'the private Linux subprocess runner is unavailable', 'the private Linux subprocess runner is unavailable'],
  635. ['win32', 'ordinary', 'the Win32 Job runner is unavailable', undefined],
  636. ['win32', 'terminal', 'Windows ConPTY remains outside Job containment', undefined],
  637. ['freebsd', 'ordinary', 'platform freebsd has no native managed range', undefined],
  638. ] as const) {
  639. const ctx = new Context()
  640. const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  641. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  642. const runtime = ctx.subprocess as unknown as {
  643. warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal', selectedReason?: string): void
  644. }
  645. try {
  646. runtime.warnFallback(platform, kind, selectedReason)
  647. expect(warning).toHaveBeenLastCalledWith(
  648. expect.stringContaining(reason),
  649. )
  650. } finally {
  651. warning.mockRestore()
  652. await fiber.dispose()
  653. }
  654. }
  655. })
  656. it('rechecks native prerequisites for every eligible spawn and prepares storage before launch', async () => {
  657. const linuxLaunch = { kind: 'linux' }
  658. const windowsLaunch = { kind: 'windows' }
  659. const launchLinuxScope = vi.fn(() => linuxLaunch)
  660. const launchWindowsJob = vi.fn(() => windowsLaunch)
  661. const probeLinuxNative = vi.fn(() => true)
  662. const probeLinuxManager = vi.fn(() => true)
  663. const probeWindowsJob = vi.fn(() => true)
  664. const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' }))
  665. const handles = [true, false, false].map((failFirstWait) => {
  666. let waits = 0
  667. return {
  668. collected: {},
  669. done: Promise.resolve({ exitCode: 0, signal: null }),
  670. terminate: vi.fn(),
  671. terminateForHostExit: vi.fn(),
  672. waitForExit: vi.fn(async () => {
  673. waits += 1
  674. if (failFirstWait && waits === 1) throw new Error('release observation failed')
  675. return true
  676. }),
  677. }
  678. })
  679. const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown, _binding: unknown) => {
  680. const handle = handles.shift()
  681. if (handle === undefined) throw new Error('missing fake handle')
  682. return handle
  683. })
  684. const spawnSubprocess = vi.fn()
  685. vi.resetModules()
  686. mockWin32ForIsolatedRuntime()
  687. vi.doMock('../src/linux-scope.ts', () => ({
  688. launchLinuxScope,
  689. prepareLinuxTerminalScope: vi.fn(),
  690. probeLinuxManager,
  691. probeLinuxNative,
  692. }))
  693. vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob }))
  694. vi.doMock('../src/spawn.ts', async importOriginal => ({
  695. ...await importOriginal<typeof import('../src/spawn.ts')>(),
  696. bindManagedProcess,
  697. prepareManagedProcessBinding,
  698. spawnSubprocess,
  699. }))
  700. const fibers: Array<{ dispose(): Promise<void> }> = []
  701. try {
  702. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  703. const linuxContext = new Context()
  704. const linuxFiber = await linuxContext.plugin(IsolatedLocalSubprocessRuntime)
  705. fibers.push(linuxFiber)
  706. const linuxRuntime = linuxContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  707. linuxRuntime.internals = { platform: 'linux' }
  708. const preparationFailure = new Error('spill directory unavailable')
  709. prepareManagedProcessBinding.mockImplementationOnce(() => { throw preparationFailure })
  710. expect(() => linuxRuntime.spawn(spec('true'))).toThrow(preparationFailure)
  711. expect(launchLinuxScope).not.toHaveBeenCalled()
  712. await linuxRuntime.spawn(spec('true')).done
  713. await new Promise(resolve => setImmediate(resolve))
  714. await linuxRuntime.spawn(spec('true')).done
  715. await new Promise(resolve => setImmediate(resolve))
  716. expect(probeLinuxNative).toHaveBeenCalledOnce()
  717. expect(probeLinuxManager).toHaveBeenCalledTimes(2)
  718. expect(launchLinuxScope).toHaveBeenCalledTimes(2)
  719. const windowsContext = new Context()
  720. const windowsFiber = await windowsContext.plugin(IsolatedLocalSubprocessRuntime)
  721. fibers.push(windowsFiber)
  722. const windowsRuntime = windowsContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  723. windowsRuntime.internals = { platform: 'win32' }
  724. await windowsRuntime.spawn(spec('true')).done
  725. await new Promise(resolve => setImmediate(resolve))
  726. expect(probeWindowsJob).toHaveBeenCalledOnce()
  727. expect(launchWindowsJob).toHaveBeenCalledOnce()
  728. expect(bindManagedProcess.mock.calls.map(([, launch]) => launch)).toEqual([
  729. linuxLaunch,
  730. linuxLaunch,
  731. windowsLaunch,
  732. ])
  733. expect(prepareManagedProcessBinding).toHaveBeenCalledTimes(4)
  734. expect(spawnSubprocess).not.toHaveBeenCalled()
  735. } finally {
  736. for (const fiber of fibers.reverse()) await fiber.dispose()
  737. vi.doUnmock('../src/linux-scope.ts')
  738. vi.doUnmock('../src/windows-job.ts')
  739. vi.doUnmock('../src/spawn.ts')
  740. unmockWin32ForIsolatedRuntime()
  741. vi.resetModules()
  742. }
  743. })
  744. it('retries failed Linux deep probes, caches the first success, and rechecks the manager', async () => {
  745. const probeLinuxNative = vi.fn()
  746. .mockReturnValueOnce(false)
  747. .mockReturnValueOnce(false)
  748. .mockReturnValueOnce(false)
  749. .mockReturnValueOnce(true)
  750. const probeLinuxManager = vi.fn()
  751. .mockReturnValueOnce(false)
  752. .mockReturnValueOnce(true)
  753. const probeWindowsJob = vi.fn()
  754. .mockReturnValueOnce(false)
  755. .mockReturnValueOnce(true)
  756. .mockReturnValueOnce(true)
  757. vi.resetModules()
  758. mockWin32ForIsolatedRuntime()
  759. vi.doMock('../src/linux-scope.ts', () => ({
  760. launchLinuxScope: vi.fn(),
  761. prepareLinuxTerminalScope: vi.fn(),
  762. probeLinuxManager,
  763. probeLinuxNative,
  764. }))
  765. vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob }))
  766. const fibers: Array<{ dispose(): Promise<void> }> = []
  767. try {
  768. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  769. const linuxContext = new Context()
  770. vi.spyOn(linuxContext.logger, 'warn').mockImplementation(() => {})
  771. const linuxFiber = await linuxContext.plugin(IsolatedLocalSubprocessRuntime)
  772. fibers.push(linuxFiber)
  773. const linuxRuntime = linuxContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  774. linuxRuntime.internals = { platform: 'linux' }
  775. const linuxSelect = (linuxRuntime as unknown as {
  776. selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback'
  777. }).selectContainmentMode.bind(linuxRuntime)
  778. expect(linuxSelect('ordinary')).toBe('fallback')
  779. expect(linuxSelect('ordinary')).toBe('fallback')
  780. expect(linuxSelect('ordinary')).toBe('fallback')
  781. expect(linuxSelect('ordinary')).toBe('linux-scope')
  782. expect(linuxSelect('ordinary')).toBe('fallback')
  783. expect(linuxSelect('ordinary')).toBe('linux-scope')
  784. expect(probeLinuxNative).toHaveBeenCalledTimes(4)
  785. expect(probeLinuxManager).toHaveBeenCalledTimes(2)
  786. const windowsContext = new Context()
  787. vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {})
  788. const windowsFiber = await windowsContext.plugin(IsolatedLocalSubprocessRuntime)
  789. fibers.push(windowsFiber)
  790. const windowsRuntime = windowsContext.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  791. windowsRuntime.internals = { platform: 'win32' }
  792. const windowsSelect = (windowsRuntime as unknown as {
  793. selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback'
  794. }).selectContainmentMode.bind(windowsRuntime)
  795. expect(windowsSelect('ordinary')).toBe('fallback')
  796. expect(windowsSelect('ordinary')).toBe('windows-job')
  797. expect(windowsSelect('ordinary')).toBe('windows-job')
  798. expect(probeWindowsJob).toHaveBeenCalledTimes(3)
  799. } finally {
  800. for (const fiber of fibers.reverse()) await fiber.dispose()
  801. vi.doUnmock('../src/linux-scope.ts')
  802. vi.doUnmock('../src/windows-job.ts')
  803. unmockWin32ForIsolatedRuntime()
  804. vi.resetModules()
  805. }
  806. })
  807. it('disposal kills still-running processes and awaits their exit', async () => {
  808. const ctx = new Context()
  809. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  810. const handle = ctx.subprocess.spawn(spec('sleep 60'))
  811. await fiber.dispose()
  812. const outcome = await handle.done
  813. // Windows teardown terminates through taskkill, which reports no signal.
  814. expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
  815. })
  816. it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
  817. const ctx = new Context()
  818. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  819. const handle = ctx.subprocess.spawn(spec('true'))
  820. const outcome = await handle.done
  821. expect(outcome.exitCode).toBe(0)
  822. await fiber.dispose()
  823. })
  824. it('disposal tolerates a handle whose spawn already failed', async () => {
  825. const ctx = new Context()
  826. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  827. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  828. await expect(handle.done).rejects.toThrow()
  829. await fiber.dispose()
  830. })
  831. it('disposal contains a spawn-failure rejection that races teardown', async () => {
  832. const ctx = new Context()
  833. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  834. // Dispose before the rejection continuation removes the handle from the
  835. // live set, so teardown itself must swallow the rejected done. Two
  836. // settlements are valid and the winner is a race: a bootstrap that
  837. // publishes its pre-exec failure rejects with that failure, and a teardown
  838. // that stops the bootstrap first settles as the requested termination —
  839. // the recorded failure only outranks the stop when it was published before
  840. // the stop landed.
  841. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  842. await fiber.dispose()
  843. const settlement = await handle.done.then(
  844. outcome => ({ kind: 'stopped' as const, outcome }),
  845. (error: unknown) => ({ kind: 'failed' as const, error }),
  846. )
  847. if (settlement.kind === 'failed') {
  848. expect(settlement.error).toBeInstanceOf(Error)
  849. } else {
  850. // Only the Linux scope records a stop this way: the win32 job owner
  851. // rejects a cancelled start and the fallback launcher rejects the ENOENT,
  852. // so neither can produce the stopped branch.
  853. expect(settlement.outcome.signal).toBe('SIGTERM')
  854. }
  855. })
  856. it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
  857. const ctx = new Context()
  858. await ctx.plugin(LocalSubprocessRuntime)
  859. class SecondManager extends LocalSubprocessRuntime {}
  860. await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
  861. })
  862. })