fix(runtime): retry watches after teardown deadlines

This commit is contained in:
Brennan Benson
2026-07-14 19:12:48 -07:00
parent 6d35ad77b7
commit 1365fe0f19
4 changed files with 39 additions and 2 deletions
@@ -28,7 +28,9 @@ export class WatcherTerminationQueue {
private current: Promise<void> | null = null
waitFor<T>(operation: () => Promise<T>): Promise<T> | null {
return this.current?.then(operation) ?? null
// Why: an idle child may miss its exit deadline without poisoning the
// reusable supervisor; queued work must re-check state after either result.
return this.current?.then(operation, operation) ?? null
}
getCurrent(): Promise<void> | null {
@@ -120,6 +120,38 @@ describe('watcher native unsubscribe timeout', () => {
}
})
it('spawns a fresh child for a subscribe queued before an idle exit deadline miss', async () => {
vi.useFakeTimers()
try {
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const first = currentChild()
ackSubscribe(first, 0)
const subscription = await promise
const unsubscribe = subscription.unsubscribe().catch((error: unknown) => error)
const queued = subscribeViaWatcherProcess('/repo2', vi.fn(), {})
await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS)
const error = (await unsubscribe) as Error & { physicalExit?: Promise<void> }
expect(error).toMatchObject({
message: 'file watcher process did not exit after termination deadline',
physicalExit: expect.any(Promise)
})
expect(forkMock).toHaveBeenCalledTimes(2)
const second = currentChild()
expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' })
ackSubscribe(second)
await expect(queued).resolves.toMatchObject({ unsubscribe: expect.any(Function) })
first.emit('exit', null, 'SIGKILL')
await error.physicalExit
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('rejects only after an unkillable child reaches its exit deadline', async () => {
vi.useFakeTimers()
try {
+1
View File
@@ -19,6 +19,7 @@ describe('cross-platform path containment', () => {
expect(normalizeRuntimePathForComparison('/srv/team\\repo')).toBe('/srv/team\\repo')
expect(normalizeRuntimePathForComparison('/srv/team/repo')).toBe('/srv/team/repo')
expect(isPathInsideOrEqual('/srv/team\\repo', '/srv/team/repo/file.ts')).toBe(false)
expect(relativePathInsideRoot('/srv/repo', '/srv/repo/a\\b.txt')).toBe('a\\b.txt')
})
it('handles Windows drive roots and sibling drives case-insensitively', () => {
+3 -1
View File
@@ -69,7 +69,9 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo
export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null {
const normalizedCandidate = trimRuntimePathTrailingSlash(
normalizeRuntimePathSeparators(candidatePath)
isWindowsAbsolutePathLike(candidatePath)
? normalizeRuntimePathSeparators(candidatePath)
: candidatePath.replace(/\/+/g, '/')
)
const comparisonRoot = normalizeRuntimePathForComparison(rootPath)
const comparisonCandidate = normalizeRuntimePathForComparison(candidatePath)