mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
* 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
428 lines
15 KiB
TypeScript
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)
|
|
})
|
|
})
|