Files
orca/src/main/ipc/filesystem-watcher.test.ts
T
Jinjing f93e92646c P2 watcher lifecycle bounds (#8640)
* fix watcher lifecycle cancellation bounds

* fix(review): guard remote watcher installs against post-shutdown resurrection

closeAllWatchers aborted the in-flight install *tokens* it could see, but a
same-key joiner awaiting a 'cancelled' resolution (and a fired retry tick)
calls installRemoteWatcher directly and, on the fresh-generation recursion,
builds a brand-new non-aborted AbortController and calls provider.watch()
after teardown — leaking an SSH watcher into the just-cleared remoteWatchers
map. Latch the subsystem shut in closeAllWatchers and refuse installs while
latched; a genuine new fs:watchWorktree clears it. Adds a regression test
(fails without the latch) plus a test for the same-tick handoff-revival guard
that had no coverage.

Also extract the duplicated isolated-quarantine-vs-fuse branch shared by
retireSlot and releaseFailedRoot into quarantineOrFuse (behavior-preserving).

* Add lifecycle generation guard to refuse stale remote-watcher joiners

- A boolean latch alone can't distinguish a pre-shutdown joiner from a
  fresh call once a genuine new watch reopens the subsystem, letting a
  stale joiner recurse and register a post-shutdown provider.watch()
- Each installRemoteWatcher call now captures a generation counter that
  closeAllWatchers bumps, so a waiter that resumes after a later
  shutdown+reopen is refused instead of resurrecting
- Adds a regression test covering the shutdown-then-reopen race
2026-07-13 19:12:30 -07:00

428 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('fs/promises', () => ({
stat: vi.fn()
}))
vi.mock('@parcel/watcher', () => ({
subscribe: vi.fn()
}))
vi.mock('./filesystem-watcher-wsl', () => ({
createWslWatcher: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
}))
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { stat } from 'node:fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
describe('registerFilesystemWatcherHandlers', () => {
const handlers: HandlerMap = {}
const originalPlatform = process.platform
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
vi.mocked(stat).mockReset()
vi.mocked(subscribeParcelWatcher).mockReset()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
it('pins Parcel to the Windows backend for local Windows watches', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never)
await handlers['fs:watchWorktree'](
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
{ worktreePath: 'C:\\repo' }
)
expect(subscribeParcelWatcher).toHaveBeenCalledWith(
expect.any(String),
expect.any(Function),
expect.objectContaining({ backend: 'windows' })
)
await closeAllWatchers()
})
it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
getSshFilesystemProviderMock.mockReturnValue(undefined)
await expect(
handlers['fs:watchWorktree'](
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
).resolves.toBeUndefined()
await handlers['fs:watchWorktree'](
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
'[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for /home/me/repo on connection conn-1'
)
handlers['fs:unwatchWorktree'](
{ sender: { id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
warnSpy.mockRestore()
vi.useRealTimers()
})
it('binds a pending SSH worktree watch after the filesystem provider appears', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const sendMock = vi.fn()
const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 }
const unwatchMock = vi.fn()
const watchMock = vi.fn().mockResolvedValue(unwatchMock)
getSshFilesystemProviderMock.mockReturnValueOnce(undefined)
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), {
signal: expect.any(AbortSignal)
})
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendMock).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ path: '/home/me/repo/file.txt', type: 'update' }]
})
warnSpy.mockRestore()
handlers['fs:unwatchWorktree'](
{ sender: { id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
vi.useRealTimers()
})
it('retries SSH worktree watches when provider setup rejects', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const sendMock = vi.fn()
const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 }
const unwatchMock = vi.fn()
const retryWatchMock = vi.fn().mockResolvedValue(unwatchMock)
getSshFilesystemProviderMock
.mockReturnValueOnce({
watch: vi.fn().mockRejectedValue(new Error('provider disposed'))
})
.mockReturnValue({ watch: retryWatchMock })
await expect(
handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
).resolves.toBeUndefined()
await vi.advanceTimersByTimeAsync(1_000)
expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), {
signal: expect.any(AbortSignal)
})
handlers['fs:unwatchWorktree'](
{ sender: { id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(unwatchMock).toHaveBeenCalledTimes(1)
warnSpy.mockRestore()
vi.useRealTimers()
})
it('cancels pending SSH watch retries during watcher shutdown', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const watchMock = vi.fn()
getSshFilesystemProviderMock.mockReturnValueOnce(undefined)
await handlers['fs:watchWorktree'](
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
await closeAllWatchers()
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).not.toHaveBeenCalled()
warnSpy.mockRestore()
vi.useRealTimers()
})
it('shares SSH worktree watchers across renderer senders until the last unwatch', async () => {
const sendOne = vi.fn()
const sendTwo = vi.fn()
const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 }
const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 }
const unwatchMock = vi.fn()
const watchMock = vi.fn().mockResolvedValue(unwatchMock)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree'](
{ sender: senderOne },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
await handlers['fs:watchWorktree'](
{ sender: senderTwo },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(watchMock).toHaveBeenCalledTimes(1)
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
expect(sendTwo).toHaveBeenCalledTimes(1)
handlers['fs:unwatchWorktree'](
{ sender: { id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(unwatchMock).not.toHaveBeenCalled()
handlers['fs:unwatchWorktree'](
{ sender: { id: 2 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(unwatchMock).toHaveBeenCalledTimes(1)
})
it('dedupes concurrent pending SSH worktree watcher installs', async () => {
const sendOne = vi.fn()
const sendTwo = vi.fn()
const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 }
const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 }
const unwatchMock = vi.fn()
let resolveWatch: (unwatch: () => void) => void = () => {}
const watchPromise = new Promise<() => void>((resolve) => {
resolveWatch = resolve
})
const watchMock = vi.fn().mockReturnValue(watchPromise)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const firstWatch = handlers['fs:watchWorktree'](
{ sender: senderOne },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
) as Promise<unknown>
const secondWatch = handlers['fs:watchWorktree'](
{ sender: senderTwo },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
) as Promise<unknown>
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
resolveWatch(unwatchMock)
await Promise.all([firstWatch, secondWatch])
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
expect(sendTwo).toHaveBeenCalledTimes(1)
handlers['fs:unwatchWorktree'](
{ sender: { id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
handlers['fs:unwatchWorktree'](
{ sender: { id: 2 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(unwatchMock).toHaveBeenCalledTimes(1)
})
it('keeps a pending SSH watcher install alive when only one pending sender unwatches', async () => {
const sendOne = vi.fn()
const sendTwo = vi.fn()
const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 }
const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 }
const unwatchMock = vi.fn()
let resolveWatch: (unwatch: () => void) => void = () => {}
const watchPromise = new Promise<() => void>((resolve) => {
resolveWatch = resolve
})
const watchMock = vi.fn().mockReturnValue(watchPromise)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const firstWatch = handlers['fs:watchWorktree'](
{ sender: senderOne },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
) as Promise<unknown>
const secondWatch = handlers['fs:watchWorktree'](
{ sender: senderTwo },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
) as Promise<unknown>
await Promise.resolve()
handlers['fs:unwatchWorktree'](
{ sender: { id: 2 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
resolveWatch(unwatchMock)
await Promise.all([firstWatch, secondWatch])
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
expect(sendTwo).not.toHaveBeenCalled()
handlers['fs:unwatchWorktree'](
{ sender: { id: 1 } },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(unwatchMock).toHaveBeenCalledTimes(1)
})
it('unsubscribes if the sender is destroyed while an SSH watcher is opening', async () => {
const destroyedCallbacks: (() => void)[] = []
const sender = {
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn((event: string, callback: () => void) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
}),
id: 1
}
const unwatchMock = vi.fn()
let resolveWatch: (unwatch: () => void) => void = () => {}
const watchPromise = new Promise<() => void>((resolve) => {
resolveWatch = resolve
})
const watchMock = vi.fn().mockReturnValue(watchPromise)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const watch = handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
) as Promise<unknown>
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
expect(destroyedCallbacks).toHaveLength(1)
destroyedCallbacks[0]()
resolveWatch(unwatchMock)
await watch
expect(unwatchMock).toHaveBeenCalledTimes(1)
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sender.send).not.toHaveBeenCalled()
})
it('revives a pending SSH watcher install when a new sender joins after cancellation', async () => {
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
const unwatchMock = vi.fn()
let resolveWatch!: (unwatch: () => void) => void
const watchMock = vi.fn().mockReturnValue(
new Promise<() => void>((resolve) => {
resolveWatch = resolve
})
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const firstWatch = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
await Promise.resolve()
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
const secondWatch = handlers['fs:watchWorktree'](
{ sender: senderTwo },
args
) as Promise<unknown>
expect(watchMock).toHaveBeenCalledTimes(1)
resolveWatch(unwatchMock)
await Promise.all([firstWatch, secondWatch])
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(senderOne.send).not.toHaveBeenCalled()
expect(senderTwo.send).toHaveBeenCalledTimes(1)
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
expect(unwatchMock).toHaveBeenCalledTimes(1)
})
it('registers one destroyed listener for many SSH worktree watches', async () => {
const destroyedCallbacks: (() => void)[] = []
const sender = {
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn((event: string, callback: () => void) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
}),
id: 99
}
const unwatchMock = vi.fn()
const watchMock = vi.fn().mockResolvedValue(unwatchMock)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
for (let i = 0; i < 12; i += 1) {
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: `/home/me/repo-${i}`, connectionId: 'conn-1' }
)
}
// Why: WebContents warns after 10 listeners. The cleanup work still covers
// every remote watch by scanning the shared remote watcher registry.
expect(sender.once).toHaveBeenCalledTimes(1)
expect(destroyedCallbacks).toHaveLength(1)
destroyedCallbacks[0]()
expect(unwatchMock).toHaveBeenCalledTimes(12)
})
})