local.spec.ts 45 KB

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