mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
* fix(ssh): re-arm remote file watches when the provider reconnects Remote file changes stopped being detected over SSH until the file was reopened. The watch pipeline itself was fine — nothing ever re-established the subscription after the transport it was made on went away. Two paths left an editor tab permanently stale: - A reconnect kills the relay's watch registrations, and the previous provider's unwatch handle belongs to the dead transport. - A connect slower than the 60s retry window made installRemoteWatcher give up for good; first deploy to a new host far exceeds that. Neither recovered, because installRemoteWatcher is only reachable from the fs:watchWorktree handler, the retry timer, and the removal-restore path, and the renderer only issues a watch for newly added targets. Reopening the file just re-read it — the watcher stayed dead. Give the layer that owns the transport the job of re-arming: registerSshFilesystemProvider now notifies subscribers, which covers both establish and reconnect since registerProviders runs on both. The watcher keeps the intent to watch in a registry that outlives any single connection, and on registration drops the stale entry (installRemoteWatcher treats an existing entry as installed and would otherwise hand back a watcher that can never fire), reinstalls, and emits overflow so consumers resync the gap. Intent is dropped on unwatch and sender destroy so a closed tab is never resurrected. Verified over SSH to a Rocky Linux 10 host: after the reconnect that previously killed it, a remote append lands in the editor in ~2s with a live remote watcher process, and a second edit in ~1.5s. * fix(ssh): drop watch intent when a remote worktree is removed A removed worktree kept its entry in the intent registry, so a reconnect landing before the renderer's unwatch would re-watch a deleted path — 60s of retries against the host and then a bogus overflow. Also covers two reinstall cases: several senders on one connection must collapse onto a single relay watch (and all of them resync), and a destroyed renderer must not be reinstalled. * fix(ssh): resync when a reconnect's watch only lands on a retry The reinstall emitted the overflow only for listeners whose first install returned 'installed'. If that attempt failed — relay-watcher.js still spawning on the fresh transport, a transient fs.watch rejection — the 1s retry restored the watch but never signalled the gap, so everything that changed while the transport was down stayed invisible: the STA-2525 symptom reappearing inside the fix for it. Thread the resync intent through the retry record so the overflow lands when the retry does. All pre-existing callers default to false, so the watch/terminal-error/restore paths are unchanged. * test(ssh): cover the resync merge when a fresh watch claims the retry slot The `resyncOnInstall ||=` merge was uncovered: removing it left every existing test green. It is load-bearing — if a second renderer joins the reinstall's failing install and reaches the retry slot first with resync=false, the whole chain stays false and the retry restores the watch without ever signalling the gap. New sibling file rather than an append: filesystem-watcher.test.ts is ~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a max-lines disable.
115 lines
3.5 KiB
TypeScript
115 lines
3.5 KiB
TypeScript
import { join, resolve } from 'node:path'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { handleMock } = vi.hoisted(() => ({
|
|
handleMock: 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: vi.fn(),
|
|
onSshFilesystemProviderRegistered: () => () => {}
|
|
}))
|
|
|
|
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
|
import { stat } from 'node:fs/promises'
|
|
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
|
|
import type { Event as WatcherEvent } from '@parcel/watcher'
|
|
|
|
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
|
|
|
|
describe('local filesystem watcher large batches', () => {
|
|
const handlers: HandlerMap = {}
|
|
|
|
beforeEach(async () => {
|
|
vi.useRealTimers()
|
|
handleMock.mockReset()
|
|
vi.mocked(stat).mockReset()
|
|
vi.mocked(subscribeParcelWatcher).mockReset()
|
|
for (const key of Object.keys(handlers)) {
|
|
delete handlers[key]
|
|
}
|
|
handleMock.mockImplementation((channel, handler) => {
|
|
handlers[channel] = handler
|
|
})
|
|
registerFilesystemWatcherHandlers()
|
|
await closeAllWatchers()
|
|
})
|
|
|
|
it('accepts a large local watcher event batch without overflowing V8 arguments', async () => {
|
|
vi.useFakeTimers()
|
|
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
|
const worktreePath = resolve('/tmp/repo')
|
|
let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined
|
|
vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => {
|
|
watcherCallback = callback as typeof watcherCallback
|
|
return { unsubscribe: vi.fn() } as never
|
|
})
|
|
|
|
await handlers['fs:watchWorktree'](
|
|
{ sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
|
|
{ worktreePath }
|
|
)
|
|
|
|
const events = Array.from(
|
|
{ length: 200_000 },
|
|
(_, index): WatcherEvent => ({ type: 'delete', path: join(worktreePath, `file-${index}`) })
|
|
)
|
|
|
|
expect(() => watcherCallback?.(null, events)).not.toThrow()
|
|
await closeAllWatchers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('emits one overflow event for oversized native watcher batches', async () => {
|
|
vi.useFakeTimers()
|
|
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
|
let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined
|
|
vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => {
|
|
watcherCallback = callback as typeof watcherCallback
|
|
return { unsubscribe: vi.fn() } as never
|
|
})
|
|
const worktreePath = resolve('/tmp/repo')
|
|
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
|
|
|
await handlers['fs:watchWorktree']({ sender }, { worktreePath })
|
|
watcherCallback?.(
|
|
null,
|
|
Array.from(
|
|
{ length: 5_001 },
|
|
(_, index): WatcherEvent => ({
|
|
type: 'update',
|
|
path: join(worktreePath, `file-${index}.txt`)
|
|
})
|
|
)
|
|
)
|
|
|
|
await vi.advanceTimersByTimeAsync(150)
|
|
|
|
expect(stat).toHaveBeenCalledTimes(1)
|
|
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
|
|
worktreePath,
|
|
events: [{ kind: 'overflow', absolutePath: worktreePath }]
|
|
})
|
|
await closeAllWatchers()
|
|
vi.useRealTimers()
|
|
})
|
|
})
|