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)
}
/**
+1 -62
View File
@@ -9,8 +9,6 @@ import type * as PullRequestContextModule from '../text-generation/pull-request-
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
const mocks = vi.hoisted(() => ({
abortMerge: vi.fn(),
abortRebase: vi.fn(),
checkoutBranch: vi.fn(),
listLocalBranches: vi.fn(),
getStagedCommitContext: vi.fn(),
@@ -26,8 +24,6 @@ const mocks = vi.hoisted(() => ({
vi.mock('../git/status', async () => ({
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
abortMerge: mocks.abortMerge,
abortRebase: mocks.abortRebase,
getStagedCommitContext: mocks.getStagedCommitContext,
getStatus: mocks.getStatus
}))
@@ -97,8 +93,6 @@ function makeCommands(worktreePath: string): RuntimeGitCommands {
describe('RuntimeGitCommands', () => {
beforeEach(() => {
mocks.abortMerge.mockReset()
mocks.abortRebase.mockReset()
mocks.getStagedCommitContext.mockReset()
mocks.getPullRequestDraftContext.mockReset()
mocks.generateCommitMessageFromContext.mockReset()
@@ -120,17 +114,6 @@ describe('RuntimeGitCommands', () => {
}
})
it('aborts a local merge through the resolved worktree', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const commands = makeCommands(worktreePath)
mocks.abortMerge.mockResolvedValue(undefined)
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath, {})
})
// Why: a directory-only ignore rule (`node_modules/`) never matches the shared
// symlink, so Git reports it untracked forever. Runtime/CLI status has to tell
// getStatus which untracked entries are Orca's own (issue #10451); nothing else
@@ -148,6 +131,7 @@ describe('RuntimeGitCommands', () => {
await commands.getRuntimeGitStatus('id:wt-1')
expect(mocks.getStatus).toHaveBeenCalledWith('/workspace/feature', {
admissionTier: 'interactive',
sharedLinkPaths: ['node_modules']
})
})
@@ -170,51 +154,6 @@ describe('RuntimeGitCommands', () => {
expect(mocks.getStatus).not.toHaveBeenCalled()
})
it('aborts a remote merge through the SSH git provider', async () => {
const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) }
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
expect(provider.abortMerge).toHaveBeenCalledWith('/remote/repo')
expect(mocks.abortMerge).not.toHaveBeenCalled()
})
it('aborts a local rebase through the resolved worktree', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const commands = makeCommands(worktreePath)
mocks.abortRebase.mockResolvedValue(undefined)
await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true })
expect(mocks.abortRebase).toHaveBeenCalledWith(worktreePath, {})
})
it('aborts a remote rebase through the SSH git provider', async () => {
const provider = { abortRebase: vi.fn().mockResolvedValue(undefined) }
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({
worktree: makeWorktree('/remote/repo'),
connectionId: 'conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true })
expect(provider.abortRebase).toHaveBeenCalledWith('/remote/repo')
expect(mocks.abortRebase).not.toHaveBeenCalled()
})
it('checks out a local branch through the resolved worktree', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type * as GitRemoteModule from '../git/remote'
import type * as GitStatusModule from '../git/status'
import type { ResolvedRuntimeGitWorktree } from './runtime-git-command-target'
import { RuntimeGitSyncCommands } from './runtime-git-sync-commands'
const mocks = vi.hoisted(() => ({
abortMerge: vi.fn(),
abortRebase: vi.fn(),
commitChanges: vi.fn(),
getSshGitProvider: vi.fn(),
gitFastForward: vi.fn(),
gitFetch: vi.fn(),
gitPull: vi.fn()
}))
vi.mock('../git/status', async () => ({
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
abortMerge: mocks.abortMerge,
abortRebase: mocks.abortRebase,
commitChanges: mocks.commitChanges
}))
vi.mock('../git/remote', async () => ({
...(await vi.importActual<typeof GitRemoteModule>('../git/remote')),
gitFastForward: mocks.gitFastForward,
gitFetch: mocks.gitFetch,
gitPull: mocks.gitPull
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: mocks.getSshGitProvider
}))
const worktree = {
id: 'wt-1',
path: '/workspace/repo'
} as ResolvedRuntimeGitWorktree
const pushTarget = {
remoteName: 'origin',
branchName: 'main'
} satisfies GitPushTarget
describe('RuntimeGitSyncCommands admission', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('prioritizes local runtime git actions and preserves host routing', async () => {
const commands = new RuntimeGitSyncCommands({
resolveRuntimeGitTarget: async () => ({
worktree,
localGitOptions: { wslDistro: 'Ubuntu' }
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
mocks.commitChanges.mockResolvedValue({ success: true })
await commands.abortRuntimeGitMerge('id:wt-1')
await commands.abortRuntimeGitRebase('id:wt-1')
await commands.fetchRuntimeGit('id:wt-1', pushTarget)
await commands.pullRuntimeGit('id:wt-1', pushTarget)
await commands.fastForwardRuntimeGit('id:wt-1', pushTarget)
await commands.commitRuntimeGit('id:wt-1', 'feat: prioritize user action')
const options = { admissionTier: 'interactive', wslDistro: 'Ubuntu' }
expect(mocks.abortMerge).toHaveBeenCalledWith(worktree.path, options)
expect(mocks.abortRebase).toHaveBeenCalledWith(worktree.path, options)
expect(mocks.gitFetch).toHaveBeenCalledWith(worktree.path, pushTarget, options)
expect(mocks.gitPull).toHaveBeenCalledWith(worktree.path, pushTarget, options)
expect(mocks.gitFastForward).toHaveBeenCalledWith(worktree.path, pushTarget, options)
expect(mocks.commitChanges).toHaveBeenCalledWith(
worktree.path,
'feat: prioritize user action',
options
)
})
it('keeps remote runtime git actions owned by the SSH provider', async () => {
const provider = {
abortMerge: vi.fn(),
abortRebase: vi.fn(),
commit: vi.fn().mockResolvedValue({ success: true }),
fastForwardBranch: vi.fn(),
fetchRemote: vi.fn(),
pullBranch: vi.fn()
}
mocks.getSshGitProvider.mockReturnValue(provider)
const commands = new RuntimeGitSyncCommands({
resolveRuntimeGitTarget: async () => ({
worktree,
connectionId: 'conn-1'
}),
getRuntimeSettings: () => ({}) as GlobalSettings
})
await commands.abortRuntimeGitMerge('id:wt-1')
await commands.abortRuntimeGitRebase('id:wt-1')
await commands.fetchRuntimeGit('id:wt-1', pushTarget)
await commands.pullRuntimeGit('id:wt-1', pushTarget)
await commands.fastForwardRuntimeGit('id:wt-1', pushTarget)
await commands.commitRuntimeGit('id:wt-1', 'feat: keep execution remote')
expect(provider.abortMerge).toHaveBeenCalledWith(worktree.path)
expect(provider.abortRebase).toHaveBeenCalledWith(worktree.path)
expect(provider.fetchRemote).toHaveBeenCalledWith(worktree.path, pushTarget)
expect(provider.pullBranch).toHaveBeenCalledWith(worktree.path, pushTarget)
expect(provider.fastForwardBranch).toHaveBeenCalledWith(worktree.path, pushTarget)
expect(provider.commit).toHaveBeenCalledWith(worktree.path, 'feat: keep execution remote')
expect(mocks.abortMerge).not.toHaveBeenCalled()
expect(mocks.abortRebase).not.toHaveBeenCalled()
expect(mocks.gitFetch).not.toHaveBeenCalled()
expect(mocks.gitPull).not.toHaveBeenCalled()
expect(mocks.gitFastForward).not.toHaveBeenCalled()
expect(mocks.commitChanges).not.toHaveBeenCalled()
})
})
+24 -6
View File
@@ -24,7 +24,10 @@ export class RuntimeGitSyncCommands {
await provider.abortMerge(target.worktree.path)
return { ok: true }
}
await abortMerge(target.worktree.path, localGitOptionsForTarget(target))
await abortMerge(target.worktree.path, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
return { ok: true }
}
@@ -38,7 +41,10 @@ export class RuntimeGitSyncCommands {
await provider.abortRebase(target.worktree.path)
return { ok: true }
}
await abortRebase(target.worktree.path, localGitOptionsForTarget(target))
await abortRebase(target.worktree.path, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
return { ok: true }
}
@@ -70,7 +76,10 @@ export class RuntimeGitSyncCommands {
await provider.fetchRemote(target.worktree.path, pushTarget)
return { ok: true }
}
await gitFetch(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
await gitFetch(target.worktree.path, pushTarget, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
return { ok: true }
}
@@ -106,7 +115,10 @@ export class RuntimeGitSyncCommands {
await provider.pullBranch(target.worktree.path, pushTarget)
return { ok: true }
}
await gitPull(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
await gitPull(target.worktree.path, pushTarget, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
return { ok: true }
}
@@ -123,7 +135,10 @@ export class RuntimeGitSyncCommands {
await provider.fastForwardBranch(target.worktree.path, pushTarget)
return { ok: true }
}
await gitFastForward(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
await gitFastForward(target.worktree.path, pushTarget, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
return { ok: true }
}
@@ -184,6 +199,9 @@ export class RuntimeGitSyncCommands {
}
return provider.commit(target.worktree.path, message)
}
return commitChanges(target.worktree.path, message, localGitOptionsForTarget(target))
return commitChanges(target.worktree.path, message, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
}
}
@@ -143,6 +143,7 @@ export function useChecksPanelForegroundEffects(model: ChecksPanelForegroundEffe
}
setGitStatusRefreshNonce((value) => value + 1)
},
jitterOnVisible: true,
intervalMs: RUNTIME_SSH_STATUS_REFRESH_MS
})
}, [isPanelVisible, repoConnectionId, runtimeEnvironmentId, setGitStatusRefreshNonce])
@@ -21,11 +21,22 @@ const setGitStatusProbeErrorContextKey = vi.fn()
const setGitStatusRefreshNonce = vi.fn()
const updateWorktreeGitIdentity = vi.fn()
function Probe({ nonce }: { nonce: number }): null {
function Probe({
nonce,
contextKey = 'context-A',
worktreeId = 'worktree-A',
worktreePath = '/repo'
}: {
nonce: number
contextKey?: string
worktreeId?: string
worktreePath?: string
}): null {
panelContextKeyRef.current = contextKey
useChecksPanelGitStatusEffects({
activeConnectionId: null,
activeWorktreeId: 'worktree-A',
activeWorktreePath: '/repo',
activeWorktreeId: worktreeId,
activeWorktreePath: worktreePath,
activeWorktreePushTarget: null,
branch: 'feature',
eligibilityHeadOidRef: { current: null },
@@ -47,7 +58,7 @@ function Probe({ nonce }: { nonce: number }): null {
fallbackGitHubPRNumber: null,
localExecutionScope: 'host',
ownerSettings: null,
panelContextKey: 'context-A',
panelContextKey: contextKey,
panelContextKeyRef,
remoteStatus: undefined,
remoteStatusInvalidation: 0,
@@ -123,4 +134,40 @@ describe('useChecksPanelGitStatusEffects poll runner', () => {
})
expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(2)
})
it('does not carry a slow worktree backoff into the next panel context', async () => {
const first = deferred<{
entries: never[]
head: string
branch: string
upstreamStatus: { hasUpstream: boolean; ahead: number; behind: number }
}>()
const status = {
entries: [],
head: 'head-A',
branch: 'feature',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
}
mocks.getRuntimeGitStatus.mockReturnValueOnce(first.promise).mockResolvedValue(status)
const root: Root = await mountProbe(<Probe nonce={0} />)
expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
first.resolve(status)
})
await flush()
await act(async () => {
root.render(
<Probe nonce={0} contextKey="context-B" worktreeId="worktree-B" worktreePath="/repo-b" />
)
})
await flush()
expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(2)
expect(mocks.getRuntimeGitStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ worktreeId: 'worktree-B', worktreePath: '/repo-b' })
)
})
})
@@ -124,7 +124,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect
gitStatusPollRunnerRef.current = null
}
}
}, [])
}, [panelContextKey])
useEffect(() => {
if (
@@ -276,6 +276,7 @@ export function useSourceControlBranchCompare({
branchComparePollEnabledRef.current = true
const stopInterval = installWindowVisibilityInterval({
run: () => void startBranchCompareRef.current('interval'),
jitterOnVisible: true,
intervalMs: BRANCH_REFRESH_INTERVAL_MS
})
return () => {
@@ -90,6 +90,7 @@ export function useWorktreeCardLifecycleEffects({
// Why: PRs created outside Orca (e.g. `gh pr create`) emit no renderer event; poll visible cards to discover them.
return installWindowVisibilityInterval({
run: refreshHostedReview,
jitterOnVisible: true,
intervalMs: HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS
})
}, [
@@ -7,8 +7,7 @@ describe('installWindowVisibilityInterval', () => {
vi.unstubAllGlobals()
})
it('runs intervals only while the document is visible', async () => {
vi.useFakeTimers()
it('runs intervals only while the document is visible', () => {
let visibilityState: DocumentVisibilityState = 'hidden'
const documentListeners = new Map<string, () => void>()
const clearIntervalMock = vi.fn()
@@ -33,8 +32,7 @@ describe('installWindowVisibilityInterval', () => {
run,
intervalMs: 3000,
setIntervalFn: setIntervalMock,
clearIntervalFn: clearIntervalMock,
jitterFn: () => 0
clearIntervalFn: clearIntervalMock
})
expect(run).not.toHaveBeenCalled()
@@ -42,7 +40,6 @@ describe('installWindowVisibilityInterval', () => {
visibilityState = 'visible'
documentListeners.get('visibilitychange')?.()
await vi.advanceTimersByTimeAsync(0)
expect(run).toHaveBeenCalledTimes(1)
expect(setIntervalMock).toHaveBeenCalledTimes(1)
@@ -52,7 +49,6 @@ describe('installWindowVisibilityInterval', () => {
visibilityState = 'visible'
documentListeners.get('visibilitychange')?.()
await vi.advanceTimersByTimeAsync(0)
expect(run).toHaveBeenCalledTimes(2)
expect(setIntervalMock).toHaveBeenCalledTimes(2)
@@ -61,11 +57,9 @@ describe('installWindowVisibilityInterval', () => {
'visibilitychange',
documentListeners.get('visibilitychange')
)
vi.useRealTimers()
})
it('uses runOnVisible for the becoming-visible run and run for interval ticks', async () => {
vi.useFakeTimers()
it('uses runOnVisible for the becoming-visible run and run for interval ticks', () => {
let visibilityState: DocumentVisibilityState = 'hidden'
const documentListeners = new Map<string, () => void>()
const intervalCallbacks: (() => void)[] = []
@@ -95,13 +89,11 @@ describe('installWindowVisibilityInterval', () => {
runOnVisible,
intervalMs: 3000,
setIntervalFn: setIntervalMock,
clearIntervalFn: vi.fn(),
jitterFn: () => 0
clearIntervalFn: vi.fn()
})
visibilityState = 'visible'
documentListeners.get('visibilitychange')?.()
await vi.advanceTimersByTimeAsync(0)
expect(runOnVisible).toHaveBeenCalledTimes(1)
expect(run).not.toHaveBeenCalled()
@@ -110,7 +102,6 @@ describe('installWindowVisibilityInterval', () => {
expect(runOnVisible).toHaveBeenCalledTimes(1)
cleanup()
vi.useRealTimers()
})
it('starts while visible even when the window is not focused', () => {
@@ -153,6 +144,7 @@ describe('installWindowVisibilityInterval', () => {
installWindowVisibilityInterval({
run,
intervalMs: 10_000,
jitterOnVisible: true,
jitterFn: () => [0, 100, 400][index] ?? 0
})
)
@@ -186,6 +178,7 @@ describe('installWindowVisibilityInterval', () => {
const cleanup = installWindowVisibilityInterval({
run,
intervalMs: 3000,
jitterOnVisible: true,
jitterFn: () => 400
})
@@ -212,6 +205,7 @@ describe('installWindowVisibilityInterval', () => {
const cleanup = installWindowVisibilityInterval({
run,
intervalMs: 3000,
jitterOnVisible: true,
jitterFn: () => 400
})
visibilityState = 'visible'
@@ -20,6 +20,7 @@ export function installWindowVisibilityInterval(args: {
intervalMs: number
setIntervalFn?: (callback: () => void, intervalMs: number) => WindowVisibilityIntervalTimer
clearIntervalFn?: (handle: WindowVisibilityIntervalTimer) => void
jitterOnVisible?: boolean
jitterFn?: () => number
}): () => void {
const setIntervalFn =
@@ -30,13 +31,15 @@ export function installWindowVisibilityInterval(args: {
args.clearIntervalFn ?? ((handle: WindowVisibilityIntervalTimer): void => clearInterval(handle))
let intervalId: WindowVisibilityIntervalTimer | null = null
let visibilityJitterId: WindowVisibilityJitterTimer | null = null
const visibilityJitterMs = Math.max(
0,
Math.min(
MAX_VISIBILITY_JITTER_MS,
Math.floor(args.jitterFn?.() ?? Math.random() * (MAX_VISIBILITY_JITTER_MS + 1))
)
)
const visibilityJitterMs = args.jitterOnVisible
? Math.max(
0,
Math.min(
MAX_VISIBILITY_JITTER_MS,
Math.floor(args.jitterFn?.() ?? Math.random() * (MAX_VISIBILITY_JITTER_MS + 1))
)
)
: 0
const stop = (): void => {
if (visibilityJitterId !== null) {
@@ -70,7 +73,7 @@ export function installWindowVisibilityInterval(args: {
}
const reconcile = (): void => {
if (isWindowVisible()) {
start(true)
start(args.jitterOnVisible === true)
} else {
stop()
}
@@ -108,7 +108,8 @@ describe('runProcess termination failure', () => {
it('settles on the barrier deadline when the root never reports', async () => {
const onChildTerminated = vi.fn()
spawnMock.mockReturnValue(mockChild())
const child = mockChild()
spawnMock.mockReturnValue(child)
const pending = runProcess({
program: 'git',
timeoutMs: 10,
@@ -126,6 +127,8 @@ describe('runProcess termination failure', () => {
await vi.advanceTimersByTimeAsync(10_000)
await expect(pending).resolves.toMatchObject({ code: null, timedOut: true })
expect(onChildTerminated).not.toHaveBeenCalled()
child.emit('close', null, 'SIGKILL')
expect(onChildTerminated).toHaveBeenCalledOnce()
})
+1 -3
View File
@@ -55,7 +55,7 @@ export type ProcessSpec = {
stdio?: NodeSpawnOptions['stdio']
/** Kill the whole process tree and do not settle until termination is verified. */
terminationBarrier?: boolean | ProcessTerminationBarrier
/** Called once when the child exits, or when a termination barrier gives up safely. */
/** Called once when the child exits or tree termination is verified. */
onChildTerminated?: () => void
}
@@ -202,8 +202,6 @@ export function runProcess(spec: ProcessSpec): Promise<ProcessResult> {
clearTimeout(graceTimer)
clearTimeout(barrierDeadlineTimer)
spec.signal?.removeEventListener('abort', onAbort)
// A barrier's bounded grace must release rather than pin capacity forever.
terminationReporter.reportIf(Boolean(spec.terminationBarrier))
act()
}