import { EventEmitter } from 'node:events' import type { ChildProcess } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { execFileMock, spawnMock, killSpawnedCommandTreeMock, signalProcessTreeMock, forceTerminateProcessTreeMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), spawnMock: vi.fn(), killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined), signalProcessTreeMock: vi.fn().mockResolvedValue(false), forceTerminateProcessTreeMock: vi.fn().mockResolvedValue(false) })) vi.mock('node:child_process', async (importOriginal) => ({ ...(await importOriginal()), execFile: execFileMock, spawn: spawnMock })) vi.mock('./spawned-command-tree-kill', () => ({ killSpawnedCommandTree: killSpawnedCommandTreeMock })) vi.mock('../../../shared/child-process/process-tree-termination', () => ({ signalProcessTree: signalProcessTreeMock, forceTerminateProcessTree: forceTerminateProcessTreeMock })) import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file' import { execFileCapture } from './exec-file-capture' import { GitAdmissionScheduler, _gitAdmissionSnapshotForTests, _resetGitAdmissionForTests } from './git-subprocess-admission' type ExecCallback = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void function mockChild(pid: number | undefined = 1234): ChildProcess { const child = new EventEmitter() as EventEmitter & Record child.pid = pid child.kill = vi.fn(() => true) child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) child.stdout = new EventEmitter() child.stderr = new EventEmitter() return child as unknown as ChildProcess } describe('git exec admission lifetime', () => { beforeEach(() => { vi.useFakeTimers() execFileMock.mockReset() spawnMock.mockReset() killSpawnedCommandTreeMock.mockClear() _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 })) }) afterEach(() => { vi.useRealTimers() _resetGitAdmissionForTests() }) it('retains the string-exec permit after timeout settlement until close', async () => { const child = mockChild() execFileMock.mockReturnValue(child) const pending = gitExecFileAsync(['status'], { cwd: '/repo', timeout: 10 }) const rejection = expect(pending).rejects.toThrow('timed out') await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(10) await rejection expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) child.emit('close', null, 'SIGKILL') await Promise.resolve() expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) }) it('retains the buffer-exec permit after maxBuffer settlement until close', async () => { const child = mockChild() let callback: ExecCallback | undefined execFileMock.mockImplementation( (_command: string, _args: string[], _options: unknown, received: ExecCallback) => { callback = received return child } ) const pending = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: '/repo' }) await vi.waitFor(() => expect(callback).toBeTypeOf('function')) callback?.(new Error('maxBuffer exceeded'), Buffer.alloc(0), Buffer.alloc(0)) await expect(pending).rejects.toThrow('maxBuffer exceeded') expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) child.emit('close', null, 'SIGTERM') await Promise.resolve() expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) }) it('reports pre-aborted capture as a no-child termination', async () => { const controller = new AbortController() const onChildTerminated = vi.fn() controller.abort() await expect( execFileCapture('git', ['status'], { signal: controller.signal, onChildTerminated }) ).rejects.toMatchObject({ name: 'AbortError' }) expect(execFileMock).not.toHaveBeenCalled() expect(onChildTerminated).toHaveBeenCalledOnce() }) it('releases termination-barrier admission on confirmed close', async () => { const child = mockChild() spawnMock.mockReturnValue(child) const pending = gitExecFileAsync(['status'], { cwd: '/repo', terminationBarrier: true }) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) child.emit('close', 0, null) await expect(pending).resolves.toEqual({ stdout: '', stderr: '' }) expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) }) it('retains barrier admission past bounded settlement until termination is observed', async () => { const child = mockChild() spawnMock.mockReturnValue(child) const pending = gitExecFileAsync(['status'], { cwd: '/repo', terminationBarrier: true, timeout: 10 }) const rejection = expect(pending).rejects.toThrow('timed out') await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(2010) expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) await vi.advanceTimersByTimeAsync(10_000) await rejection expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) child.emit('close', null, 'SIGKILL') await Promise.resolve() expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) }) it('admits interactive fetch before a background fetch can queue on FETCH_HEAD', async () => { _resetGitAdmissionForTests(new GitAdmissionScheduler({ networkCap: 1, networkHeadroom: 1 })) const children = new Map() const callbacks = new Map() execFileMock.mockImplementation( (_command: string, args: string[], _options: unknown, callback: ExecCallback) => { const label = args[1] ?? '' const child = mockChild() children.set(label, child) callbacks.set(label, callback) return child } ) const first = gitExecFileAsync(['fetch', 'first'], { cwd: '/repo', admissionTier: 'background' }) await vi.waitFor(() => expect(callbacks.has('first')).toBe(true)) const background = gitExecFileAsync(['fetch', 'background'], { cwd: '/repo', admissionTier: 'background' }) const interactive = gitExecFileAsync(['fetch', 'interactive'], { cwd: '/repo', admissionTier: 'interactive' }) await vi.waitFor(() => expect(_gitAdmissionSnapshotForTests().queued).toBe(1)) expect([...callbacks.keys()]).toEqual(['first']) callbacks.get('first')?.(null, '', '') await expect(first).resolves.toEqual({ stdout: '', stderr: '' }) await vi.waitFor(() => expect(callbacks.has('interactive')).toBe(true)) expect([...callbacks.keys()]).toEqual(['first', 'interactive']) children.get('first')?.emit('close', 0, null) await Promise.resolve() expect(callbacks.has('background')).toBe(false) callbacks.get('interactive')?.(null, '', '') await expect(interactive).resolves.toEqual({ stdout: '', stderr: '' }) await vi.waitFor(() => expect(callbacks.has('background')).toBe(true)) expect([...callbacks.keys()]).toEqual(['first', 'interactive', 'background']) children.get('interactive')?.emit('close', 0, null) callbacks.get('background')?.(null, '', '') await expect(background).resolves.toEqual({ stdout: '', stderr: '' }) children.get('background')?.emit('close', 0, null) }) })