mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
* 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>
192 lines
6.7 KiB
TypeScript
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()
|
|
})
|
|
})
|