fix(git): preserve interactive admission invariants

This commit is contained in:
Brennan Benson
2026-08-27 16:01:51 -07:00
parent b98c1d4dad
commit 48bcdf0130
14 changed files with 291 additions and 105 deletions
@@ -130,7 +130,7 @@ describe('git exec admission lifetime', () => {
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('releases barrier admission at bounded unverifiable-exit settlement', async () => {
it('retains barrier admission past bounded settlement until termination is observed', async () => {
const child = mockChild()
spawnMock.mockReturnValue(child)
const pending = gitExecFileAsync(['status'], {
@@ -145,6 +145,61 @@ describe('git exec admission lifetime', () => {
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)
})
})
+13 -6
View File
@@ -104,7 +104,7 @@ async function gitExecFileAsyncUnlocked(
)
: execFileCapture(command.binary, command.args, captureOptions)
}
try {
const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => {
let result: { stdout: string | Buffer; stderr: string | Buffer }
try {
result = await capture(resolved)
@@ -136,6 +136,17 @@ async function gitExecFileAsyncUnlocked(
stdout: readCapturedGitString(stdout as string, resolved),
stderr: stderr as string
}
}
try {
const command = resolveGitFetchHeadCommand(args, options.cwd)
return command.needsLock
? await runWithGitFetchHeadLock(
command.cwd,
options.signal,
runCapturedCommand,
command.gitDir
)
: await runCapturedCommand()
} finally {
const termination = terminationState.current
if (termination) {
@@ -152,11 +163,7 @@ export function gitExecFileAsync(
args: string[],
options: GitExecOptions
): Promise<{ stdout: string; stderr: string }> {
const run = () => gitExecFileAsyncUnlocked(args, options)
const command = resolveGitFetchHeadCommand(args, options.cwd)
return command.needsLock
? runWithGitFetchHeadLock(command.cwd, options.signal, run, command.gitDir)
: run()
return gitExecFileAsyncUnlocked(args, options)
}
/**