diff --git a/src/main/ipc/parcel-watcher-child-termination.ts b/src/main/ipc/parcel-watcher-child-termination.ts index d4376468574..fc498410438 100644 --- a/src/main/ipc/parcel-watcher-child-termination.ts +++ b/src/main/ipc/parcel-watcher-child-termination.ts @@ -28,7 +28,9 @@ export class WatcherTerminationQueue { private current: Promise | null = null waitFor(operation: () => Promise): Promise | 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 | null { diff --git a/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts b/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts index b028a13bf92..2aad68d54b7 100644 --- a/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts +++ b/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts @@ -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 } + 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 { diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 4098e8f9b86..70d313ba63c 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -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', () => { diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index fee96842af8..e329784719b 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -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)