Files
orca/src/main/git/command-runner/git-exec-admission-lifetime.test.ts
T

206 lines
7.5 KiB
TypeScript

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<string, unknown>
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<string, ChildProcess>()
const callbacks = new Map<string, ExecCallback>()
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)
})
})