Files
orca/src/main/ipc/filesystem-watcher-remote-batch.test.ts
T
JinjingandOrca 5c0195af64 Bound remote watcher fan-out and defer File Explorer refreshes (#11908)
* batch remote watcher events and defer File Explorer refreshes

Remote filesystem watcher events now batch with the shared 150ms trailing and 500ms
max-wait window, coalescing per-path like local events. File Explorer tree and
directory refreshes are scheduled with debounce and transport-aware concurrency caps
(16 local, 8 runtime, 4 SSH). Stale directory cache tracking prevents trusting
collapsed listings skipped by full refresh; they are re-read on re-expansion. Relay
implements a 15-minute idle-only grace cap for zero-PTY relays via PTY pool
lifecycle tracking, independent of explicitly configured grace time.

* fix(watch/relay): bound remote watcher fan-out and read the live relay grace

Three P1 fixes from the SSH/remote freeze audit:

- Remote watchers now debounce on the same 150/500 window as local ones
  (finding D), and every teardown path drops the trailing flush timer
  instead of letting it fire into a dead watch. The deferred send is
  wrapped so a frame disposed mid-window can't escape as a fatal
  main-process exception.
- File Explorer refreshes are scheduled and concurrency-capped rather
  than fanned out unbounded over expanded dirs (finding C). Local
  transports use a zero window, since main already coalesced the burst.
- relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the
  launch-time argv closure, so a grace raised after launch is honored.
  The branch selection moves to relay-grace-branch.ts because relay.ts
  has no exports and calls main() at import, making it untestable.
  Consequence: a host-sleep relay holding zero PTYs now exits after the
  idle cap. Pinned by test and documented in
  docs/reference/relay-grace-time-reconfiguration.md.

Also drops the duplicated 150/500/5000 constants in the runtime-RPC
batcher in favor of the shared window module.

* docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit

Co-authored-by: Orca <help@stably.ai>

* refactor(file-explorer): use useMemo for paths; remove relay reference

Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle.

* rm design doc

* fix(remote-watcher): prevent stranded timer after close

An in-flight provider receive can land after the batch is torn down.
Without a guard, pushing events to a closed batch would re-arm a timer
that would never be cleared, stranding the task indefinitely. Track the
closed state and skip pushes after close().

Relay.ts comment clarifies why pool watches remain registered during
grace-period shutdown deferral — the socket server stays listening so
a reconnecting client can cancel the grace and resume.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-01 11:55:58 -07:00

192 lines
6.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../shared/types'
const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted(
() => ({
handleMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
providerRegistrationListeners: new Set<(connectionId: string) => void>()
})
)
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,
onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => {
providerRegistrationListeners.add(listener)
return () => providerRegistrationListeners.delete(listener)
}
}))
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
type WatchCallback = (events: FsChangeEvent[]) => void
const WORKTREE_PATH = '/home/me/repo'
const WATCH_ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' }
describe('remote filesystem watcher batching', () => {
const handlers: HandlerMap = {}
const watchCallbacks: WatchCallback[] = []
function makeSender(overrides: Partial<{ isDestroyed: () => boolean }> = {}) {
return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1, ...overrides }
}
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
watchCallbacks.length = 0
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
getSshFilesystemProviderMock.mockReturnValue({
watch: vi.fn(async (_root: string, callback: WatchCallback) => {
watchCallbacks.push(callback)
return vi.fn()
})
})
})
it('coalesces a burst of remote events into a single fs:changed send', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
for (let i = 0; i < 50; i++) {
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
}
expect(sender.send).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]
})
await closeAllWatchers()
vi.useRealTimers()
})
// An atomic replace (delete+create) followed by an rm inside one window must still tombstone the path.
it('sends the net delete when a remote replace is removed in the same window', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([
{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` },
{ kind: 'create', absolutePath: `${WORKTREE_PATH}/dist/app.js` }
])
watchCallbacks[0]([{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }])
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }]
})
await closeAllWatchers()
vi.useRealTimers()
})
it('collapses an oversized remote burst into one overflow payload', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0](
Array.from(
{ length: 6_000 },
(_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${WORKTREE_PATH}/file-${index}.ts`
})
)
)
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }]
})
await closeAllWatchers()
vi.useRealTimers()
})
it('sends nothing when the worktree is unwatched before the flush deadline', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
await handlers['fs:unwatchWorktree']({ sender }, WATCH_ARGS)
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
it('sends nothing when all watchers close before the flush deadline', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
await closeAllWatchers()
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('skips a WebContents destroyed while the batch was buffering', async () => {
vi.useFakeTimers()
let destroyed = false
const sender = makeSender({ isDestroyed: () => destroyed })
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
destroyed = true
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
it('drops a stale flush once a newer install owns the key', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
for (const listener of providerRegistrationListeners) {
listener('conn-1')
}
await vi.advanceTimersByTimeAsync(1_000)
expect(watchCallbacks).toHaveLength(2)
sender.send.mockClear()
// The replaced transport's callback still holds its own batch; its flush must not reach the renderer.
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
})