refactor(ssh): remove the dead SSH filesystem provider and desktop SSH file watcher (#31922)

* refactor(ssh): remove the dead SSH filesystem provider and desktop SSH file watcher

Nothing in production has registered an SSH filesystem provider since the
relay removal (#27214/#27215), so every getSshFilesystemProvider read
returned undefined. Delete the provider table and the remote watcher
subsystem, and collapse the call sites to that always-undefined behaviour:
fs:watchWorktree/fs:unwatchWorktree ignore SSH roots, the worktree base
watcher only resolves local repos, and worktree removal skips the desktop
watcher for SSH paths.

* test: drop the nested SSH watcher catalog case that can no longer occur

The base-directory watcher never carries a connection id now, so the
host-blind-event guard it exercised is gone. Also prune the deleted remote
watcher suites from the shard timings and the nested-SSH wording from the
worktree catalog reliability gate.
This commit is contained in:
Neil
2026-10-22 01:54:29 -07:00
committed by GitHub
parent dcf2795b1d
commit dc315a4e5a
96 changed files with 257 additions and 5181 deletions
+20 -28
View File
@@ -853,28 +853,23 @@
"protection": "partial",
"owner": "runtime-platform",
"layer": "ipc-contract",
"surfaces": [
"desktop file explorer watchers",
"desktop editor file watchers",
"direct SSH file watchers"
],
"surfaces": ["desktop file explorer watchers", "desktop editor file watchers"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "ssh", "wsl"],
"providers": ["local", "wsl"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh", "wsl"],
"coverageNotes": "Actual desktop local/WSL subscription and SSH watcher controller logic under mocked filesystem/provider handles on macOS. Document events use an EventEmitter sender. Live remote/native Windows/Linux/WSL and rendered reload reattachment are unproved; PTY/process liveness and wire formats are unaffected.",
"coveredProviders": ["local", "wsl"],
"coverageNotes": "Actual desktop local/WSL subscription logic under mocked filesystem handles on macOS. Document events use an EventEmitter sender. Native Windows/Linux/WSL and rendered reload reattachment are unproved; PTY/process liveness and wire formats are unaffected. SSH roots have no desktop watcher.",
"motivatingLinks": [
"https://github.com/stablyai/orca/blob/main/src/main/ipc/filesystem-watcher-listener-lifecycle.ts"
],
"invariant": "A replaced or crashed renderer document retains no file watches or pending ownership and cannot recreate them from an old asynchronous continuation. Shared roots retain live sibling owners; blocked and same-document navigation retain the current document. Shutdown removes registered lifecycle listeners.",
"oracle": "Run repeated reloads/crash, pending local/SSH installation cancellation with late success, aborted-install joiners, reconnect retry snapshots, failed-removal restoration and shutdown/reopen. Require zero obsolete roots/intent/listeners, no stale setup/retry, physical handle disposal and preservation of sibling notifications and same-tick remote handoff.",
"oracle": "Run repeated reloads/crash, pending installation cancellation with late success, aborted-install joiners, failed-removal restoration and shutdown/reopen. Require zero obsolete roots/listeners, no stale setup, physical handle disposal and preservation of sibling notifications.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-remote-cancellation.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot"
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot"
],
"testFiles": [
"src/main/ipc/filesystem-watcher-document-lifetime.test.ts",
"src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts",
"src/main/ipc/filesystem-watcher-remote-cancellation.test.ts",
"src/main/ipc/filesystem-watcher-native-capacity.test.ts"
],
"assertionRefs": [
@@ -882,22 +877,21 @@
"file": "src/main/ipc/filesystem-watcher-document-lifetime.test.ts",
"assertions": [
"retains zero roots and listeners across 15 reloads and a renderer crash",
"does not revive a cancelled %s setup for a joiner whose document reloaded",
"does not re-arm an old provider snapshot into the same WebContents after replacement",
"does not restore a replaced sibling document after an awaited %s removal recovery",
"does not revive a cancelled setup for a joiner whose document reloaded",
"does not restore a replaced sibling document after an awaited removal recovery",
"keeps same-document and blocked navigations alive, and removes lifecycle listeners at shutdown"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-25",
"date": "2026-10-22",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-remote-cancellation.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.08,
"summary": "45 tests across four focused suites pass. Broader watcher suite passes 146 plus one existing skipped; isolated publication without sibling #22995 passes 145 plus one existing skipped."
"durationSeconds": 0.99,
"summary": "33 tests across three focused suites pass after the dead SSH watcher was removed."
}
],
"runtimeBudget": {
@@ -910,14 +904,14 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Seven permanent regressions fail with HEAD source substituted: cancelled joiner resurrection for local and SSH, restoration into replaced local/SSH documents, stale provider retry, shutdown listener retention, and root retention. All 13 document ownership cases pass after the change."
"evidence": "Seven permanent regressions failed with HEAD source substituted when this gate landed: cancelled joiner resurrection, restoration into replaced documents, stale retry, shutdown listener retention and root retention. The SSH cases went with the dead SSH watcher; the local cases remain."
},
"performanceBudget": {
"required": true,
"evidence": "15 reloads plus crash: retained local/SSH/desired roots 16→0 each; pending setup aborted; sender lifecycle listeners 1→0. Actual ownership modules with synthetic handles; no native process/heap/latency claim. No new timer, polling, process scan or provider fanout; existing root cleanup runs at document end."
"evidence": "15 reloads plus crash: retained local roots 16→0; pending setup aborted; sender lifecycle listeners 1→0. Actual ownership modules with synthetic handles; no native process/heap/latency claim. No new timer, polling, process scan or provider fanout; existing root cleanup runs at document end."
},
"knownGaps": [
"No native Windows/Linux/WSL or live SSH execution.",
"No native Windows/Linux/WSL execution.",
"No rendered Electron reload-reattachment/paint run or CI soak; broader watcher process isolation is a separate gate."
],
"promotionCriteria": [
@@ -4540,8 +4534,8 @@
"https://github.com/stablyai/orca/pull/7086",
"https://github.com/stablyai/orca/issues/16477"
],
"invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.",
"oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, and fill a partial pushed authority before clearing that target's PTY bindings once.",
"invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.",
"oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, and fill a partial pushed authority before clearing that target's PTY bindings once.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts",
@@ -4582,8 +4576,7 @@
"linked index bursts produce zero notifyWorktreesChanged calls and one debounced status-only notification",
"linked HEAD and locked metadata still produce a structural worktree notification",
"status-only head moves emit head identities without structural fanout and only when heads actually changed",
"structural notifications re-baseline head identities silently and SSH watches never read identities",
"SSH-shaped index renames are status-only while overflow remains conservatively structural"
"structural notifications re-baseline head identities silently"
]
},
{
@@ -4635,7 +4628,7 @@
"host inventory and both paired catalogs advance to the same worktree identity after runtime cache invalidation",
"an invalidated in-flight host scan and a second client's joined read converge through one fresh retry before either can return stale data",
"a disconnected client reconnects to current catalog state without replaying a stale invalidation",
"an event-ignoring client remains compatible, and nested SSH or colliding local/SSH owners do not publish an owner-blind event",
"an event-ignoring client remains compatible, and colliding local/SSH owners do not publish an owner-blind event",
"the shared runtime publication remains valid without a headed renderer notifier"
]
},
@@ -4643,8 +4636,7 @@
"file": "src/main/ipc/watched-worktree-catalog-notification.test.ts",
"assertions": [
"the watcher authority refreshes the host renderer and paired clients from one boundary",
"remote publication failure cannot suppress the host renderer refresh",
"nested SSH discoveries remain host-local until the existing event can preserve their owner identity"
"remote publication failure cannot suppress the host renderer refresh"
]
},
{
-7
View File
@@ -2573,7 +2573,6 @@
"src/main/ipc/filesystem-symlink-directory-entries.test.ts": 33,
"src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 456,
"src/main/ipc/filesystem-watcher-document-lifetime.test.ts": 388,
"src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 204,
"src/main/ipc/filesystem-watcher-event-batch.test.ts": 34,
"src/main/ipc/filesystem-watcher-ignore-real.test.ts": 441,
"src/main/ipc/filesystem-watcher-ignore.test.ts": 45,
@@ -2582,12 +2581,7 @@
"src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 1005,
"src/main/ipc/filesystem-watcher-native-capacity.test.ts": 186,
"src/main/ipc/filesystem-watcher-real.test.ts": 762,
"src/main/ipc/filesystem-watcher-remote-batch.test.ts": 202,
"src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 501,
"src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 330,
"src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 168,
"src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 664,
"src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 195,
"src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 174,
"src/main/ipc/filesystem-watcher-wsl.test.ts": 107,
"src/main/ipc/filesystem-watcher.test.ts": 2082,
@@ -2810,7 +2804,6 @@
"src/main/ipc/registered-worktree-root-refresh.test.ts": 321,
"src/main/ipc/registered-worktree-root-wsl-runtime.test.ts": 192,
"src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts": 239,
"src/main/ipc/remote-watcher-event-batch.test.ts": 56,
"src/main/ipc/renderer-lifetime-abort.test.ts": 47,
"src/main/ipc/renderer-shutdown-checkpoint.test.ts": 433,
"src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 40,
@@ -31,10 +31,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -33,10 +33,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -29,10 +29,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -29,10 +29,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock('./local-worktree-runtime-options', () => ({
getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock,
getLocalGitOptionsForRepo: vi.fn(() => ({})),
@@ -34,10 +34,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -33,10 +33,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -40,10 +40,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -23,10 +23,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -43,10 +43,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -33,10 +33,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
-6
View File
@@ -53,7 +53,6 @@ export const cancelGeneratePullRequestFieldsLocalMock: IpcMock = vi.fn()
export const getPullRequestDraftContextMock: IpcMock = vi.fn()
export const resolveHostedReviewBodyForGenerationMock: IpcMock = vi.fn()
export const loadPullRequestLinkedIssueMock: IpcMock = vi.fn()
export const getSshFilesystemProviderMock: IpcMock = vi.fn()
export const tryDeleteWslUncPathMock: IpcMock = vi.fn()
export const recordCrashBreadcrumbMock: IpcMock = vi.fn()
@@ -108,10 +107,6 @@ export const gitWorktreeMock = {
listWorktreesStrict: listWorktreesMock
}
export const sshFilesystemDispatchMock = {
getSshFilesystemProvider: getSshFilesystemProviderMock
}
export const textGenerationModuleMock = {
resolveCommitMessageSettings: resolveCommitMessageSettingsMock,
generateCommitMessageFromContext: generateCommitMessageFromContextMock,
@@ -207,7 +202,6 @@ const ALL_MOCKS = [
gitStatusModuleMock,
gitIgnoredPathsMock,
gitWorktreeMock,
sshFilesystemDispatchMock,
textGenerationModuleMock,
pullRequestContextMock,
pullRequestTemplateMock,
@@ -10,11 +10,9 @@ class Sender extends EventEmitter {
isDestroyed = () => false
send = vi.fn()
}
const { handleMock, watchRemote, createLocal, getProvider } = vi.hoisted(() => ({
const { handleMock, createLocal } = vi.hoisted(() => ({
handleMock: vi.fn(),
watchRemote: vi.fn(),
createLocal: vi.fn(),
getProvider: vi.fn()
createLocal: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
vi.mock('node:fs/promises', () => ({ stat: async () => ({ isDirectory: () => true }) }))
@@ -23,27 +21,14 @@ vi.mock('./filesystem-watcher-local-events', () => ({
scheduleLocalBatchFlush: vi.fn()
}))
vi.mock('./parcel-watcher-process', () => ({ disposeWatcherProcess: vi.fn() }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getProvider,
onSshFilesystemProviderRegistered: () => () => {}
}))
import {
REMOTE_WATCH_RETRY_MS,
watcherLifecycleState as state
} from './filesystem-watcher-lifecycle-state'
import { getLocalWatcherRoot, getRemoteWatcherKey } from './filesystem-watcher-paths'
import { reinstallRemoteWatchersForConnection } from './filesystem-watcher-remote-controller'
import { reinstallRemoteWatchersForConnectionCore } from './filesystem-watcher-remote-provider-rearm'
import { watcherLifecycleState as state } from './filesystem-watcher-lifecycle-state'
import { getLocalWatcherRoot } from './filesystem-watcher-paths'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import {
closeLocalWatcherForWorktreePath,
restoreLocalWatcherAfterFailedRemoval
} from './filesystem-watcher-local-removal'
import {
closeRemoteWatcherForWorktreePath,
restoreRemoteWatcherAfterFailedRemoval
} from './filesystem-watcher-remote-removal'
const handlers = new Map<string, (event: { sender: Sender }, args: WatchArgs) => Promise<void>>()
function invoke(channel: string, sender: Sender, args: WatchArgs): Promise<void> {
@@ -75,8 +60,6 @@ beforeEach(async () => {
await closeAllWatchers()
vi.clearAllMocks()
createLocal.mockImplementation(async () => localRoot())
watchRemote.mockImplementation(async () => vi.fn())
getProvider.mockReturnValue({ watch: watchRemote })
handleMock.mockImplementation((channel, handler) => handlers.set(channel, handler))
registerFilesystemWatcherHandlers()
})
@@ -87,33 +70,23 @@ afterEach(async () => {
describe('filesystem watcher renderer document ownership', () => {
it.each(['did-navigate', 'render-process-gone'])(
'releases installed local and SSH roots on %s without closing sibling owners',
'releases installed local roots on %s without closing sibling owners',
async (event) => {
const sender = new Sender(1)
const sibling = new Sender(2)
const local = { worktreePath: '/folder' }
const remote = { ...local, connectionId: 'ssh' }
const root = localRoot()
const remoteClose = vi.fn()
createLocal.mockResolvedValue(root)
watchRemote.mockResolvedValue(remoteClose)
await watch(sender, local)
await watch(sender, remote)
await watch(sibling, local)
await watch(sibling, remote)
sender.emit(event)
await Promise.resolve()
expect(root.subscription.unsubscribe).not.toHaveBeenCalled()
expect(remoteClose).not.toHaveBeenCalled()
expect([...root.listeners.keys()]).toEqual([2])
expect([...state.desiredRemoteWatchers.values()][0].listeners.size).toBe(1)
sibling.emit(event)
await Promise.resolve()
expect(root.subscription.unsubscribe).toHaveBeenCalledTimes(1)
expect(remoteClose).toHaveBeenCalledTimes(1)
expect(state.watchedRoots.size).toBe(0)
expect(state.remoteWatchers.size).toBe(0)
expect(state.desiredRemoteWatchers.size).toBe(0)
expect(sender.eventNames()).toEqual([])
expect(sibling.eventNames()).toEqual([])
}
@@ -134,168 +107,77 @@ describe('filesystem watcher renderer document ownership', () => {
}
})
it.each(['local', 'ssh'])(
'does not revive a cancelled %s setup for a joiner whose document reloaded',
async (kind) => {
const sender = new Sender(1)
const joiner = new Sender(2)
const args = { worktreePath: '/folder', ...(kind === 'ssh' ? { connectionId: 'ssh' } : {}) }
const install = deferred<ReturnType<typeof localRoot> & (() => void)>()
const setup = kind === 'ssh' ? watchRemote : createLocal
setup.mockReturnValueOnce(install.promise)
const first = watch(sender, args)
await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(1))
await unwatch(sender, args)
await Promise.resolve()
const key =
kind === 'ssh' ? getRemoteWatcherKey('ssh', '/folder') : getLocalWatcherRoot('/folder').key
const token =
kind === 'ssh'
? state.inFlightRemoteInstalls.get(key)
: state.inFlightLocalInstalls.get(key)
expect(token?.abortController.signal.aborted).toBe(true)
const pendingJoiner = watch(joiner, args)
joiner.emit('did-navigate')
const fresh = watch(joiner, { ...args, worktreePath: '/fresh' })
const closeLate = vi.fn(async () => {})
install.resolve(Object.assign(closeLate, localRoot(closeLate)))
await Promise.all([first, pendingJoiner, fresh])
expect(setup.mock.calls.filter(([path]) => path === '/folder')).toHaveLength(1)
expect(closeLate).toHaveBeenCalledTimes(1)
expect(kind === 'ssh' ? state.remoteWatchers.has(key) : state.watchedRoots.has(key)).toBe(
false
)
expect(setup).toHaveBeenCalledTimes(2)
}
)
it.each(['local', 'ssh'])(
'aborts pending %s setup on renderer crash and discards late success',
async (kind) => {
const sender = new Sender(1)
const setup = kind === 'ssh' ? watchRemote : createLocal
const install = deferred<ReturnType<typeof localRoot> & (() => void)>()
setup.mockReturnValueOnce(install.promise)
const pending = watch(sender, {
worktreePath: '/folder',
...(kind === 'ssh' ? { connectionId: 'ssh' } : {})
})
await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(1))
const token = [
...(kind === 'ssh' ? state.inFlightRemoteInstalls : state.inFlightLocalInstalls).values()
][0]
sender.emit('render-process-gone')
await Promise.resolve()
expect(token.abortController.signal.aborted).toBe(true)
const closeLate = vi.fn(async () => {})
install.resolve(Object.assign(closeLate, localRoot(closeLate)))
await pending
expect(closeLate).toHaveBeenCalledTimes(1)
expect(state.watchedRoots.size + state.remoteWatchers.size).toBe(0)
}
)
it('does not re-arm stale SSH handler or retry snapshots after reload', async () => {
vi.useFakeTimers()
it('does not revive a cancelled setup for a joiner whose document reloaded', async () => {
const sender = new Sender(1)
const args = { worktreePath: '/folder', connectionId: 'ssh' }
watchRemote.mockRejectedValueOnce(new Error('temporary unavailable'))
await watch(sender, args)
const retry = deferred<() => void>()
watchRemote.mockReturnValueOnce(retry.promise)
await vi.advanceTimersByTimeAsync(REMOTE_WATCH_RETRY_MS)
expect(watchRemote).toHaveBeenCalledTimes(2)
sender.emit('did-navigate')
// A fresh document can ask for the same root while the old retry is settling.
const fresh = watch(sender, args)
retry.resolve(vi.fn())
await fresh
expect(state.pendingRemoteWatcherRetries.size).toBe(0)
expect(state.remoteWatcherResyncStates.size).toBe(0)
expect(watchRemote).toHaveBeenCalledTimes(2)
})
it('does not re-arm an old provider snapshot into the same WebContents after replacement', async () => {
const sender = new Sender(1)
const args = { worktreePath: '/folder', connectionId: 'ssh' }
await watch(sender, args)
const pending = deferred<'unavailable'>()
const dependencies = {
install: vi.fn(() => pending.promise),
requestResync: vi.fn(),
scheduleRetry: vi.fn(),
scheduleDormant: vi.fn()
}
reinstallRemoteWatchersForConnectionCore('ssh', dependencies)
sender.emit('did-navigate')
await watch(sender, args)
pending.resolve('unavailable')
await pending.promise
const joiner = new Sender(2)
const args = { worktreePath: '/folder' }
const install = deferred<ReturnType<typeof localRoot>>()
createLocal.mockReturnValueOnce(install.promise)
const first = watch(sender, args)
await vi.waitFor(() => expect(createLocal).toHaveBeenCalledTimes(1))
await unwatch(sender, args)
await Promise.resolve()
expect(dependencies.scheduleRetry).not.toHaveBeenCalled()
expect(dependencies.requestResync).not.toHaveBeenCalled()
expect(dependencies.scheduleDormant).not.toHaveBeenCalled()
const key = getLocalWatcherRoot('/folder').key
expect(state.inFlightLocalInstalls.get(key)?.abortController.signal.aborted).toBe(true)
const pendingJoiner = watch(joiner, args)
joiner.emit('did-navigate')
const fresh = watch(joiner, { ...args, worktreePath: '/fresh' })
const closeLate = vi.fn(async () => {})
install.resolve(localRoot(closeLate))
await Promise.all([first, pendingJoiner, fresh])
expect(createLocal.mock.calls.filter(([path]) => path === '/folder')).toHaveLength(1)
expect(closeLate).toHaveBeenCalledTimes(1)
expect(state.watchedRoots.has(key)).toBe(false)
expect(createLocal).toHaveBeenCalledTimes(2)
})
it.each(['local', 'ssh'])(
'does not restore a replaced sibling document after an awaited %s removal recovery',
async (kind) => {
const first = new Sender(1)
const sibling = new Sender(2)
const args = { worktreePath: '/folder', ...(kind === 'ssh' ? { connectionId: 'ssh' } : {}) }
await watch(first, args)
await watch(sibling, args)
await (kind === 'ssh'
? closeRemoteWatcherForWorktreePath('ssh', '/folder')
: closeLocalWatcherForWorktreePath('/folder'))
const install = deferred<ReturnType<typeof localRoot> & (() => void)>()
const setup = kind === 'ssh' ? watchRemote : createLocal
setup.mockReturnValueOnce(install.promise)
const restore =
kind === 'ssh'
? restoreRemoteWatcherAfterFailedRemoval('ssh', '/folder')
: restoreLocalWatcherAfterFailedRemoval('/folder')
await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(2))
sibling.emit('did-navigate')
install.resolve(Object.assign(vi.fn(), localRoot()))
await restore
expect(sibling.send).not.toHaveBeenCalled()
expect(first.send).toHaveBeenCalledTimes(1)
const roots = kind === 'ssh' ? state.remoteWatchers : state.watchedRoots
expect([...roots.values()][0].listeners.size).toBe(1)
}
)
it('aborts pending setup on renderer crash and discards late success', async () => {
const sender = new Sender(1)
const install = deferred<ReturnType<typeof localRoot>>()
createLocal.mockReturnValueOnce(install.promise)
const pending = watch(sender, { worktreePath: '/folder' })
await vi.waitFor(() => expect(createLocal).toHaveBeenCalledTimes(1))
const token = [...state.inFlightLocalInstalls.values()][0]
sender.emit('render-process-gone')
await Promise.resolve()
expect(token.abortController.signal.aborted).toBe(true)
const closeLate = vi.fn(async () => {})
install.resolve(localRoot(closeLate))
await pending
expect(closeLate).toHaveBeenCalledTimes(1)
expect(state.watchedRoots.size).toBe(0)
})
it('does not restore a replaced sibling document after an awaited removal recovery', async () => {
const first = new Sender(1)
const sibling = new Sender(2)
const args = { worktreePath: '/folder' }
await watch(first, args)
await watch(sibling, args)
await closeLocalWatcherForWorktreePath('/folder')
const install = deferred<ReturnType<typeof localRoot>>()
createLocal.mockReturnValueOnce(install.promise)
const restore = restoreLocalWatcherAfterFailedRemoval('/folder')
await vi.waitFor(() => expect(createLocal).toHaveBeenCalledTimes(2))
sibling.emit('did-navigate')
install.resolve(localRoot())
await restore
expect(sibling.send).not.toHaveBeenCalled()
expect(first.send).toHaveBeenCalledTimes(1)
expect([...state.watchedRoots.values()][0].listeners.size).toBe(1)
})
it('retains zero roots and listeners across 15 reloads and a renderer crash', async () => {
const sender = new Sender(1)
const closeLocal = vi.fn(async () => {})
const closeRemote = vi.fn()
createLocal.mockImplementation(async () => localRoot(closeLocal))
watchRemote.mockResolvedValue(closeRemote)
for (let generation = 0; generation < 16; generation++) {
await watch(sender, { worktreePath: `/folder-${generation}` })
await watch(sender, { worktreePath: `/folder-${generation}`, connectionId: 'ssh' })
sender.emit(generation === 15 ? 'render-process-gone' : 'did-navigate')
await Promise.resolve()
expect(
state.watchedRoots.size + state.remoteWatchers.size + state.desiredRemoteWatchers.size
).toBe(0)
expect(state.watchedRoots.size).toBe(0)
expect(sender.eventNames()).toEqual([])
}
expect(closeLocal).toHaveBeenCalledTimes(16)
expect(closeRemote).toHaveBeenCalledTimes(16)
})
it('clears desired SSH roots while no provider is available and does not re-arm them on reconnect', async () => {
const sender = new Sender(1)
getProvider.mockReturnValue(undefined)
await watch(sender, { worktreePath: '/folder', connectionId: 'ssh' })
expect(state.pendingRemoteWatcherRetries.size).toBe(1)
sender.emit('render-process-gone')
getProvider.mockReturnValue({ watch: watchRemote })
reinstallRemoteWatchersForConnection('ssh')
expect(state.pendingRemoteWatcherRetries.size).toBe(0)
expect(state.desiredRemoteWatchers.size).toBe(0)
expect(watchRemote).not.toHaveBeenCalled()
})
})
@@ -1,174 +0,0 @@
import { senderEvents } from './filesystem-watcher-test-sender'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
const WORKTREE_PATH = '/home/me/repo'
const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' }
const OVERFLOW_PAYLOAD = {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }],
resync: true,
connectionId: 'conn-1'
}
const RETRY_GIVE_UP_MS = 61_000
const DORMANT_FIRST_MS = 60_000
function createSender(id: number): {
isDestroyed: () => boolean
send: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
once: ReturnType<typeof vi.fn>
id: number
} {
return { isDestroyed: () => false, send: vi.fn(), ...senderEvents(), id }
}
describe('remote filesystem watcher dormant re-arm', () => {
const handlers: HandlerMap = {}
let warnSpy: ReturnType<typeof vi.spyOn>
afterEach(() => {
warnSpy.mockRestore()
})
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
/** Install a live watch, kill it terminally, then burn the 1s/60s fast retry window out. */
async function installThenGiveUp(
sender: ReturnType<typeof createSender>,
watchAfterDeath: ReturnType<typeof vi.fn>
): Promise<void> {
let onTerminalError: (error: Error) => void = () => {}
getSshFilesystemProviderMock.mockReturnValue({
watch: vi.fn().mockImplementation((_path, _callback, options) => {
onTerminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
})
await handlers['fs:watchWorktree']({ sender }, ARGS)
// The SSH link stays up: only the remote watcher dies (inotify exhaustion, relay watcher killed).
getSshFilesystemProviderMock.mockReturnValue({ watch: watchAfterDeath })
onTerminalError(new Error('remote watcher died'))
await vi.advanceTimersByTimeAsync(RETRY_GIVE_UP_MS)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
sender.send.mockClear()
}
it('re-arms after the fast retry window gives up, with no reconnect to trigger it', async () => {
vi.useFakeTimers()
const sender = createSender(1)
const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached'))
await installThenGiveUp(sender, failingWatch)
const recoveredWatch = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: recoveredWatch })
await vi.advanceTimersByTimeAsync(DORMANT_FIRST_MS)
expect(recoveredWatch).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
await closeAllWatchers()
vi.useRealTimers()
})
it('backs the re-arm off instead of hammering a host that keeps refusing', async () => {
vi.useFakeTimers()
const sender = createSender(1)
const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached'))
await installThenGiveUp(sender, failingWatch)
failingWatch.mockClear()
await vi.advanceTimersByTimeAsync(DORMANT_FIRST_MS)
expect(failingWatch).toHaveBeenCalledTimes(1)
// Half an hour of a permanently broken remote watcher must stay in single digits, not 1s retries.
await vi.advanceTimersByTimeAsync(30 * 60_000)
expect(failingWatch.mock.calls.length).toBeLessThanOrEqual(6)
expect(failingWatch.mock.calls.length).toBeGreaterThan(1)
await closeAllWatchers()
vi.useRealTimers()
})
it('stops re-arming once the renderer unwatches', async () => {
vi.useFakeTimers()
const sender = createSender(1)
const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached'))
await installThenGiveUp(sender, failingWatch)
failingWatch.mockClear()
handlers['fs:unwatchWorktree']({ sender }, ARGS)
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(failingWatch).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
it('leaves a dropped connection to the provider-registration re-arm', async () => {
vi.useFakeTimers()
const sender = createSender(1)
const failingWatch = vi.fn().mockRejectedValue(new Error('relay gone'))
await installThenGiveUp(sender, failingWatch)
failingWatch.mockClear()
// Provider gone == connection down; its registration is the cheaper trigger, so don't poll.
getSshFilesystemProviderMock.mockReturnValue(undefined)
await vi.advanceTimersByTimeAsync(60 * 60_000)
const recoveredWatch = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: recoveredWatch })
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(recoveredWatch).not.toHaveBeenCalled()
for (const listener of providerRegistrationListeners) {
listener('conn-1')
}
await vi.advanceTimersByTimeAsync(0)
expect(recoveredWatch).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
await closeAllWatchers()
vi.useRealTimers()
})
})
+4 -95
View File
@@ -1,77 +1,17 @@
import { ipcMain } from 'electron'
import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime'
import { onSshFilesystemProviderRegistered } from '../providers/ssh-filesystem-dispatch'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
import {
cancelInFlightRemoteInstallIfUnowned,
forgetCancelledInstallWaiter,
forgetDesiredRemoteWatcher,
registerWatcherSenderCleanup,
releaseRemoteWatchListener
} from './filesystem-watcher-listener-lifecycle'
import {
installRemoteWatcher,
reinstallRemoteWatchersForConnection,
scheduleDormantRemoteWatcherRearm,
scheduleRemoteWatcherRetry
} from './filesystem-watcher-remote-controller'
import { rememberDesiredRemoteWatcher } from './filesystem-watcher-remote-desired'
import {
subscribeLocalWatcher,
unsubscribeLocalWatcher
} from './filesystem-watcher-local-subscription'
export function registerFilesystemWatcherHandlers(): void {
// Why: re-registration replaces the handler set, so drop the previous subscription instead of
// stacking a second re-arm on every provider registration.
watcherLifecycleState.unsubscribeFromProviderRegistrations?.()
watcherLifecycleState.unsubscribeFromProviderRegistrations = onSshFilesystemProviderRegistered(
reinstallRemoteWatchersForConnection
)
ipcMain.handle(
'fs:watchWorktree',
async (event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
const senderSignal = registerWatcherSenderCleanup(event.sender)
// Why: an SSH path names a remote file; the desktop has no SSH filesystem transport, and
// managed-server workspaces watch through the runtime stream instead of this channel.
if (args.connectionId) {
// Why: a real new watch reopens the subsystem after closeAllWatchers latched it shut (also resets tests between cases).
watcherLifecycleState.remoteWatchersClosed = false
const key = getRemoteWatcherKey(args.connectionId, args.worktreePath)
// Why: record intent before the install so a provider registering mid-flight (or long after
// this attempt gives up) can still re-arm this listener.
rememberDesiredRemoteWatcher(args.connectionId, args.worktreePath, event.sender)
const result = await installRemoteWatcher(
event.sender,
args.connectionId,
args.worktreePath
)
if (!isCurrentWatcherSender(event.sender, senderSignal)) {
return
}
if (result === 'capacity') {
// Why straight to the dormant backoff: the cap is full until some other root is released,
// which a 1 Hz reinstall cannot bring about — it only adds relay load per refused root.
scheduleDormantRemoteWatcherRearm(args.connectionId, args.worktreePath)
return
}
if (result === 'unavailable') {
if (!watcherLifecycleState.loggedUnavailableRemoteWatchers.has(key)) {
watcherLifecycleState.loggedUnavailableRemoteWatchers.add(key)
console.warn(
`[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}`
)
}
// Why: writes before the retry lands were never reported, and editor tabs may be stale from an earlier unwatched spell.
scheduleRemoteWatcherRetry(
event.sender,
args.connectionId,
args.worktreePath,
undefined,
true
)
return
}
return
}
// Why: reopen the local subsystem for tests and post-shutdown reattachment; stale callers keep the prior generation.
@@ -82,42 +22,11 @@ export function registerFilesystemWatcherHandlers(): void {
ipcMain.handle(
'fs:unwatchWorktree',
(_event, args: { worktreePath: string; connectionId?: string }): void => {
(event, args: { worktreePath: string; connectionId?: string }): void => {
if (args.connectionId) {
const key = getRemoteWatcherKey(args.connectionId, args.worktreePath)
// Why: the caller stopped watching on purpose — drop the intent or a later provider
// registration would resurrect a watch nobody asked for.
forgetDesiredRemoteWatcher(key, _event.sender.id)
forgetCancelledInstallWaiter(
watcherLifecycleState.cancelledRemoteInstallWaiters,
key,
_event.sender.id
)
const suspended = watcherLifecycleState.suspendedRemoteWatcherListeners.get(key)
suspended?.listeners.delete(_event.sender.id)
if (suspended?.listeners.size === 0) {
watcherLifecycleState.suspendedRemoteWatcherListeners.delete(key)
}
const retry = watcherLifecycleState.pendingRemoteWatcherRetryListeners.get(key)
retry?.listeners.delete(_event.sender.id)
const retryTimer = watcherLifecycleState.pendingRemoteWatcherRetries.get(key)
if (retryTimer && retry?.listeners.size === 0) {
clearTimeout(retryTimer)
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
}
// Why: a retry-tick provider.watch() may still be in flight; mark cancelled so its resolved unwatch handle is discarded.
const inFlight = watcherLifecycleState.inFlightRemoteInstalls.get(key)
if (inFlight) {
inFlight.listeners.delete(_event.sender.id)
cancelInFlightRemoteInstallIfUnowned(inFlight)
}
watcherLifecycleState.loggedUnavailableRemoteWatchers.delete(key)
releaseRemoteWatchListener(key, _event?.sender?.id ?? 0)
return
}
const senderId = _event.sender.id
unsubscribeLocalWatcher(args.worktreePath, senderId)
unsubscribeLocalWatcher(args.worktreePath, event.sender.id)
}
)
}
@@ -24,11 +24,6 @@ vi.mock('./filesystem-watcher-wsl', () => ({
createWslWatcher: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { stat } from 'node:fs/promises'
@@ -15,11 +15,6 @@ vi.mock('./parcel-watcher-process', () => ({
disposeWatcherProcess: vi.fn()
}))
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { desktopWorktreeWatcherRemoval } from './filesystem-watcher'
@@ -1,6 +1,5 @@
import type { WebContents } from 'electron'
import type { WatchedRoot } from './filesystem-watcher-wsl'
import type { RemoteWatcherEventBatch } from './remote-watcher-event-batch'
// ── Per-root watcher state ───────────────────────────────────────────
// WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape.
@@ -20,46 +19,10 @@ export type LocalWatcherCapacityRetry = {
cancelWait: () => void
}
export type RemoteWatcherInstallToken = {
cancelled: boolean
listeners: Map<number, WebContents>
abortController: AbortController
abortScheduled: boolean
terminalError?: Error
}
export type RemoteWatcherState = {
unwatch: () => void
listeners: Map<number, WebContents>
installToken: RemoteWatcherInstallToken
/** Held so every teardown can drop the trailing flush timer instead of letting it fire into a dead watch. */
batch: RemoteWatcherEventBatch
}
// Why 'capacity' is not 'unavailable': the relay refused because its watch-root cap is full, which is
// a decision, not a fault. The 1 Hz unavailable retry cannot change that answer, and a folder
// workspace whose repo count exceeds the cap turns it into a permanent per-root storm (#11196).
export type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'capacity' | 'cancelled'
export type RemoteWatcherResyncState = {
lastSentAt: number
listeners: Map<number, WebContents>
timer?: ReturnType<typeof setTimeout>
worktreePath: string
}
// Why: cache roots that failed watcher creation (e.g. WSL UNC paths) so we don't retry every worktree switch and spam the console with errors.
export const UNWATCHABLE_ROOT_CACHE_MAX = 256
// Why: recreating @parcel/watcher on Windows is expensive (ReadDirectoryChangesW + AV, 500ms+); a 30s grace lets rapid switches reuse the watcher.
export const WATCHER_TEARDOWN_GRACE_MS = 30_000
export const REMOTE_WATCH_RETRY_MS = 1_000
export const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000
// Why: preserve the first and latest resync while bounding full-tree SSH refreshes during flaps.
export const REMOTE_WATCH_RESYNC_COALESCE_MS = 5_000
// Why: doubling from a minute to a half-hour ceiling costs a permanently broken remote ~7 fs.watch
// calls in the first hour and 2/hour after, which a flapping link can absorb.
export const REMOTE_WATCH_DORMANT_RETRY_MS = 60_000
export const REMOTE_WATCH_DORMANT_RETRY_MAX_MS = 30 * 60_000
// Why: a sender waiting out a cancelled install is in no listener map; unwatch and removal still need to reach it.
export type CancelledInstallWaiter = { token: symbol; sender: WebContents }
@@ -90,44 +53,5 @@ export const watcherLifecycleState = {
inFlightLocalInstalls: new Map<string, LocalWatcherInstallToken>(),
pendingLocalInstallPromises: new Map<string, Promise<LocalWatcherInstallResult>>(),
pendingLocalCapacityRetries: new Map<string, LocalWatcherCapacityRetry>(),
cancelledLocalInstallWaiters: new Map<string, Map<number, CancelledInstallWaiter>>(),
// Remote watcher state
// Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state.
remoteWatchers: new Map<string, RemoteWatcherState>(),
suspendedRemoteWatcherListeners: new Map<
string,
{ connectionId: string; worktreePath: string; listeners: Map<number, WebContents> }
>(),
// Why: the renderer subscribes once per target and never re-issues, so the intent to watch has to
// outlive any single connection — an install that failed or died with a dropped transport is
// re-armed from here when a provider appears. Without it a reconnect (or a connect slower than the
// retry window) leaves the watch dead until the app restarts.
desiredRemoteWatchers: new Map<
string,
{ connectionId: string; worktreePath: string; listeners: Map<number, WebContents> }
>(),
// Why: provider registration only fires on reconnect, so a watch that dies while the SSH link stays
// healthy (remote OOM, inotify/fd exhaustion, relay watcher killed) has no re-arm trigger at all
// once the fast window gives up. Backoff keeps the recovery attempt without the 1s storm.
dormantRemoteWatchers: new Map<
string,
{ delayMs: number; timer: ReturnType<typeof setTimeout> }
>(),
loggedUnavailableRemoteWatchers: new Set<string>(),
pendingRemoteWatcherRetries: new Map<string, ReturnType<typeof setTimeout>>(),
pendingRemoteWatcherRetryListeners: new Map<
string,
{ listeners: Map<number, WebContents>; startedAt: number; resyncOnInstall: boolean }
>(),
remoteWatcherResyncStates: new Map<string, RemoteWatcherResyncState>(),
// Why: last-listener cleanup aborts relay setup; late success is unwatched rather than installed after the renderer stopped watching.
inFlightRemoteInstalls: new Map<string, RemoteWatcherInstallToken>(),
// Why: dedupe concurrent installRemoteWatcher calls per key so overlapping watches share one watcher instead of clobbering per-key state.
pendingRemoteInstallPromises: new Map<string, Promise<RemoteWatcherInstallResult>>(),
cancelledRemoteInstallWaiters: new Map<string, Map<number, CancelledInstallWaiter>>(),
// Why: block installs beginning after closeAllWatchers (joiner recursion / retry tick bypass the abort loop); a new fs:watchWorktree clears it.
remoteWatchersClosed: false,
// Why: closeAllWatchers bumps this so a joiner that awaited across shutdown+reopen is refused (the latch alone can't tell it from a fresh call).
remoteWatcherLifecycleGeneration: 0,
unsubscribeFromProviderRegistrations: null as (() => void) | null
cancelledLocalInstallWaiters: new Map<string, Map<number, CancelledInstallWaiter>>()
}
@@ -5,7 +5,6 @@ import {
type CancelledInstallWaiter,
type CancelledInstallWaiters,
type LocalWatcherInstallToken,
type RemoteWatcherInstallToken,
UNWATCHABLE_ROOT_CACHE_MAX,
watcherLifecycleState
} from './filesystem-watcher-lifecycle-state'
@@ -125,56 +124,6 @@ export function addLocalWatchListener(rootKey: string, sender: WebContents): voi
registerWatcherSenderCleanup(sender)
}
export function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void {
token.cancelled = token.listeners.size === 0
if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) {
return
}
token.abortScheduled = true
// Why: a replacement sender can synchronously revive the shared install during a renderer handoff; otherwise stop the relay crawl next microtask.
queueMicrotask(() => {
token.abortScheduled = false
if (token.cancelled && token.listeners.size === 0) {
token.abortController.abort()
}
})
}
export function addInFlightRemoteInstallListener(
token: RemoteWatcherInstallToken,
sender: WebContents
): void {
if (sender.isDestroyed() || token.abortController.signal.aborted) {
return
}
token.listeners.set(sender.id, sender)
token.cancelled = false
registerWatcherSenderCleanup(sender)
}
export function addRemoteWatchListener(key: string, sender: WebContents): void {
const state = watcherLifecycleState.remoteWatchers.get(key)
if (!state) {
return
}
state.listeners.set(sender.id, sender)
registerWatcherSenderCleanup(sender)
}
export function releaseRemoteWatchListener(key: string, senderId: number): void {
const state = watcherLifecycleState.remoteWatchers.get(key)
if (!state) {
return
}
state.listeners.delete(senderId)
if (state.listeners.size > 0) {
return
}
state.unwatch()
state.batch.close()
watcherLifecycleState.remoteWatchers.delete(key)
}
export function addCancelledInstallWaiter(
waitersByKey: CancelledInstallWaiters,
key: string,
@@ -216,7 +165,6 @@ export function forgetCancelledInstallWaiter(
export function registerWatcherSenderCleanup(sender: WebContents): AbortSignal {
return captureWatcherSenderLifetime(sender, () => {
cleanupLocalWatchersForSender(sender.id)
cleanupRemoteWatchersForSender(sender.id)
})
}
@@ -249,68 +197,3 @@ function cleanupLocalWatchersForSender(senderId: number): void {
}
}
}
function cleanupRemoteWatchersForSender(senderId: number): void {
for (const key of Array.from(watcherLifecycleState.cancelledRemoteInstallWaiters.keys())) {
forgetCancelledInstallWaiter(watcherLifecycleState.cancelledRemoteInstallWaiters, key, senderId)
}
for (const key of Array.from(watcherLifecycleState.desiredRemoteWatchers.keys())) {
forgetDesiredRemoteWatcher(key, senderId)
}
for (const resync of watcherLifecycleState.remoteWatcherResyncStates.values()) {
resync.listeners.delete(senderId)
}
for (const [key, suspended] of watcherLifecycleState.suspendedRemoteWatcherListeners) {
suspended.listeners.delete(senderId)
if (suspended.listeners.size === 0) {
watcherLifecycleState.suspendedRemoteWatcherListeners.delete(key)
}
}
for (const token of watcherLifecycleState.inFlightRemoteInstalls.values()) {
token.listeners.delete(senderId)
cancelInFlightRemoteInstallIfUnowned(token)
}
for (const [key, retry] of watcherLifecycleState.pendingRemoteWatcherRetryListeners) {
retry.listeners.delete(senderId)
if (retry.listeners.size === 0) {
const timer = watcherLifecycleState.pendingRemoteWatcherRetries.get(key)
if (timer) {
clearTimeout(timer)
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
}
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
}
}
for (const key of Array.from(watcherLifecycleState.remoteWatchers.keys())) {
releaseRemoteWatchListener(key, senderId)
}
}
export function clearRemoteWatcherResync(key: string): void {
const resync = watcherLifecycleState.remoteWatcherResyncStates.get(key)
if (resync?.timer) {
clearTimeout(resync.timer)
}
watcherLifecycleState.remoteWatcherResyncStates.delete(key)
}
export function clearDormantRemoteWatcher(key: string): void {
const dormant = watcherLifecycleState.dormantRemoteWatchers.get(key)
if (dormant) {
clearTimeout(dormant.timer)
watcherLifecycleState.dormantRemoteWatchers.delete(key)
}
}
export function forgetDesiredRemoteWatcher(key: string, senderId: number): void {
const desired = watcherLifecycleState.desiredRemoteWatchers.get(key)
if (!desired) {
return
}
desired.listeners.delete(senderId)
if (desired.listeners.size === 0) {
watcherLifecycleState.desiredRemoteWatchers.delete(key)
clearRemoteWatcherResync(key)
clearDormantRemoteWatcher(key)
}
}
@@ -32,11 +32,6 @@ vi.mock('./parcel-watcher-process', async (importOriginal) => {
}
})
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import {
closeLocalWatcherForWorktreePath,
@@ -16,11 +16,6 @@ vi.mock('./parcel-watcher-process', () => ({
disposeWatcherProcess: disposeWatcherProcessMock
}))
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import {
-13
View File
@@ -33,16 +33,3 @@ export function normalizeWatcherEventPath(eventPath: string): string {
}
return resolved
}
export function getRemoteWatcherKey(connectionId: string, worktreePath: string): string {
return JSON.stringify([connectionId, normalizeRuntimePathForComparison(worktreePath)])
}
export function getRemoteWatcherKeyConnectionId(key: string): string | undefined {
try {
const parsed: unknown = JSON.parse(key)
return Array.isArray(parsed) && typeof parsed[0] === 'string' ? parsed[0] : undefined
} catch {
return undefined
}
}
@@ -1,199 +0,0 @@
import { senderEvents } from './filesystem-watcher-test-sender'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../shared/filesystem-entry-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 } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
type HandlerMap = Record<string, (_event: unknown, args: 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(),
...senderEvents(),
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` }],
connectionId: 'conn-1'
})
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` }],
connectionId: 'conn-1'
})
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 }],
connectionId: 'conn-1'
})
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()
})
})
@@ -1,341 +0,0 @@
import { createWatcherSender } from './filesystem-watcher-test-sender'
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,
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('remote filesystem watcher cancellation', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => {
let installSignal: AbortSignal | undefined
let resolveInstall: ((unwatch: () => void) => void) | undefined
const lateUnwatch = vi.fn()
const watchMock = vi.fn(
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((resolve) => {
installSignal = options?.signal
resolveInstall = resolve
})
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const senderOne = createWatcherSender(1)
const senderTwo = createWatcherSender(2)
const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
await Promise.resolve()
try {
expect(watchMock).toHaveBeenCalledTimes(1)
expect(installSignal?.aborted).toBe(false)
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
await Promise.resolve()
expect(installSignal?.aborted).toBe(false)
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
await Promise.resolve()
expect(installSignal?.aborted).toBe(true)
} finally {
resolveInstall?.(lateUnwatch)
await Promise.all([first, second])
}
expect(lateUnwatch).toHaveBeenCalledTimes(1)
})
it('starts a fresh same-root generation when a listener arrives after physical abort', async () => {
let firstSignal: AbortSignal | undefined
let secondCallback: ((events: unknown[]) => void) | undefined
const secondUnwatch = vi.fn()
const watchMock = vi
.fn()
.mockImplementationOnce(
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((_resolve, reject) => {
firstSignal = options?.signal
options?.signal?.addEventListener(
'abort',
() => {
const error = new Error('cancelled')
error.name = 'AbortError'
reject(error)
},
{ once: true }
)
})
)
.mockImplementationOnce((_rootPath, callback) => {
secondCallback = callback
return Promise.resolve(secondUnwatch)
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const firstSender = createWatcherSender(1)
const secondSender = createWatcherSender(2)
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
await Promise.resolve()
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise<unknown>
await Promise.all([first, second])
expect(watchMock).toHaveBeenCalledTimes(2)
secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
// Why: remote fs:changed rides the shared debounce window, so the send lands a flush later.
await vi.waitFor(() => expect(secondSender.send).toHaveBeenCalledTimes(1))
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
expect(secondUnwatch).toHaveBeenCalledTimes(1)
})
it('drops a re-watch that was unwatched while waiting out a cancelled SSH install', async () => {
let resolveFirst: ((unwatch: () => void) => void) | undefined
let firstSignal: AbortSignal | undefined
const lateUnwatch = vi.fn()
const watchMock = vi
.fn()
.mockImplementationOnce(
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((resolve) => {
firstSignal = options?.signal
resolveFirst = resolve
})
)
.mockImplementation(() => Promise.resolve(vi.fn()))
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const sender = createWatcherSender(1)
const first = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args))
await Promise.resolve()
handlers['fs:unwatchWorktree']({ sender }, args)
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
const second = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args))
await Promise.resolve()
handlers['fs:unwatchWorktree']({ sender }, args)
resolveFirst?.(lateUnwatch)
await Promise.all([first, second])
expect(watchMock).toHaveBeenCalledTimes(1)
expect(lateUnwatch).toHaveBeenCalledTimes(1)
expect(
watcherLifecycleState.remoteWatchers.get(getRemoteWatcherKey('conn-1', args.worktreePath))
).toBeUndefined()
})
it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => {
const installs = new Map<
string,
{ signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void }
>()
const watchMock = vi.fn(
(rootPath: string, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((resolve) => {
installs.set(rootPath, { signal: options?.signal, resolve })
})
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const destroyedCallbacks: (() => void)[] = []
const destroyedSender = {
isDestroyed: () => false,
send: vi.fn(),
removeListener: vi.fn(),
once: vi.fn((event: string, callback: () => void) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
}),
id: 1
}
const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' }
const destroyedWatch = handlers['fs:watchWorktree'](
{ sender: destroyedSender },
destroyedArgs
) as Promise<unknown>
await Promise.resolve()
destroyedCallbacks[0]()
await Promise.resolve()
expect(installs.get('/destroyed')?.signal?.aborted).toBe(true)
installs.get('/destroyed')?.resolve(vi.fn())
await destroyedWatch
const shutdownSender = createWatcherSender(2)
const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' }
const shutdownWatch = handlers['fs:watchWorktree'](
{ sender: shutdownSender },
shutdownArgs
) as Promise<unknown>
await Promise.resolve()
await closeAllWatchers()
expect(installs.get('/shutdown')?.signal?.aborted).toBe(true)
installs.get('/shutdown')?.resolve(vi.fn())
await shutdownWatch
})
it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => {
let installSignal: AbortSignal | undefined
let resolveInstall: ((unwatch: () => void) => void) | undefined
const watchMock = vi.fn(
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((resolve) => {
installSignal = options?.signal
resolveInstall = resolve
})
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const senderOne = createWatcherSender(1)
const senderTwo = createWatcherSender(2)
const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
// The last listener leaves and a replacement joins in the SAME tick — before
// the queued abort microtask runs. The shared install must survive.
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
await Promise.resolve()
await Promise.resolve()
expect(installSignal?.aborted).toBe(false)
expect(watchMock).toHaveBeenCalledTimes(1)
resolveInstall?.(vi.fn())
await Promise.all([first, second])
})
it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => {
let firstSignal: AbortSignal | undefined
let resolveFirst: ((unwatch: () => void) => void) | undefined
const lateUnwatch = vi.fn()
const watchMock = vi.fn(
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((resolve) => {
firstSignal = options?.signal
resolveFirst = resolve
})
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const firstSender = createWatcherSender(1)
const secondSender = createWatcherSender(2)
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
// Last listener leaves -> deferred abort fires while the install is still pending.
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
// Joiner arrives after physical abort (canJoinInstall === false); it awaits the
// 'cancelled' resolution and would recurse into a fresh install.
const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise<unknown>
await Promise.resolve()
// Shutdown latches the subsystem before the joiner's recursion runs.
await closeAllWatchers()
// Late success of the aborted generation must be unwatched, not registered.
resolveFirst?.(lateUnwatch)
await Promise.all([first, second])
// The recursion is refused post-shutdown: provider.watch() is never called again.
expect(watchMock).toHaveBeenCalledTimes(1)
expect(lateUnwatch).toHaveBeenCalledTimes(1)
})
it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => {
const installs = new Map<
string,
{ signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void }
>()
const watchMock = vi.fn(
(rootPath: string, _callback, options?: { signal?: AbortSignal }) =>
new Promise<() => void>((resolve) => {
installs.set(rootPath, { signal: options?.signal, resolve })
})
)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' }
const lateUnwatch = vi.fn()
const firstSender = createWatcherSender(1)
const joinerSender = createWatcherSender(2)
const reopenSender = createWatcherSender(3)
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
// Last listener leaves -> deferred abort fires while the install is still pending.
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true))
// Joiner arrives after physical abort (canJoinInstall === false); it captures the
// current lifecycle generation and awaits the 'cancelled' resolution.
const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise<unknown>
await Promise.resolve()
// Shutdown bumps the generation, then a genuine new watch reopens the subsystem
// (clearing the boolean latch) before the joiner resumes.
await closeAllWatchers()
const reopen = handlers['fs:watchWorktree'](
{ sender: reopenSender },
reopenArgs
) as Promise<unknown>
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(2)
// Now let the aborted install resolve; the joiner recurses on the stale generation.
installs.get('/home/me/repo')?.resolve(lateUnwatch)
installs.get('/home/me/other')?.resolve(vi.fn())
await Promise.all([first, joiner, reopen])
// The joiner's recursion is refused despite the reopen: no third provider.watch().
expect(watchMock).toHaveBeenCalledTimes(2)
expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength(
1
)
expect(lateUnwatch).toHaveBeenCalledTimes(1)
})
})
@@ -1,93 +0,0 @@
import { createWatcherSender } from './filesystem-watcher-test-sender'
import { afterEach, 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,
onSshFilesystemProviderRegistered: () => () => {}
}))
import { WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE } from '../../shared/watch-root-capacity-refusal'
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('remote filesystem watcher capacity refusals', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
afterEach(async () => {
for (const dormant of watcherLifecycleState.dormantRemoteWatchers.values()) {
clearTimeout(dormant.timer)
}
watcherLifecycleState.dormantRemoteWatchers.clear()
await closeAllWatchers()
vi.useRealTimers()
})
// A folder workspace with more repos than the relay's watch-root cap leaves every excess root
// permanently refused; the 1 Hz unavailable ladder then bills the relay one install per root per
// second, which is the load that pinned it (#11196).
it('does not retry a relay watch-root capacity refusal on the fast ladder', async () => {
vi.useFakeTimers()
const watchMock = vi.fn(async () => {
throw new Error(WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE)
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const sender = createWatcherSender(1)
const args = { worktreePath: '/home/me/repos/one', connectionId: 'conn-capacity' }
await handlers['fs:watchWorktree']({ sender }, args)
const key = getRemoteWatcherKey(args.connectionId, args.worktreePath)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(watcherLifecycleState.pendingRemoteWatcherRetries.has(key)).toBe(false)
expect(watcherLifecycleState.dormantRemoteWatchers.has(key)).toBe(true)
await vi.advanceTimersByTimeAsync(10_000)
expect(watchMock).toHaveBeenCalledTimes(1)
})
it('still retries an ordinary unavailable install on the fast ladder', async () => {
vi.useFakeTimers()
const watchMock = vi.fn(async () => {
throw new Error('Relay channel lost')
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
const sender = createWatcherSender(2)
const args = { worktreePath: '/home/me/repos/two', connectionId: 'conn-unavailable' }
await handlers['fs:watchWorktree']({ sender }, args)
const key = getRemoteWatcherKey(args.connectionId, args.worktreePath)
expect(watcherLifecycleState.pendingRemoteWatcherRetries.has(key)).toBe(true)
await vi.advanceTimersByTimeAsync(2_500)
expect(watchMock.mock.calls.length).toBeGreaterThan(1)
})
})
@@ -1,100 +0,0 @@
import type { WebContents } from 'electron'
import type { RemoteWatcherInstallToken } from './filesystem-watcher-lifecycle-state'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { registerWatcherSenderCleanup } from './filesystem-watcher-listener-lifecycle'
import {
installRemoteWatcherCore,
type RemoteWatcherTerminalErrorHandler
} from './filesystem-watcher-remote-install'
import { requestRemoteWatcherResync } from './filesystem-watcher-remote-resync'
import { scheduleRemoteWatcherRetryCore } from './filesystem-watcher-remote-retry'
import { scheduleDormantRemoteWatcherRearmCore } from './filesystem-watcher-remote-dormant'
import { reinstallRemoteWatchersForConnectionCore } from './filesystem-watcher-remote-provider-rearm'
export function installRemoteWatcher(
sender: WebContents,
connectionId: string,
worktreePath: string,
generation = watcherLifecycleState.remoteWatcherLifecycleGeneration,
senderSignal = registerWatcherSenderCleanup(sender)
) {
return installRemoteWatcherCore(
sender,
connectionId,
worktreePath,
handleRemoteWatcherTerminalError,
generation,
senderSignal
)
}
export function scheduleRemoteWatcherRetry(
sender: WebContents,
connectionId: string,
worktreePath: string,
startedAt = Date.now(),
resyncOnInstall = false
): void {
scheduleRemoteWatcherRetryCore(
sender,
connectionId,
worktreePath,
{
install: installRemoteWatcher,
requestResync: requestRemoteWatcherResync,
scheduleDormant: scheduleDormantRemoteWatcherRearm
},
startedAt,
resyncOnInstall
)
}
export function scheduleDormantRemoteWatcherRearm(
connectionId: string,
worktreePath: string,
delayMs?: number
): void {
scheduleDormantRemoteWatcherRearmCore(
connectionId,
worktreePath,
{ install: installRemoteWatcher, requestResync: requestRemoteWatcherResync },
delayMs
)
}
export function reinstallRemoteWatchersForConnection(connectionId: string): void {
reinstallRemoteWatchersForConnectionCore(connectionId, {
install: installRemoteWatcher,
requestResync: requestRemoteWatcherResync,
scheduleRetry: scheduleRemoteWatcherRetry,
scheduleDormant: scheduleDormantRemoteWatcherRearm
})
}
const handleRemoteWatcherTerminalError: RemoteWatcherTerminalErrorHandler = (
key: string,
connectionId: string,
worktreePath: string,
installToken: RemoteWatcherInstallToken,
error: Error
): void => {
installToken.terminalError = error
const state = watcherLifecycleState.remoteWatchers.get(key)
if (!state || state.installToken !== installToken) {
return
}
watcherLifecycleState.remoteWatchers.delete(key)
state.batch.close()
// Why: a suspended snapshot means a removal or its restore owns these listeners; a retry would race it.
if (
watcherLifecycleState.remoteWatchersClosed ||
watcherLifecycleState.suspendedRemoteWatcherListeners.has(key)
) {
return
}
console.warn(`[filesystem-watcher] SSH watcher terminated for ${key}:`, error)
const startedAt = Date.now()
for (const listener of state.listeners.values()) {
scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, startedAt, true)
}
}
@@ -1,29 +0,0 @@
import type { WebContents } from 'electron'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
import { registerWatcherSenderCleanup } from './filesystem-watcher-listener-lifecycle'
export function rememberDesiredRemoteWatcher(
connectionId: string,
worktreePath: string,
sender: WebContents
): void {
if (sender.isDestroyed()) {
return
}
const key = getRemoteWatcherKey(connectionId, worktreePath)
const desired = watcherLifecycleState.desiredRemoteWatchers.get(key) ?? {
connectionId,
worktreePath,
listeners: new Map<number, WebContents>()
}
desired.listeners.set(sender.id, sender)
watcherLifecycleState.desiredRemoteWatchers.set(key, desired)
registerWatcherSenderCleanup(sender)
}
export function isCurrentDesiredRemoteWatcher(key: string, listener: WebContents): boolean {
return (
watcherLifecycleState.desiredRemoteWatchers.get(key)?.listeners.get(listener.id) === listener
)
}
@@ -1,125 +0,0 @@
import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime'
import { registerWatcherSenderCleanup } from './filesystem-watcher-listener-lifecycle'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { isWatcherRemovalInProgressError } from './watcher-removal-gate'
import type {
InstallRemoteWatcher,
RequestRemoteWatcherResync
} from './filesystem-watcher-remote-retry'
import {
REMOTE_WATCH_DORMANT_RETRY_MAX_MS,
REMOTE_WATCH_DORMANT_RETRY_MS,
watcherLifecycleState
} from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
type ScheduleDormantDependencies = {
install: InstallRemoteWatcher
requestResync: RequestRemoteWatcherResync
}
export function scheduleDormantRemoteWatcherRearmCore(
connectionId: string,
worktreePath: string,
dependencies: ScheduleDormantDependencies,
delayMs = REMOTE_WATCH_DORMANT_RETRY_MS
): void {
const key = getRemoteWatcherKey(connectionId, worktreePath)
if (
watcherLifecycleState.remoteWatchersClosed ||
!watcherLifecycleState.desiredRemoteWatchers.has(key) ||
watcherLifecycleState.dormantRemoteWatchers.has(key)
) {
return
}
const timer = setTimeout(() => {
watcherLifecycleState.dormantRemoteWatchers.delete(key)
void rearmDormantRemoteWatcher(key, connectionId, worktreePath, delayMs, dependencies)
}, delayMs)
// Why: a half-hour timer shouldn't be what keeps the process alive at quit.
timer.unref?.()
watcherLifecycleState.dormantRemoteWatchers.set(key, { delayMs, timer })
}
async function rearmDormantRemoteWatcher(
key: string,
connectionId: string,
worktreePath: string,
delayMs: number,
dependencies: ScheduleDormantDependencies
): Promise<void> {
const desired = watcherLifecycleState.desiredRemoteWatchers.get(key)
if (watcherLifecycleState.remoteWatchersClosed || !desired) {
return
}
for (const [senderId, sender] of Array.from(desired.listeners)) {
if (sender.isDestroyed()) {
desired.listeners.delete(senderId)
}
}
if (desired.listeners.size === 0) {
watcherLifecycleState.desiredRemoteWatchers.delete(key)
return
}
// Why: a live watch or an in-flight fast retry already owns this key; installing again would
// clobber the entry the running watch reads its listeners from.
if (
watcherLifecycleState.remoteWatchers.has(key) ||
watcherLifecycleState.pendingRemoteWatcherRetries.has(key)
) {
return
}
// Why: no provider means the connection itself is down, and its registration re-arms for free —
// polling would only add wire traffic to a link that is already being rebuilt.
if (!getSshFilesystemProvider(connectionId)) {
return
}
const listeners = Array.from(desired.listeners.values())
const signals = listeners.map(registerWatcherSenderCleanup)
let results: Awaited<ReturnType<InstallRemoteWatcher>>[]
try {
results = await Promise.all(
listeners.map((listener) => dependencies.install(listener, connectionId, worktreePath))
)
} catch (error) {
if (isWatcherRemovalInProgressError(error)) {
// Why: removal owns the key now and either forgets the intent or restores the watch itself.
return
}
if (!listeners.some((listener, index) => isCurrentWatcherSender(listener, signals[index]))) {
return
}
scheduleDormantRemoteWatcherRearmCore(
connectionId,
worktreePath,
dependencies,
nextDormantDelayMs(delayMs)
)
return
}
if (!listeners.some((listener, index) => isCurrentWatcherSender(listener, signals[index]))) {
return
}
dependencies.requestResync(
key,
worktreePath,
listeners.filter(
(listener, index) =>
results[index] === 'installed' && isCurrentWatcherSender(listener, signals[index])
)
)
// Why: 'cancelled' means shutdown or the last listener left, so only a refusal stays dormant.
if (results.some((result) => result === 'unavailable' || result === 'capacity')) {
scheduleDormantRemoteWatcherRearmCore(
connectionId,
worktreePath,
dependencies,
nextDormantDelayMs(delayMs)
)
}
}
function nextDormantDelayMs(delayMs: number): number {
return Math.min(delayMs * 2, REMOTE_WATCH_DORMANT_RETRY_MAX_MS)
}
@@ -1,258 +0,0 @@
import type { WebContents } from 'electron'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import { isWatchRootCapacityRefusal } from '../../shared/watch-root-capacity-refusal'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { MAX_BATCHED_WATCHER_EVENTS } from './filesystem-watcher-event-batch'
import { createRemoteWatcherEventBatch } from './remote-watcher-event-batch'
import { beginWatcherInstall } from './watcher-removal-gate'
import type {
RemoteWatcherInstallResult,
RemoteWatcherInstallToken
} from './filesystem-watcher-lifecycle-state'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime'
import {
addCancelledInstallWaiter,
addInFlightRemoteInstallListener,
addRemoteWatchListener,
registerWatcherSenderCleanup,
takeCancelledInstallWaiter
} from './filesystem-watcher-listener-lifecycle'
const cancelledRemoteInstallWaiters = watcherLifecycleState.cancelledRemoteInstallWaiters
export type RemoteWatcherTerminalErrorHandler = (
key: string,
connectionId: string,
worktreePath: string,
installToken: RemoteWatcherInstallToken,
error: Error
) => void
export async function installRemoteWatcherCore(
sender: WebContents,
connectionId: string,
worktreePath: string,
onTerminalError: RemoteWatcherTerminalErrorHandler,
generation = watcherLifecycleState.remoteWatcherLifecycleGeneration,
senderSignal = registerWatcherSenderCleanup(sender)
): Promise<RemoteWatcherInstallResult> {
// Why: refuse installs racing in after teardown (or a waiter from an earlier lifecycle) so provider.watch() isn't called post-shutdown.
if (
!isCurrentWatcherSender(sender, senderSignal) ||
watcherLifecycleState.remoteWatchersClosed ||
generation !== watcherLifecycleState.remoteWatcherLifecycleGeneration
) {
return 'cancelled'
}
const finishInstall = beginWatcherInstall(worktreePath, connectionId)
try {
return await installRemoteWatcherWhileRemovalAllowed(
sender,
connectionId,
worktreePath,
onTerminalError,
generation,
senderSignal
)
} finally {
finishInstall()
}
}
async function installRemoteWatcherWhileRemovalAllowed(
sender: WebContents,
connectionId: string,
worktreePath: string,
onTerminalError: RemoteWatcherTerminalErrorHandler,
generation: number,
senderSignal: AbortSignal
): Promise<RemoteWatcherInstallResult> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider || sender.isDestroyed()) {
return 'unavailable'
}
const key = getRemoteWatcherKey(connectionId, worktreePath)
const existing = watcherLifecycleState.remoteWatchers.get(key)
if (existing) {
addRemoteWatchListener(key, sender)
return 'installed'
}
// Why: concurrent same-key watches must share the first provider.watch(); separate watchers would clobber per-key state and drop the unwatch handle.
const pendingInstall = watcherLifecycleState.pendingRemoteInstallPromises.get(key)
if (pendingInstall) {
const inFlight = watcherLifecycleState.inFlightRemoteInstalls.get(key)
const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted
if (canJoinInstall) {
// Why: a new watcher joining before provider.watch() resolves should revive the install instead of inheriting the stale cancellation.
addInFlightRemoteInstallListener(inFlight, sender)
}
const waitToken = canJoinInstall
? null
: addCancelledInstallWaiter(cancelledRemoteInstallWaiters, key, sender)
const result = await pendingInstall
const stillWanted =
waitToken === null ||
takeCancelledInstallWaiter(cancelledRemoteInstallWaiters, key, sender.id, waitToken)
if (!stillWanted || !isCurrentWatcherSender(sender, senderSignal)) {
return 'cancelled'
}
if (
result === 'installed' &&
watcherLifecycleState.remoteWatchers.has(key) &&
!sender.isDestroyed() &&
(!inFlight || inFlight.listeners.has(sender.id))
) {
addRemoteWatchListener(key, sender)
}
if (
result === 'cancelled' &&
!canJoinInstall &&
!sender.isDestroyed() &&
generation === watcherLifecycleState.remoteWatcherLifecycleGeneration
) {
// Why: AbortSignal can't be revived; a listener arriving after cancellation waits out that generation, then owns a fresh install.
if (watcherLifecycleState.pendingRemoteInstallPromises.get(key) === pendingInstall) {
watcherLifecycleState.pendingRemoteInstallPromises.delete(key)
}
return installRemoteWatcherCore(
sender,
connectionId,
worktreePath,
onTerminalError,
generation,
senderSignal
)
}
return result
}
const cancelToken: RemoteWatcherInstallToken = {
cancelled: false,
listeners: new Map(),
abortController: new AbortController(),
abortScheduled: false
}
watcherLifecycleState.inFlightRemoteInstalls.set(key, cancelToken)
addInFlightRemoteInstallListener(cancelToken, sender)
const installPromise = doInstallRemoteWatcher(
provider,
key,
connectionId,
worktreePath,
cancelToken,
onTerminalError
)
watcherLifecycleState.pendingRemoteInstallPromises.set(key, installPromise)
try {
return await installPromise
} finally {
if (watcherLifecycleState.pendingRemoteInstallPromises.get(key) === installPromise) {
watcherLifecycleState.pendingRemoteInstallPromises.delete(key)
}
}
}
async function doInstallRemoteWatcher(
provider: NonNullable<ReturnType<typeof getSshFilesystemProvider>>,
key: string,
connectionId: string,
worktreePath: string,
cancelToken: RemoteWatcherInstallToken,
onTerminalError: RemoteWatcherTerminalErrorHandler
): Promise<RemoteWatcherInstallResult> {
let unwatch: () => void
// Why: the abandon paths below close the batch directly; once it is stored on the remoteWatchers
// entry, every later teardown closes it through that handle. deliver's state/installToken guard
// still covers a flush that races a close.
const batch = createRemoteWatcherEventBatch({
rootPath: worktreePath,
maxEvents: MAX_BATCHED_WATCHER_EVENTS,
deliver: (events) => {
const state = watcherLifecycleState.remoteWatchers.get(key)
// Why: buffering defers delivery past install, so a bare non-null check would let a stale flush
// land on a different install generation.
if (!state || state.installToken !== cancelToken) {
return
}
for (const listener of state.listeners.values()) {
if (listener.isDestroyed()) {
continue
}
try {
listener.send('fs:changed', {
worktreePath,
events,
connectionId
} satisfies FsChangedPayload)
} catch (err) {
// Why: batching moved this send out of the SSH mux's notification try/catch into a bare
// timer, so a frame disposed mid-window would escape as a fatal main-process exception.
console.warn(
`[filesystem-watch] failed to deliver remote fs:changed for ${worktreePath}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
}
})
try {
unwatch = await provider.watch(
worktreePath,
(events) => {
batch.push(events)
},
{
signal: cancelToken.abortController.signal,
onTerminalError: (error) =>
onTerminalError(key, connectionId, worktreePath, cancelToken, error)
}
)
} catch (err) {
// Why: the provider can fire its callback before rejecting (an aborted reinstall), so this
// abandon path owes the same close as the two below — nothing else holds the batch.
batch.close()
if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) {
return 'cancelled'
}
if (isWatchRootCapacityRefusal(err)) {
console.warn(`[filesystem-watcher] relay watch-root capacity reached for ${key}`)
return 'capacity'
}
console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err)
return 'unavailable'
} finally {
if (watcherLifecycleState.inFlightRemoteInstalls.get(key) === cancelToken) {
watcherLifecycleState.inFlightRemoteInstalls.delete(key)
}
}
const liveListeners = new Map(
Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed())
)
if (cancelToken.cancelled || liveListeners.size === 0) {
batch.close()
try {
unwatch()
} catch (err) {
console.error(`[filesystem-watcher] remote unwatch (post-cancel) error for ${key}:`, err)
}
return 'cancelled'
}
if (cancelToken.terminalError) {
batch.close()
return 'unavailable'
}
watcherLifecycleState.remoteWatchers.set(key, {
unwatch,
listeners: liveListeners,
installToken: cancelToken,
batch
})
for (const listener of liveListeners.values()) {
registerWatcherSenderCleanup(listener)
}
watcherLifecycleState.loggedUnavailableRemoteWatchers.delete(key)
return 'installed'
}
@@ -1,138 +0,0 @@
import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime'
import type { WebContents } from 'electron'
import { isWatcherRemovalInProgressError } from './watcher-removal-gate'
import type {
InstallRemoteWatcher,
RequestRemoteWatcherResync
} from './filesystem-watcher-remote-retry'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import {
clearDormantRemoteWatcher,
registerWatcherSenderCleanup
} from './filesystem-watcher-listener-lifecycle'
type ScheduleRemoteWatcherRetry = (
sender: WebContents,
connectionId: string,
worktreePath: string,
startedAt?: number,
resyncOnInstall?: boolean
) => void
/**
* Rebuild remote watches for a connection whose filesystem provider was just (re)registered.
*
* Why: the relay's watch registrations die with the transport they were made on, and the previous
* provider's unwatch handle is scoped to that dead transport. Reinstalling is the only way the
* subscription comes back, and consumers get an overflow so they resync whatever changed while the
* watch was down.
*/
export function reinstallRemoteWatchersForConnectionCore(
connectionId: string,
dependencies: {
install: InstallRemoteWatcher
requestResync: RequestRemoteWatcherResync
scheduleRetry: ScheduleRemoteWatcherRetry
scheduleDormant: (connectionId: string, worktreePath: string) => void
}
): void {
if (watcherLifecycleState.remoteWatchersClosed) {
return
}
for (const [key, desired] of Array.from(watcherLifecycleState.desiredRemoteWatchers)) {
if (desired.connectionId !== connectionId) {
continue
}
for (const [senderId, sender] of Array.from(desired.listeners)) {
if (sender.isDestroyed()) {
desired.listeners.delete(senderId)
}
}
if (desired.listeners.size === 0) {
watcherLifecycleState.desiredRemoteWatchers.delete(key)
continue
}
// Why: drop the entry the dead transport left behind first — installRemoteWatcher treats an
// existing entry as already-installed and would hand back a watcher that can never fire again.
const stale = watcherLifecycleState.remoteWatchers.get(key)
if (stale) {
watcherLifecycleState.remoteWatchers.delete(key)
stale.batch.close()
try {
stale.unwatch()
} catch {
// Why: the handle belongs to the replaced transport; failing to close it is expected.
}
}
const retryTimer = watcherLifecycleState.pendingRemoteWatcherRetries.get(key)
if (retryTimer) {
clearTimeout(retryTimer)
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
}
// Why: a pending watch belongs to the replaced transport; joiners must retry on the new provider.
const inFlight = watcherLifecycleState.inFlightRemoteInstalls.get(key)
if (inFlight) {
inFlight.listeners.clear()
inFlight.cancelled = true
inFlight.abortController.abort()
}
// Why: this reinstall supersedes the pending backoff; leaving it armed double-installs the key.
clearDormantRemoteWatcher(key)
watcherLifecycleState.loggedUnavailableRemoteWatchers.delete(key)
const listeners = Array.from(desired.listeners.values())
const signals = listeners.map(registerWatcherSenderCleanup)
const liveListeners = () =>
listeners.filter((listener, index) => isCurrentWatcherSender(listener, signals[index]))
void Promise.all(
listeners.map((listener) =>
dependencies.install(listener, desired.connectionId, desired.worktreePath)
)
)
.then((results) => {
if (liveListeners().length === 0) {
return
}
// Why: events between the transport dropping and this reinstall are gone for good.
dependencies.requestResync(
key,
desired.worktreePath,
listeners.filter(
(listener, index) =>
results[index] === 'installed' && isCurrentWatcherSender(listener, signals[index])
)
)
if (results.some((result) => result === 'capacity')) {
dependencies.scheduleDormant(desired.connectionId, desired.worktreePath)
return
}
if (results.some((result) => result === 'unavailable')) {
for (const listener of liveListeners()) {
dependencies.scheduleRetry(
listener,
desired.connectionId,
desired.worktreePath,
Date.now(),
true
)
}
}
})
.catch((error: unknown) => {
if (isWatcherRemovalInProgressError(error)) {
return
}
for (const listener of liveListeners()) {
dependencies.scheduleRetry(
listener,
desired.connectionId,
desired.worktreePath,
Date.now(),
true
)
}
})
}
}
@@ -1,92 +0,0 @@
import { createWatcherSender } from './filesystem-watcher-test-sender'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted(
() => ({
handleMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
providerRegistrationListeners: new Set<(connectionId: string) => void>()
})
)
/** Drive the provider-registration hook the way a relay establish/reconnect would. */
function emitProviderRegistered(connectionId: string): void {
for (const listener of providerRegistrationListeners) {
listener(connectionId)
}
}
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 } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('remote filesystem watcher re-arm', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
it('still resyncs when a fresh watch beat the failed reinstall to the retry slot', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const senderOne = createWatcherSender(1)
const senderTwo = createWatcherSender(2)
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) })
await handlers['fs:watchWorktree']({ sender: senderOne }, args)
// Hold the reinstall's fs.watch open so a second renderer joins it and claims the retry slot first.
let failReinstall: (error: Error) => void = () => {}
const heldWatch = new Promise<never>((_resolve, reject) => {
failReinstall = reject
})
getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockReturnValue(heldWatch) })
senderOne.send.mockClear()
emitProviderRegistered('conn-1')
const joinedWatch = handlers['fs:watchWorktree']({ sender: senderTwo }, args)
const retryWatchMock = vi.fn().mockResolvedValue(vi.fn())
failReinstall(new Error('relay not ready'))
getSshFilesystemProviderMock.mockReturnValue({ watch: retryWatchMock })
await joinedWatch
await vi.advanceTimersByTimeAsync(1_000)
// senderOne's watch really died with the old transport, so its resync must survive the merge.
expect(retryWatchMock).toHaveBeenCalledTimes(1)
expect(senderOne.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
resync: true,
connectionId: 'conn-1'
})
warnSpy.mockRestore()
await closeAllWatchers()
vi.useRealTimers()
})
})
@@ -1,164 +0,0 @@
import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime'
import type { WebContents } from 'electron'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
watcherLifecycleState,
type RemoteWatcherInstallResult
} from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
import {
clearDormantRemoteWatcher,
registerWatcherSenderCleanup,
clearRemoteWatcherResync
} from './filesystem-watcher-listener-lifecycle'
import {
installRemoteWatcher,
scheduleDormantRemoteWatcherRearm,
scheduleRemoteWatcherRetry
} from './filesystem-watcher-remote-controller'
export async function closeRemoteWatcherForWorktreePath(
connectionId: string,
worktreePath: string
): Promise<void> {
const key = getRemoteWatcherKey(connectionId, worktreePath)
const suspended = watcherLifecycleState.suspendedRemoteWatcherListeners.get(key) ?? {
connectionId,
worktreePath,
listeners: new Map<number, WebContents>()
}
const waiters = watcherLifecycleState.cancelledRemoteInstallWaiters.get(key)
for (const source of [
watcherLifecycleState.pendingRemoteWatcherRetryListeners.get(key)?.listeners,
watcherLifecycleState.inFlightRemoteInstalls.get(key)?.listeners,
watcherLifecycleState.remoteWatchers.get(key)?.listeners,
new Map(Array.from(waiters?.values() ?? [], ({ sender }) => [sender.id, sender]))
]) {
for (const [senderId, sender] of source ?? []) {
if (!sender.isDestroyed()) {
suspended.listeners.set(senderId, sender)
}
}
}
// Why: a waiter's retry would install after this close; the restore re-subscribes it instead.
watcherLifecycleState.cancelledRemoteInstallWaiters.delete(key)
if (suspended.listeners.size > 0) {
watcherLifecycleState.suspendedRemoteWatcherListeners.set(key, suspended)
}
clearRemoteWatcherResync(key)
const retryTimer = watcherLifecycleState.pendingRemoteWatcherRetries.get(key)
if (retryTimer) {
clearTimeout(retryTimer)
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
}
// Why: removal is deliberate — a backoff firing mid-removal would re-watch the path being deleted.
clearDormantRemoteWatcher(key)
const inFlight = watcherLifecycleState.inFlightRemoteInstalls.get(key)
if (inFlight) {
inFlight.listeners.clear()
inFlight.cancelled = true
}
const state = watcherLifecycleState.remoteWatchers.get(key)
const provider = getSshFilesystemProvider(connectionId)
await (provider?.closeWatch
? provider.closeWatch(worktreePath)
: Promise.resolve(state?.unwatch()))
state?.batch.close()
watcherLifecycleState.remoteWatchers.delete(key)
watcherLifecycleState.loggedUnavailableRemoteWatchers.delete(key)
}
export async function restoreRemoteWatcherAfterFailedRemoval(
connectionId: string,
worktreePath: string
): Promise<void> {
const key = getRemoteWatcherKey(connectionId, worktreePath)
const suspended = watcherLifecycleState.suspendedRemoteWatcherListeners.get(key)
if (!suspended) {
return
}
const suspendedByKey = watcherLifecycleState.suspendedRemoteWatcherListeners
const failures: {
sender: WebContents
signal: AbortSignal
error: unknown
}[] = []
const owners = Array.from(suspended.listeners.values(), (sender) => ({
sender,
signal: registerWatcherSenderCleanup(sender)
}))
for (const { sender, signal } of owners) {
// Why: a forgotten or replaced snapshot belongs to a newer removal; this restore must not consume it.
if (suspendedByKey.get(key) !== suspended) {
break
}
// Why: owners stay suspended until their turn, so an unwatch during an earlier owner's re-subscribe still withdraws them.
// Why lifetime first: a reloaded window keeps its id, and its new entry is not this restore's to take.
if (!isCurrentWatcherSender(sender, signal) || !suspended.listeners.delete(sender.id)) {
continue
}
let result: RemoteWatcherInstallResult
try {
result = await installRemoteWatcher(sender, connectionId, worktreePath, undefined, signal)
} catch (error) {
if (isCurrentWatcherSender(sender, signal)) {
failures.push({ sender, signal, error })
}
continue
}
if (!isCurrentWatcherSender(sender, signal)) {
continue
}
if (result === 'capacity') {
scheduleDormantRemoteWatcherRearm(connectionId, worktreePath)
} else if (result === 'unavailable') {
scheduleRemoteWatcherRetry(sender, connectionId, worktreePath)
}
sender.send('fs:changed', {
worktreePath,
events: [{ kind: 'overflow', absolutePath: worktreePath }],
connectionId,
resync: true
} satisfies FsChangedPayload)
}
// Why: an overlapping retried removal may have re-suspended owners into this same entry.
if (suspendedByKey.get(key) === suspended && suspended.listeners.size === 0) {
suspendedByKey.delete(key)
}
const liveFailures = failures.filter(({ sender, signal }) =>
isCurrentWatcherSender(sender, signal)
)
if (liveFailures.length > 0) {
const current = suspendedByKey.get(key) ?? {
connectionId,
worktreePath,
listeners: new Map<number, WebContents>()
}
for (const { sender } of liveFailures) {
current.listeners.set(sender.id, sender)
}
suspendedByKey.set(key, current)
throw liveFailures[0].error
}
}
export function forgetRemoteWatcherRemovalSnapshot(
connectionId: string,
worktreePath: string
): void {
const key = getRemoteWatcherKey(connectionId, worktreePath)
watcherLifecycleState.suspendedRemoteWatcherListeners.delete(key)
clearRemoteWatcherResync(key)
const retryTimer = watcherLifecycleState.pendingRemoteWatcherRetries.get(key)
if (retryTimer) {
clearTimeout(retryTimer)
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
}
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
// Why: the worktree is gone — keeping the intent lets a reconnect landing before the renderer's
// unwatch re-watch a deleted path (60s of retries against the host, then a bogus overflow).
watcherLifecycleState.desiredRemoteWatchers.delete(key)
clearDormantRemoteWatcher(key)
}
@@ -1,80 +0,0 @@
import type { WebContents } from 'electron'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import {
REMOTE_WATCH_RESYNC_COALESCE_MS,
watcherLifecycleState
} from './filesystem-watcher-lifecycle-state'
import { isCurrentDesiredRemoteWatcher } from './filesystem-watcher-remote-desired'
import { getRemoteWatcherKeyConnectionId } from './filesystem-watcher-paths'
function flushRemoteWatcherResync(key: string): void {
const state = watcherLifecycleState.remoteWatcherResyncStates.get(key)
if (!state) {
return
}
state.timer = undefined
if (
watcherLifecycleState.remoteWatchersClosed ||
watcherLifecycleState.suspendedRemoteWatcherListeners.has(key) ||
!watcherLifecycleState.remoteWatchers.has(key)
) {
if (!watcherLifecycleState.desiredRemoteWatchers.has(key)) {
watcherLifecycleState.remoteWatcherResyncStates.delete(key)
}
return
}
let sent = false
for (const listener of state.listeners.values()) {
if (listener.isDestroyed() || !isCurrentDesiredRemoteWatcher(key, listener)) {
continue
}
try {
listener.send('fs:changed', {
worktreePath: state.worktreePath,
events: [{ kind: 'overflow', absolutePath: state.worktreePath }],
connectionId: getRemoteWatcherKeyConnectionId(key),
resync: true
} satisfies FsChangedPayload)
sent = true
} catch (error) {
console.warn(`[filesystem-watcher] failed to send SSH watcher resync for ${key}:`, error)
}
}
state.listeners.clear()
if (sent) {
state.lastSentAt = Date.now()
} else {
watcherLifecycleState.remoteWatcherResyncStates.delete(key)
}
}
export function requestRemoteWatcherResync(
key: string,
worktreePath: string,
listeners: Iterable<WebContents>
): void {
const state = watcherLifecycleState.remoteWatcherResyncStates.get(key) ?? {
lastSentAt: Number.NEGATIVE_INFINITY,
listeners: new Map<number, WebContents>(),
worktreePath
}
state.worktreePath = worktreePath
for (const listener of listeners) {
if (!listener.isDestroyed() && isCurrentDesiredRemoteWatcher(key, listener)) {
state.listeners.set(listener.id, listener)
}
}
if (state.listeners.size === 0) {
return
}
watcherLifecycleState.remoteWatcherResyncStates.set(key, state)
const delayMs = Math.max(0, state.lastSentAt + REMOTE_WATCH_RESYNC_COALESCE_MS - Date.now())
if (delayMs === 0) {
flushRemoteWatcherResync(key)
return
}
if (!state.timer) {
state.timer = setTimeout(() => flushRemoteWatcherResync(key), delayMs)
state.timer.unref?.()
}
}
@@ -1,158 +0,0 @@
import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime'
import type { WebContents } from 'electron'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import { isWatcherRemovalInProgressError } from './watcher-removal-gate'
import type { RemoteWatcherInstallResult } from './filesystem-watcher-lifecycle-state'
import {
REMOTE_WATCH_RETRY_MS,
REMOTE_WATCH_RETRY_TIMEOUT_MS,
watcherLifecycleState
} from './filesystem-watcher-lifecycle-state'
import { getRemoteWatcherKey } from './filesystem-watcher-paths'
import {
clearRemoteWatcherResync,
registerWatcherSenderCleanup
} from './filesystem-watcher-listener-lifecycle'
import { isCurrentDesiredRemoteWatcher } from './filesystem-watcher-remote-desired'
export type InstallRemoteWatcher = (
sender: WebContents,
connectionId: string,
worktreePath: string
) => Promise<RemoteWatcherInstallResult>
export type RequestRemoteWatcherResync = (
key: string,
worktreePath: string,
listeners: Iterable<WebContents>
) => void
type ScheduleDormantRemoteWatcher = (
connectionId: string,
worktreePath: string,
delayMs?: number
) => void
export function scheduleRemoteWatcherRetryCore(
sender: WebContents,
connectionId: string,
worktreePath: string,
dependencies: {
install: InstallRemoteWatcher
requestResync: RequestRemoteWatcherResync
scheduleDormant: ScheduleDormantRemoteWatcher
},
startedAt = Date.now(),
// Why: a retry that replaces a watch which was already live owes the renderer an overflow once it
// lands — the events lost while it was down are otherwise never signalled.
resyncOnInstall = false
): void {
const key = getRemoteWatcherKey(connectionId, worktreePath)
if (watcherLifecycleState.remoteWatchersClosed || !isCurrentDesiredRemoteWatcher(key, sender)) {
return
}
const existingRetry = watcherLifecycleState.pendingRemoteWatcherRetryListeners.get(key)
if (existingRetry) {
if (!sender.isDestroyed()) {
existingRetry.listeners.set(sender.id, sender)
}
existingRetry.resyncOnInstall ||= resyncOnInstall
return
}
const retry = {
listeners: new Map(sender.isDestroyed() ? [] : [[sender.id, sender]]),
startedAt,
resyncOnInstall
}
watcherLifecycleState.pendingRemoteWatcherRetryListeners.set(key, retry)
if (Date.now() - startedAt >= REMOTE_WATCH_RETRY_TIMEOUT_MS || sender.isDestroyed()) {
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
watcherLifecycleState.loggedUnavailableRemoteWatchers.delete(key)
clearRemoteWatcherResync(key)
// Why: handler already resolved so the renderer thinks the watch is live; emit overflow to force a manual refresh instead of waiting forever.
for (const listener of retry.listeners.values()) {
if (listener.isDestroyed() || !isCurrentDesiredRemoteWatcher(key, listener)) {
continue
}
console.warn(
`[filesystem-watcher] giving up SSH watch retry for ${worktreePath} on connection ${connectionId} after ${REMOTE_WATCH_RETRY_TIMEOUT_MS}ms`
)
listener.send('fs:changed', {
worktreePath,
events: [{ kind: 'overflow', absolutePath: worktreePath }],
connectionId,
resync: true
} satisfies FsChangedPayload)
}
// Why: overflow only refreshes once — without this the watch stays dead until the app restarts.
dependencies.scheduleDormant(connectionId, worktreePath)
return
}
const retryTimer = setTimeout(() => {
watcherLifecycleState.pendingRemoteWatcherRetries.delete(key)
watcherLifecycleState.pendingRemoteWatcherRetryListeners.delete(key)
const listeners = Array.from(retry.listeners.values()).filter(
(listener) => !listener.isDestroyed() && isCurrentDesiredRemoteWatcher(key, listener)
)
const signals = listeners.map(registerWatcherSenderCleanup)
const liveListeners = () =>
listeners.filter((listener, index) => isCurrentWatcherSender(listener, signals[index]))
void Promise.all(
listeners.map((listener) => dependencies.install(listener, connectionId, worktreePath))
)
.then((results) => {
if (liveListeners().length === 0) {
return
}
if (retry.resyncOnInstall) {
dependencies.requestResync(
key,
worktreePath,
listeners.filter(
(listener, index) =>
results[index] === 'installed' && isCurrentWatcherSender(listener, signals[index])
)
)
}
// Why capacity leaves the fast window: the relay is refusing on a full watch-root cap, and a
// 1 Hz reinstall per refused root is exactly the load that keeps the cap busy (#11196).
if (results.some((result) => result === 'capacity')) {
dependencies.scheduleDormant(connectionId, worktreePath)
return
}
// Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires.
if (results.some((result) => result === 'unavailable')) {
for (const listener of liveListeners()) {
scheduleRemoteWatcherRetryCore(
listener,
connectionId,
worktreePath,
dependencies,
retry.startedAt,
retry.resyncOnInstall
)
}
}
})
.catch((error: unknown) => {
if (isWatcherRemovalInProgressError(error)) {
return
}
for (const listener of liveListeners()) {
scheduleRemoteWatcherRetryCore(
listener,
connectionId,
worktreePath,
dependencies,
retry.startedAt,
retry.resyncOnInstall
)
}
})
}, REMOTE_WATCH_RETRY_MS)
watcherLifecycleState.pendingRemoteWatcherRetries.set(key, retryTimer)
}
@@ -32,11 +32,6 @@ vi.mock('./parcel-watcher-process', async (importOriginal) => {
}
})
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { closeLocalWatcherForWorktreePath } from './filesystem-watcher-local-removal'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
@@ -2,11 +2,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createWatcherSender, fireWatcherSenderEvent } from './filesystem-watcher-test-sender'
const { handleMock, statMock, subscribeMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({
const { handleMock, statMock, subscribeMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
statMock: vi.fn(),
subscribeMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn()
subscribeMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
@@ -16,10 +15,6 @@ vi.mock('./parcel-watcher-process', () => ({
disposeWatcherProcess: vi.fn()
}))
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock,
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
@@ -27,14 +22,12 @@ import { desktopWorktreeWatcherRemoval } from './filesystem-watcher'
import { setWorktreeWatcherRemoval } from './worktree-watcher-removal'
import { createRuntimeFileWatcherRemoval } from '../runtime/runtime-file-watcher-removal'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { getLocalWatcherRoot, getRemoteWatcherKey } from './filesystem-watcher-paths'
import { getLocalWatcherRoot } from './filesystem-watcher-paths'
import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry'
type Handler = (event: unknown, args: unknown) => unknown
const CONNECTION = 'conn-1'
const LOCAL: { worktreePath: string; connectionId?: string } = { worktreePath: '/repo' }
const REMOTE = { worktreePath: '/home/me/repo', connectionId: CONNECTION }
const args = { worktreePath: '/repo' }
async function settle(): Promise<void> {
for (let i = 0; i < 40; i += 1) {
@@ -99,12 +92,6 @@ describe('watcher restore after a failed worktree removal', () => {
return { unsubscribe: vi.fn(async () => {}) }
}
)
getSshFilesystemProviderMock.mockReturnValue({
watch: vi.fn(async (_root: string, _cb: unknown, options?: { signal?: AbortSignal }) => {
await waitIfDeferred(options?.signal)
return () => {}
})
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
@@ -117,137 +104,106 @@ describe('watcher restore after a failed worktree removal', () => {
vi.restoreAllMocks()
})
const localListeners = (): number[] => [
const listeners = (): number[] => [
...(watcherLifecycleState.watchedRoots
.get(getLocalWatcherRoot('/repo').key)
?.listeners.keys() ?? [])
]
const remoteListeners = (): number[] => [
...(watcherLifecycleState.remoteWatchers
.get(getRemoteWatcherKey(CONNECTION, REMOTE.worktreePath))
?.listeners.keys() ?? [])
]
it.each([
['local', LOCAL, localListeners],
['SSH', REMOTE, remoteListeners]
])(
'drops a %s window that unwatches while an earlier window is being restored',
async (_label, args, listeners) => {
const first = createWatcherSender(1)
const second = createWatcherSender(2)
await handlers['fs:watchWorktree']({ sender: first }, args)
await handlers['fs:watchWorktree']({ sender: second }, args)
const removal = await coordinator.acquire(args.worktreePath, args.connectionId)
it('drops a window that unwatches while an earlier window is being restored', async () => {
const first = createWatcherSender(1)
const second = createWatcherSender(2)
await handlers['fs:watchWorktree']({ sender: first }, args)
await handlers['fs:watchWorktree']({ sender: second }, args)
const removal = await coordinator.acquire(args.worktreePath)
deferSubscribe = true
const restoring = removal.finish(false)
await settle()
handlers['fs:unwatchWorktree']({ sender: second }, args)
await resolveAllDeferred()
await restoring
deferSubscribe = true
const restoring = removal.finish(false)
await settle()
handlers['fs:unwatchWorktree']({ sender: second }, args)
await resolveAllDeferred()
await restoring
expect(listeners()).toEqual([1])
expect(watcherLifecycleState.suspendedLocalWatcherListeners.size).toBe(0)
expect(watcherLifecycleState.suspendedRemoteWatcherListeners.size).toBe(0)
expect(listeners()).toEqual([1])
expect(watcherLifecycleState.suspendedLocalWatcherListeners.size).toBe(0)
})
it('keeps a retried removal’s owners when it overlaps the previous restore', async () => {
const first = createWatcherSender(1)
const second = createWatcherSender(2)
await handlers['fs:watchWorktree']({ sender: first }, args)
await handlers['fs:watchWorktree']({ sender: second }, args)
const firstRemoval = await coordinator.acquire(args.worktreePath)
// The first restore is mid-way through re-subscribing window 1 when the retry starts.
deferSubscribe = true
const firstRestore = firstRemoval.finish(false)
await settle()
const secondAcquire = coordinator.acquire(args.worktreePath)
await settle()
await resolveAllDeferred()
const secondRemoval = await secondAcquire
await firstRestore
deferSubscribe = false
await secondRemoval.finish(false)
await settle()
expect(listeners().sort()).toEqual([1, 2])
expect(watcherLifecycleState.suspendedLocalWatcherListeners.size).toBe(0)
})
it('keeps a window that reloads (same id) while overlapping restores run', async () => {
const first = createWatcherSender(1)
const second = createWatcherSender(2)
// Window 3 keeps the first restore's snapshot non-empty after window 2's old page is cleaned up.
const third = createWatcherSender(3)
for (const sender of [first, second, third]) {
await handlers['fs:watchWorktree']({ sender }, args)
}
)
const firstRemoval = await coordinator.acquire(args.worktreePath)
it.each([
['local', LOCAL, localListeners],
['SSH', REMOTE, remoteListeners]
])(
'keeps a retried %s removal’s owners when it overlaps the previous restore',
async (_label, args, listeners) => {
const first = createWatcherSender(1)
const second = createWatcherSender(2)
await handlers['fs:watchWorktree']({ sender: first }, args)
await handlers['fs:watchWorktree']({ sender: second }, args)
const firstRemoval = await coordinator.acquire(args.worktreePath, args.connectionId)
deferSubscribe = true
const firstRestore = firstRemoval.finish(false)
await settle()
// The first restore captured window 2's old lifetime; the reloaded page watches again.
fireWatcherSenderEvent(second, 'did-navigate')
const rewatch = Promise.resolve(handlers['fs:watchWorktree']({ sender: second }, args))
await settle()
const secondAcquire = coordinator.acquire(args.worktreePath)
await settle()
await resolveAllDeferred()
const secondRemoval = await secondAcquire
await firstRestore
await rewatch.catch(() => {})
// The first restore is mid-way through re-subscribing window 1 when the retry starts.
deferSubscribe = true
const firstRestore = firstRemoval.finish(false)
await settle()
const secondAcquire = coordinator.acquire(args.worktreePath, args.connectionId)
await settle()
await resolveAllDeferred()
const secondRemoval = await secondAcquire
await firstRestore
deferSubscribe = false
await secondRemoval.finish(false)
await settle()
deferSubscribe = false
await secondRemoval.finish(false)
await settle()
expect(listeners().sort()).toEqual([1, 2, 3])
})
expect(listeners().sort()).toEqual([1, 2])
expect(watcherLifecycleState.suspendedLocalWatcherListeners.size).toBe(0)
expect(watcherLifecycleState.suspendedRemoteWatcherListeners.size).toBe(0)
}
)
it('restores a window that was waiting out a cancelled install when removal began', async () => {
const sender = createWatcherSender(1)
deferSubscribe = true
wedged = true
const first = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args))
await settle()
handlers['fs:unwatchWorktree']({ sender }, args)
await settle()
// The re-watch can't join the aborted install, so it waits for it in no listener map.
deferSubscribe = false
const second = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args))
await settle()
const acquiring = coordinator.acquire(args.worktreePath)
await settle()
await resolveAllDeferred()
const removal = await acquiring
await Promise.allSettled([first, second])
await removal.finish(false)
await settle()
it.each([
['local', LOCAL, localListeners],
['SSH', REMOTE, remoteListeners]
])(
'keeps a %s window that reloads (same id) while overlapping restores run',
async (_label, args, listeners) => {
const first = createWatcherSender(1)
const second = createWatcherSender(2)
// Window 3 keeps the first restore's snapshot non-empty after window 2's old page is cleaned up.
const third = createWatcherSender(3)
for (const sender of [first, second, third]) {
await handlers['fs:watchWorktree']({ sender }, args)
}
const firstRemoval = await coordinator.acquire(args.worktreePath, args.connectionId)
deferSubscribe = true
const firstRestore = firstRemoval.finish(false)
await settle()
// The first restore captured window 2's old lifetime; the reloaded page watches again.
fireWatcherSenderEvent(second, 'did-navigate')
const rewatch = Promise.resolve(handlers['fs:watchWorktree']({ sender: second }, args))
await settle()
const secondAcquire = coordinator.acquire(args.worktreePath, args.connectionId)
await settle()
await resolveAllDeferred()
const secondRemoval = await secondAcquire
await firstRestore
await rewatch.catch(() => {})
deferSubscribe = false
await secondRemoval.finish(false)
await settle()
expect(listeners().sort()).toEqual([1, 2, 3])
}
)
it.each([
['local', LOCAL, localListeners],
['SSH', REMOTE, remoteListeners]
])(
'restores a %s window that was waiting out a cancelled install when removal began',
async (_label, args, listeners) => {
const sender = createWatcherSender(1)
deferSubscribe = true
wedged = true
const first = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args))
await settle()
handlers['fs:unwatchWorktree']({ sender }, args)
await settle()
// The re-watch can't join the aborted install, so it waits for it in no listener map.
deferSubscribe = false
const second = Promise.resolve(handlers['fs:watchWorktree']({ sender }, args))
await settle()
const acquiring = coordinator.acquire(args.worktreePath, args.connectionId)
await settle()
await resolveAllDeferred()
const removal = await acquiring
await Promise.allSettled([first, second])
await removal.finish(false)
await settle()
expect(listeners()).toEqual([1])
}
)
expect(listeners()).toEqual([1])
})
})
+1 -42
View File
@@ -5,18 +5,13 @@ import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
/** Tear down all watchers on app shutdown. */
export async function closeAllWatchers(): Promise<void> {
// Why: drop the intent with the rest of the state, but keep the provider-registration
// subscription — a new fs:watchWorktree reopens the subsystem and still needs the re-arm hook.
watcherLifecycleState.desiredRemoteWatchers.clear()
for (const lifetime of watcherLifecycleState.senderLifetimes.values()) {
lifetime.dispose()
}
watcherLifecycleState.senderLifetimes.clear()
watcherLifecycleState.unwatchableRoots.clear()
watcherLifecycleState.suspendedLocalWatcherListeners.clear()
watcherLifecycleState.suspendedRemoteWatcherListeners.clear()
watcherLifecycleState.cancelledLocalInstallWaiters.clear()
watcherLifecycleState.cancelledRemoteInstallWaiters.clear()
for (const retry of watcherLifecycleState.pendingLocalCapacityRetries.values()) {
retry.cancelWait()
}
@@ -28,34 +23,9 @@ export async function closeAllWatchers(): Promise<void> {
}
watcherLifecycleState.pendingTeardowns.clear()
for (const timer of watcherLifecycleState.pendingRemoteWatcherRetries.values()) {
clearTimeout(timer)
}
watcherLifecycleState.pendingRemoteWatcherRetries.clear()
watcherLifecycleState.pendingRemoteWatcherRetryListeners.clear()
for (const state of watcherLifecycleState.remoteWatcherResyncStates.values()) {
if (state.timer) {
clearTimeout(state.timer)
}
}
watcherLifecycleState.remoteWatcherResyncStates.clear()
for (const dormant of watcherLifecycleState.dormantRemoteWatchers.values()) {
clearTimeout(dormant.timer)
}
watcherLifecycleState.dormantRemoteWatchers.clear()
watcherLifecycleState.loggedUnavailableRemoteWatchers.clear()
// Why: latch both subsystems shut so late installs can't register; generation bumps reject older-lifecycle waiters.
watcherLifecycleState.remoteWatchersClosed = true
watcherLifecycleState.remoteWatcherLifecycleGeneration += 1
// Why: latch the subsystem shut so late installs can't register; the generation bump rejects older-lifecycle waiters.
watcherLifecycleState.localWatchersClosed = true
watcherLifecycleState.localWatcherLifecycleGeneration += 1
watcherLifecycleState.pendingRemoteInstallPromises.clear()
// Why: cancel in-flight provider.watch() calls so their resolved unwatch handles aren't installed post-shutdown.
for (const token of watcherLifecycleState.inFlightRemoteInstalls.values()) {
token.listeners.clear()
token.cancelled = true
token.abortController.abort()
}
for (const token of watcherLifecycleState.inFlightLocalInstalls.values()) {
token.listeners.clear()
token.cancelled = true
@@ -71,15 +41,4 @@ export async function closeAllWatchers(): Promise<void> {
watcherLifecycleState.failedLocalUnsubscribes.clear()
// Why: kill the forked watcher process instead of watcher.node's crash-prone async teardown; process death frees native handles.
disposeWatcherProcess()
// Why: remote watchers are separate from local @parcel/watcher subs; unwatch here or the relay keeps polling FS after shutdown.
for (const [key, state] of watcherLifecycleState.remoteWatchers) {
state.batch.close()
try {
state.unwatch()
} catch (err) {
console.error(`[filesystem-watcher] remote unwatch error for ${key}:`, err)
}
}
watcherLifecycleState.remoteWatchers.clear()
}
@@ -1,405 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 } from './filesystem-watcher-shutdown'
import {
closeRemoteWatcherForWorktreePath,
forgetRemoteWatcherRemovalSnapshot,
restoreRemoteWatcherAfterFailedRemoval
} from './filesystem-watcher-remote-removal'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
type TerminalErrorHandler = (error: Error) => void
const WORKTREE_PATH = '/home/me/repo'
const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' }
const OVERFLOW_PAYLOAD = {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }],
resync: true,
connectionId: 'conn-1'
}
type MockSender = {
isDestroyed: () => boolean
send: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
once: ReturnType<typeof vi.fn>
id: number
destroy: () => void
}
function createSender(id: number): MockSender {
let destroyed = false
const destroyedHandlers: (() => void)[] = []
return {
isDestroyed: () => destroyed,
send: vi.fn(),
removeListener: vi.fn(),
once: vi.fn((event: string, handler: () => void) => {
if (event === 'destroyed') {
destroyedHandlers.push(handler)
}
}),
id,
destroy: () => {
destroyed = true
for (const handler of destroyedHandlers) {
handler()
}
}
}
}
describe('remote filesystem watcher terminal retry resync', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
afterEach(async () => {
await closeAllWatchers()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('resyncs only owners still watching when the terminal retry installs', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const senderOne = createSender(1)
const senderTwo = createSender(2)
let terminalError: TerminalErrorHandler = () => {}
let resolveRetry: (unwatch: () => void) => void = () => {}
const retryInstall = new Promise<() => void>((resolve) => {
resolveRetry = resolve
})
const watchMock = vi
.fn()
.mockImplementationOnce((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
.mockReturnValueOnce(retryInstall)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender: senderOne }, ARGS)
await handlers['fs:watchWorktree']({ sender: senderTwo }, ARGS)
terminalError(new Error('relay watcher died'))
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
handlers['fs:unwatchWorktree']({ sender: senderOne }, ARGS)
resolveRetry(vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(senderOne.send).not.toHaveBeenCalled()
expect(senderTwo.send).toHaveBeenCalledTimes(1)
expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
terminalError(new Error('late stale failure'))
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
expect(senderTwo.send).toHaveBeenCalledTimes(1)
})
it('resyncs once a retried initial setup installs', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
const watchMock = vi
.fn()
.mockRejectedValueOnce(new Error('provider not ready'))
.mockResolvedValueOnce(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender }, ARGS)
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
// Why: writes before the retry landed were never reported, so the renderer must re-read.
await vi.advanceTimersByTimeAsync(5_000)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
})
it('lets provider registration supersede a pending terminal retry', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender }, ARGS)
terminalError(new Error('old relay watcher died'))
for (const listener of providerRegistrationListeners) {
listener('conn-1')
}
await vi.advanceTimersByTimeAsync(0)
expect(watchMock).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledTimes(1)
})
it('aborts an in-flight terminal retry when a replacement provider registers', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
let retrySignal: AbortSignal | undefined
let resolveRetry: (unwatch: () => void) => void = () => {}
const retryInstall = new Promise<() => void>((resolve) => {
resolveRetry = resolve
})
const retryUnwatch = vi.fn()
const staleWatch = vi
.fn()
.mockImplementationOnce((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
.mockImplementationOnce((_path, _events, options) => {
retrySignal = options.signal
return retryInstall
})
const replacementWatch = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: staleWatch })
await handlers['fs:watchWorktree']({ sender }, ARGS)
terminalError(new Error('old relay watcher died'))
await vi.advanceTimersByTimeAsync(1_000)
expect(staleWatch).toHaveBeenCalledTimes(2)
getSshFilesystemProviderMock.mockReturnValue({ watch: replacementWatch })
for (const listener of providerRegistrationListeners) {
listener('conn-1')
}
expect(retrySignal?.aborted).toBe(true)
resolveRetry(retryUnwatch)
await vi.advanceTimersByTimeAsync(0)
expect(retryUnwatch).toHaveBeenCalledTimes(1)
expect(replacementWatch).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
})
it('coalesces repeated terminal recovery resyncs with a trailing refresh', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
const terminalErrors: TerminalErrorHandler[] = []
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalErrors.push(options.onTerminalError)
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender }, ARGS)
terminalErrors[0]?.(new Error('first watcher failure'))
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).toHaveBeenCalledTimes(1)
terminalErrors[1]?.(new Error('second watcher failure'))
await vi.advanceTimersByTimeAsync(1_000)
terminalErrors[2]?.(new Error('third watcher failure'))
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(4)
expect(sender.send).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(3_000)
expect(sender.send).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenLastCalledWith('fs:changed', OVERFLOW_PAYLOAD)
})
it('cancels a terminal retry when the last owner unwatches', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender }, ARGS)
terminalError(new Error('relay watcher died'))
handlers['fs:unwatchWorktree']({ sender }, ARGS)
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('cancels a terminal retry when removal forgets the watcher snapshot', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender }, ARGS)
terminalError(new Error('relay watcher died'))
forgetRemoteWatcherRemovalSnapshot('conn-1', WORKTREE_PATH)
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('cancels a terminal retry when its renderer is destroyed', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree']({ sender }, ARGS)
terminalError(new Error('relay watcher died'))
sender.destroy()
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('does not retry a terminal callback raised during destructive removal', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
let resolveClose: () => void = () => {}
const closeWatch = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveClose = resolve
})
)
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
await handlers['fs:watchWorktree']({ sender }, ARGS)
const close = closeRemoteWatcherForWorktreePath('conn-1', WORKTREE_PATH)
await Promise.resolve()
terminalError(new Error('close terminated watcher'))
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
resolveClose()
await close
forgetRemoteWatcherRemovalSnapshot('conn-1', WORKTREE_PATH)
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('restores once after removal teardown rejects with a terminal callback', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
let rejectClose: (error: Error) => void = () => {}
const closeWatch = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectClose = reject
})
)
const watchMock = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(vi.fn())
})
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
await handlers['fs:watchWorktree']({ sender }, ARGS)
const close = closeRemoteWatcherForWorktreePath('conn-1', WORKTREE_PATH)
await Promise.resolve()
terminalError(new Error('close terminated watcher'))
rejectClose(new Error('close failed'))
await expect(close).rejects.toThrow('close failed')
await restoreRemoteWatcherAfterFailedRemoval('conn-1', WORKTREE_PATH)
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
})
it('does not carry a shutdown terminal callback into a reopened lifecycle', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
let terminalError: TerminalErrorHandler = () => {}
const initialWatch = vi.fn().mockImplementation((_path, _events, options) => {
terminalError = options.onTerminalError
return Promise.resolve(() => terminalError(new Error('shutdown terminated watcher')))
})
getSshFilesystemProviderMock.mockReturnValue({ watch: initialWatch })
await handlers['fs:watchWorktree']({ sender }, ARGS)
await closeAllWatchers()
const reopenedWatch = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: reopenedWatch })
await handlers['fs:watchWorktree']({ sender }, ARGS)
await vi.advanceTimersByTimeAsync(1_000)
expect(initialWatch).toHaveBeenCalledTimes(1)
expect(reopenedWatch).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
})
@@ -24,11 +24,6 @@ vi.mock('./filesystem-watcher-wsl', () => ({
createWslWatcher: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import { closeAllWatchers } from './filesystem-watcher-shutdown'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { stat } from 'node:fs/promises'
+5 -782
View File
@@ -1,20 +1,7 @@
import { createWatcherSender } from './filesystem-watcher-test-sender'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted(
() => ({
handleMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
providerRegistrationListeners: new Set<(connectionId: string) => void>()
})
)
/** Drive the provider-registration hook the way a relay establish/reconnect would. */
function emitProviderRegistered(connectionId: string): void {
for (const listener of providerRegistrationListeners) {
listener(connectionId)
}
}
const { handleMock } = vi.hoisted(() => ({ handleMock: vi.fn() }))
vi.mock('electron', () => ({
ipcMain: {
@@ -34,38 +21,16 @@ 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 } from './filesystem-watcher-shutdown'
import {
closeRemoteWatcherForWorktreePath,
forgetRemoteWatcherRemovalSnapshot,
restoreRemoteWatcherAfterFailedRemoval
} from './filesystem-watcher-remote-removal'
import { registerFilesystemWatcherHandlers } from './filesystem-watcher-handlers'
import { stat } from 'node:fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
import { createWslWatcher } from './filesystem-watcher-wsl'
import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
import { WATCH_BATCH_INTERVAL_MS } from '../../shared/filesystem-watch-batch-window'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
/** Remote fs:changed rides the shared flush window, so drain it before asserting sends. */
const emitRemote = async (onEvents: (e: unknown[]) => void, events: unknown[]) => {
onEvents(events)
await (vi.isFakeTimers()
? vi.advanceTimersByTimeAsync(WATCH_BATCH_INTERVAL_MS)
: new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_INTERVAL_MS + 25)))
}
describe('registerFilesystemWatcherHandlers', () => {
const handlers: HandlerMap = {}
const originalPlatform = process.platform
@@ -73,7 +38,6 @@ describe('registerFilesystemWatcherHandlers', () => {
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
vi.mocked(stat).mockReset()
vi.mocked(subscribeParcelWatcher).mockReset()
vi.mocked(createWslWatcher).mockReset()
@@ -133,755 +97,14 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(subscribeParcelWatcher).toHaveBeenCalledTimes(1)
})
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: createWatcherSender(1) },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
).resolves.toBeUndefined()
await handlers['fs:watchWorktree'](
{ sender: createWatcherSender(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 = createWatcherSender(1, sendMock)
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),
onTerminalError: expect.any(Function)
})
const onEvents = watchMock.mock.calls[0][1]
await emitRemote(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' }],
connectionId: 'conn-1'
})
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 = createWatcherSender(1, sendMock)
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),
onTerminalError: expect.any(Function)
})
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: createWatcherSender(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('retries all live renderer owners after a terminal relay watch failure', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const senderOne = createWatcherSender(1)
const senderTwo = createWatcherSender(2)
const watchMock = vi.fn().mockResolvedValue(vi.fn())
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' }
)
const staleTerminal = watchMock.mock.calls[0][2].onTerminalError as (error: Error) => void
staleTerminal(new Error('relay watcher recovery failed'))
handlers['fs:unwatchWorktree'](
{ sender: senderOne },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
const recoveredEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
await emitRemote(recoveredEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(senderOne.send).not.toHaveBeenCalled()
expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }],
connectionId: 'conn-1'
})
staleTerminal(new Error('late stale failure'))
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
warnSpy.mockRestore()
vi.useRealTimers()
})
it('reinstalls an SSH worktree watch when the provider is re-registered after a reconnect', async () => {
const sender = createWatcherSender(1)
const staleUnwatch = vi.fn()
const watchMock = vi.fn().mockResolvedValue(staleUnwatch)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(watchMock).toHaveBeenCalledTimes(1)
// The reconnect replaces the provider; the watch made on the dead transport can never fire again.
emitProviderRegistered('conn-1')
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2))
expect(staleUnwatch).toHaveBeenCalledTimes(1)
// Events missed while the watch was down are unrecoverable, so consumers are told to resync.
await vi.waitFor(() =>
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
resync: true,
connectionId: 'conn-1'
})
)
const reinstalledEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
await emitRemote(reinstalledEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }],
connectionId: 'conn-1'
})
await closeAllWatchers()
})
it('re-arms an SSH watch whose first install found no provider yet', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createWatcherSender(1)
// A connect slower than the retry window leaves the renderer subscribed with nothing installed.
getSshFilesystemProviderMock.mockReturnValue(undefined)
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
const watchMock = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
emitProviderRegistered('conn-1')
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(1))
warnSpy.mockRestore()
await closeAllWatchers()
})
it('resyncs after a reconnect whose reinstall only succeeded on a retry', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createWatcherSender(1)
getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) })
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
// The relay is back, but its first fs.watch on the fresh transport still fails.
const retryWatchMock = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock
.mockReturnValueOnce({ watch: vi.fn().mockRejectedValue(new Error('relay not ready')) })
.mockReturnValue({ watch: retryWatchMock })
sender.send.mockClear()
emitProviderRegistered('conn-1')
await vi.advanceTimersByTimeAsync(1_000)
expect(retryWatchMock).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
resync: true,
connectionId: 'conn-1'
})
warnSpy.mockRestore()
await closeAllWatchers()
vi.useRealTimers()
})
it('does not resurrect an SSH watch the renderer already unwatched', async () => {
const sender = createWatcherSender(1)
const watchMock = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
handlers['fs:unwatchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
emitProviderRegistered('conn-1')
await Promise.resolve()
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
await closeAllWatchers()
})
it('leaves watches on other connections untouched when one provider re-registers', async () => {
const sender = createWatcherSender(1)
const watchMock = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
emitProviderRegistered('conn-2')
await Promise.resolve()
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
await closeAllWatchers()
})
it('reinstalls one shared watch when several senders share a re-registered connection', async () => {
const senderOne = createWatcherSender(1)
const senderTwo = createWatcherSender(2)
const watchMock = vi.fn().mockResolvedValue(vi.fn())
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)
// Per-listener reinstall must still collapse onto one relay watch, and every listener resyncs.
emitProviderRegistered('conn-1')
await vi.waitFor(() => expect(senderTwo.send).toHaveBeenCalled())
expect(watchMock).toHaveBeenCalledTimes(2)
for (const sender of [senderOne, senderTwo]) {
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
resync: true,
connectionId: 'conn-1'
})
}
await closeAllWatchers()
})
it('does not reinstall an SSH watch for a renderer that was destroyed', async () => {
let destroyed = false
const destroyHandlers: (() => void)[] = []
const sender = {
isDestroyed: () => destroyed,
send: vi.fn(),
removeListener: vi.fn(),
once: vi.fn((event: string, handler: () => void) => {
if (event === 'destroyed') {
destroyHandlers.push(handler)
}
}),
id: 1
}
const watchMock = vi.fn().mockResolvedValue(vi.fn())
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(destroyHandlers).toHaveLength(1)
destroyed = true
for (const handler of destroyHandlers) {
handler()
}
emitProviderRegistered('conn-1')
await Promise.resolve()
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
await closeAllWatchers()
})
it('shares SSH worktree watchers across renderer senders until the last unwatch', async () => {
const sendOne = vi.fn()
const sendTwo = vi.fn()
const senderOne = createWatcherSender(1, sendOne)
const senderTwo = createWatcherSender(2, sendTwo)
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]
await emitRemote(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('awaits acknowledged SSH watcher teardown before remote deletion', async () => {
let resolveClose: () => void = () => {}
const closeWatch = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveClose = resolve
})
)
const provider = { watch: vi.fn().mockResolvedValue(vi.fn()), closeWatch }
getSshFilesystemProviderMock.mockReturnValue(provider)
await handlers['fs:watchWorktree'](
{ sender: createWatcherSender(1) },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
let closed = false
const close = closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo').then(() => {
closed = true
})
await Promise.resolve()
expect(closeWatch).toHaveBeenCalledWith('/home/me/repo')
expect(closed).toBe(false)
resolveClose()
await close
})
it('re-arms SSH listeners after remote worktree deletion fails', async () => {
const firstUnwatch = vi.fn()
const replacementUnwatch = vi.fn()
const watchMock = vi
.fn()
.mockResolvedValueOnce(firstUnwatch)
.mockResolvedValueOnce(replacementUnwatch)
const closeWatch = vi.fn().mockResolvedValue(undefined)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
const sender = createWatcherSender(1)
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')
await restoreRemoteWatcherAfterFailedRemoval('conn-1', '/home/me/repo')
expect(closeWatch).toHaveBeenCalledWith('/home/me/repo')
expect(watchMock).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
resync: true,
connectionId: 'conn-1'
})
const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
await emitRemote(replacementEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(sender.send).toHaveBeenLastCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }],
connectionId: 'conn-1'
})
})
it('does not re-arm an SSH watch for a worktree that was successfully deleted', async () => {
const watchMock = vi.fn().mockResolvedValue(vi.fn())
const closeWatch = vi.fn().mockResolvedValue(undefined)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
const sender = createWatcherSender(1)
await handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')
forgetRemoteWatcherRemovalSnapshot('conn-1', '/home/me/repo')
// A reconnect can land before the renderer's unwatch; the path no longer exists on the host.
emitProviderRegistered('conn-1')
await Promise.resolve()
await Promise.resolve()
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
})
it('does not restore an SSH listener stopped while deletion is pending', async () => {
const firstUnwatch = vi.fn()
const watchMock = vi.fn().mockResolvedValue(firstUnwatch)
const closeWatch = vi.fn().mockResolvedValue(undefined)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
it('never watches an SSH path on the local disk', async () => {
const sender = createWatcherSender(1)
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
await handlers['fs:watchWorktree']({ sender }, args)
await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')
await expect(handlers['fs:watchWorktree']({ sender }, args)).resolves.toBeUndefined()
handlers['fs:unwatchWorktree']({ sender }, args)
await restoreRemoteWatcherAfterFailedRemoval('conn-1', '/home/me/repo')
expect(closeWatch).toHaveBeenCalledWith('/home/me/repo')
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('does not restore an SSH listener destroyed while deletion is pending', async () => {
const watchMock = vi.fn().mockResolvedValue(vi.fn())
const closeWatch = vi.fn().mockResolvedValue(undefined)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
const destroyedCallbacks: (() => void)[] = []
const sender = {
...createWatcherSender(1),
once: vi.fn((event: string, callback: () => void) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
})
}
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
await handlers['fs:watchWorktree']({ sender }, args)
await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')
destroyedCallbacks[0]?.()
await restoreRemoteWatcherAfterFailedRemoval('conn-1', '/home/me/repo')
expect(closeWatch).toHaveBeenCalledWith('/home/me/repo')
expect(watchMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('preserves remote event routing when acknowledged teardown rejects', async () => {
const sendOne = vi.fn()
const sendTwo = vi.fn()
const senderOne = createWatcherSender(1, sendOne)
const senderTwo = createWatcherSender(2, sendTwo)
const watchMock = vi.fn().mockResolvedValue(vi.fn())
const closeWatch = vi
.fn()
.mockRejectedValueOnce(new Error('Remote path is still watched by another client'))
.mockResolvedValueOnce(undefined)
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch })
await handlers['fs:watchWorktree'](
{ sender: senderOne },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
await expect(closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')).rejects.toThrow(
'still watched by another client'
)
const onEvents = watchMock.mock.calls[0][1]
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
await handlers['fs:watchWorktree'](
{ sender: senderTwo },
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(watchMock).toHaveBeenCalledTimes(1)
await emitRemote(onEvents, [{ path: '/home/me/repo/file-2.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(2)
expect(sendTwo).toHaveBeenCalledTimes(1)
await expect(
closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')
).resolves.toBeUndefined()
expect(closeWatch).toHaveBeenCalledTimes(2)
})
it('dedupes concurrent pending SSH worktree watcher installs', async () => {
const sendOne = vi.fn()
const sendTwo = vi.fn()
const senderOne = createWatcherSender(1, sendOne)
const senderTwo = createWatcherSender(2, sendTwo)
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]
await emitRemote(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 = createWatcherSender(1, sendOne)
const senderTwo = createWatcherSender(2, sendTwo)
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]
await emitRemote(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 = {
...createWatcherSender(1),
once: vi.fn((event: string, callback: () => void) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
})
}
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]
await emitRemote(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 = createWatcherSender(1)
const senderTwo = createWatcherSender(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]
await emitRemote(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 = {
...createWatcherSender(99),
once: vi.fn((event: string, callback: () => void) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
})
}
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(3)
expect(destroyedCallbacks).toHaveLength(1)
destroyedCallbacks[0]()
expect(unwatchMock).toHaveBeenCalledTimes(12)
expect(stat).not.toHaveBeenCalled()
expect(subscribeParcelWatcher).not.toHaveBeenCalled()
})
})
+1 -9
View File
@@ -4,11 +4,6 @@ import {
forgetLocalWatcherRemovalSnapshot,
restoreLocalWatcherAfterFailedRemoval
} from './filesystem-watcher-local-removal'
import {
closeRemoteWatcherForWorktreePath,
forgetRemoteWatcherRemovalSnapshot,
restoreRemoteWatcherAfterFailedRemoval
} from './filesystem-watcher-remote-removal'
// ── Public API ───────────────────────────────────────────────────────
@@ -19,8 +14,5 @@ export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = {
? closeLocalWatcherForWorktreePath(worktreePath, deadline)
: closeLocalWatcherForWorktreePath(worktreePath),
restoreLocal: restoreLocalWatcherAfterFailedRemoval,
forgetLocal: forgetLocalWatcherRemovalSnapshot,
closeRemote: closeRemoteWatcherForWorktreePath,
restoreRemote: restoreRemoteWatcherAfterFailedRemoval,
forgetRemote: forgetRemoteWatcherRemovalSnapshot
forgetLocal: forgetLocalWatcherRemovalSnapshot
}
-4
View File
@@ -42,10 +42,6 @@ vi.mock(
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
)
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
vi.mock(
'../providers/ssh-filesystem-dispatch',
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
)
vi.mock(
'../text-generation/commit-message-text-generation',
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
@@ -1,317 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
import { createRemoteWatcherEventBatch } from './remote-watcher-event-batch'
import {
WATCH_BATCH_INTERVAL_MS,
WATCH_BATCH_LEADING_MS
} from '../../shared/filesystem-watch-batch-window'
const ROOT = '/home/u/repo'
function makeBatch(deliver: (events: FsChangeEvent[]) => void, maxEvents = 5_000) {
return createRemoteWatcherEventBatch({
rootPath: ROOT,
deliver,
maxEvents
})
}
describe('createRemoteWatcherEventBatch', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('collapses pushes that arrive while a flush is armed into one delivery', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
for (let i = 0; i < 20; i++) {
batch.push([{ kind: 'update', absolutePath: `${ROOT}/file-${i}.ts` }])
vi.advanceTimersByTime(1)
}
expect(deliver).not.toHaveBeenCalled()
vi.advanceTimersByTime(WATCH_BATCH_LEADING_MS - 20)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toHaveLength(20)
})
it('waits a full interval after a delivery before the next one', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
vi.advanceTimersByTime(WATCH_BATCH_LEADING_MS)
expect(deliver).toHaveBeenCalledTimes(1)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/b.ts` }])
vi.advanceTimersByTime(WATCH_BATCH_INTERVAL_MS - 1)
expect(deliver).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1)
expect(deliver).toHaveBeenCalledTimes(2)
})
it('keeps delivering on the interval when pushes never stop', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
for (let i = 0; i < 100; i++) {
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
vi.advanceTimersByTime(10)
}
expect(deliver.mock.calls.length).toBeGreaterThanOrEqual(9)
expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
})
it('emits both events for delete then create, delete first', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
})
it('drops both events for create then delete', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'create', absolutePath: `${ROOT}/tmp.ts` },
{ kind: 'delete', absolutePath: `${ROOT}/tmp.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver).not.toHaveBeenCalled()
})
it('keeps a replacement delete when a directory is recreated as a file', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/dist` },
{ kind: 'create', absolutePath: `${ROOT}/dist` },
{ kind: 'update', absolutePath: `${ROOT}/dist`, isDirectory: false }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/dist` },
{ kind: 'update', absolutePath: `${ROOT}/dist`, isDirectory: false }
])
})
it('keeps the delete when a later update has unknown isDirectory', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/dist` },
{ kind: 'create', absolutePath: `${ROOT}/dist` },
{ kind: 'update', absolutePath: `${ROOT}/dist` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/dist` },
{ kind: 'update', absolutePath: `${ROOT}/dist` }
])
})
it('emits the net delete for delete then create then delete', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/dist/app.js` },
{ kind: 'create', absolutePath: `${ROOT}/dist/app.js` },
{ kind: 'delete', absolutePath: `${ROOT}/dist/app.js` }
])
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/dist/app.js` }
])
})
it('re-hoists the delete when the window ends on a recreate', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` },
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
})
it('keeps only the last update for one path', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }])
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }])
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }
])
})
it('discards the buffer and delivers one overflow when an overflow event arrives', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
batch.push([{ kind: 'overflow', absolutePath: ROOT }])
batch.push([{ kind: 'update', absolutePath: `${ROOT}/b.ts` }])
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }])
})
it('latches overflow once the accumulated batch exceeds maxEvents', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver, 100)
for (let i = 0; i < 3; i++) {
batch.push(
Array.from({ length: 50 }, (_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${ROOT}/wave-${i}-${index}.ts`
}))
)
}
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }])
})
it('never invents isDirectory for events that arrive without it', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'create', absolutePath: `${ROOT}/dir` }])
vi.advanceTimersByTime(150)
const [delivered] = deliver.mock.calls[0][0] as FsChangeEvent[]
expect(delivered.isDirectory).toBeUndefined()
expect('isDirectory' in delivered).toBe(false)
})
it('passes an unknown kind through verbatim after the coalesced set', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` },
{ kind: 'update', absolutePath: `${ROOT}/a.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: `${ROOT}/a.ts` },
{ kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` }
])
})
it('delivers nothing after close', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
batch.close()
vi.advanceTimersByTime(1_000)
expect(deliver).not.toHaveBeenCalled()
})
it('ignores a push that lands after close instead of arming a timer', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.close()
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(1_000)
expect(deliver).not.toHaveBeenCalled()
})
// Why: the host platform is irrelevant — the coalesce key must stay shape-based, so a case-sensitive
// remote pair must survive it on any host, and neither path may pick up drive/backslash rewriting.
it('keeps case-distinct remote POSIX paths separate and byte-identical', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: '/home/u/A' }])
batch.push([{ kind: 'update', absolutePath: '/home/u/a' }])
vi.advanceTimersByTime(150)
const delivered = deliver.mock.calls[0][0] as FsChangeEvent[]
expect(delivered).toEqual([
{ kind: 'update', absolutePath: '/home/u/A' },
{ kind: 'update', absolutePath: '/home/u/a' }
])
for (const event of delivered) {
expect(event.absolutePath).not.toContain('\\')
expect(event.absolutePath).not.toMatch(/^[a-zA-Z]:/)
}
})
it('keeps NFC- and NFD-distinct remote POSIX names separate', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
const nfcPath = `${ROOT}/caf\u00e9.txt`
const nfdPath = `${ROOT}/cafe\u0301.txt`
batch.push([{ kind: 'update', absolutePath: nfcPath }])
batch.push([{ kind: 'update', absolutePath: nfdPath }])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: nfcPath },
{ kind: 'update', absolutePath: nfdPath }
])
})
it('still coalesces equivalent Windows path spellings', () => {
const deliver = vi.fn()
const batch = createRemoteWatcherEventBatch({
rootPath: 'C:\\Repo',
deliver,
maxEvents: 5_000
})
batch.push([{ kind: 'update', absolutePath: 'C:\\Repo\\CAF\u00c9.txt' }])
batch.push([{ kind: 'update', absolutePath: 'c:/repo/cafe\u0301.txt' }])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: 'c:/repo/cafe\u0301.txt' }
])
})
})
-144
View File
@@ -1,144 +0,0 @@
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
import { watchBatchFlushDelayMs } from '../../shared/filesystem-watch-batch-window'
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
export type RemoteWatcherEventBatchOptions = {
rootPath: string
deliver: (events: FsChangeEvent[]) => void
maxEvents: number
}
export type RemoteWatcherEventBatch = {
push: (events: FsChangeEvent[]) => void
close: () => void
}
// Unlike local coalescing, a recreate keeps its delete through live updates so cached dirs are purged;
// delete→create→delete also remains a net delete because the path predated the batch.
function posixEventIdentity(absolutePath: string): string {
const normalized = absolutePath.replace(/\/+/g, '/')
return normalized === '/' ? normalized : normalized.replace(/\/+$/, '')
}
function coalesceRemoteEvents(raw: FsChangeEvent[], rootPath: string): FsChangeEvent[] {
const lastByKey = new Map<string, FsChangeEvent>()
const deleteBeforeCreate = new Map<string, FsChangeEvent>()
const passthrough: FsChangeEvent[] = []
const windowsPaths = isWindowsAbsolutePathLike(rootPath)
for (const event of raw) {
if (event.kind !== 'create' && event.kind !== 'update' && event.kind !== 'delete') {
// Why: 'rename' has no producer on this transport; forward it untouched rather than invent a rule.
passthrough.push(event)
continue
}
// POSIX event identity keeps byte-distinct names; Windows still folds separators and casing.
const key = windowsPaths
? normalizeRuntimePathForComparison(event.absolutePath)
: posixEventIdentity(event.absolutePath)
const prev = lastByKey.get(key)
if (prev) {
if (prev.kind === 'delete' && event.kind === 'create') {
deleteBeforeCreate.set(key, prev)
}
if (prev.kind === 'create' && event.kind === 'delete') {
// Why: cancel only a path created and removed entirely inside the window; an earlier delete means
// the file predates it, so the window nets out to a delete the renderer still has to act on.
const netNoOp = !deleteBeforeCreate.delete(key)
if (netNoOp) {
lastByKey.delete(key)
continue
}
}
}
lastByKey.set(key, event)
// A final delete subsumes the transition; a live update cannot prove the old entry was not a directory.
if (event.kind === 'delete') {
deleteBeforeCreate.delete(key)
}
}
return [...deleteBeforeCreate.values(), ...lastByKey.values(), ...passthrough]
}
export function createRemoteWatcherEventBatch({
rootPath,
deliver,
maxEvents
}: RemoteWatcherEventBatchOptions): RemoteWatcherEventBatch {
let buffered: FsChangeEvent[] = []
let overflowed = false
let lastFlushAt = 0
let timer: ReturnType<typeof setTimeout> | null = null
let closed = false
function flush(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
const pending = buffered
const latched = overflowed
buffered = []
overflowed = false
if (latched) {
lastFlushAt = Date.now()
deliver([{ kind: 'overflow', absolutePath: rootPath }])
return
}
const coalesced = coalesceRemoteEvents(pending, rootPath)
if (coalesced.length > 0) {
lastFlushAt = Date.now()
deliver(coalesced)
}
}
function schedule(): void {
// Why: never push an armed flush back; resetting on each event starved delivery under continuous writes.
if (timer) {
return
}
timer = setTimeout(flush, watchBatchFlushDelayMs(Date.now(), lastFlushAt))
}
return {
push(events) {
// Why: an in-flight provider receive can land after teardown; re-arming there would strand a timer.
if (closed) {
return
}
if (!overflowed) {
if (
buffered.length + events.length > maxEvents ||
events.some((event) => event.kind === 'overflow')
) {
// Why: past the cap precision only burns memory; one overflow buys the same conservative refresh.
buffered = []
overflowed = true
} else {
// Why: a deletion storm can exceed V8's argument limit for push(...events).
for (const event of events) {
buffered.push(event)
}
}
}
schedule()
},
close() {
closed = true
if (timer) {
clearTimeout(timer)
timer = null
}
buffered = []
overflowed = false
}
}
}
@@ -47,16 +47,4 @@ describe('watched worktree catalog notifications', () => {
error
)
})
it('keeps nested SSH discoveries local until the event can carry owner identity', () => {
const notifyRemote = vi.fn()
setWorktreeCatalogRemoteClientNotifier({
notifyWorktreeCatalogChangedForRemoteClients: notifyRemote
})
notifyWatchedWorktreeCatalogChanged(mainWindow as never, 'repo-1', 'ssh-target-1')
expect(notifyWorktreesChanged).toHaveBeenCalledWith(mainWindow, 'repo-1')
expect(notifyRemote).not.toHaveBeenCalled()
})
})
@@ -17,13 +17,9 @@ export function setWorktreeCatalogRemoteClientNotifier(
export function notifyWatchedWorktreeCatalogChanged(
mainWindow: BrowserWindow,
repoId: string,
connectionId?: string
repoId: string
): void {
notifyWorktreesChanged(mainWindow, repoId)
if (connectionId) {
return
}
try {
remoteClientNotifier?.notifyWorktreeCatalogChangedForRemoteClients(repoId)
} catch (error) {
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import { collectLocalWorktreeBaseChanges } from './worktree-base-directory-change-collector'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import { EMPTY_HEAD_IDENTITY_SCOPE, FULL_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope'
@@ -19,16 +16,6 @@ function makeTarget(): WorktreeBaseWatchTarget {
}
describe('worktree base change collector', () => {
it('states the full head scope on overflow rather than leaving it absent', () => {
// Overflow means every event in the window was lost. Stating FULL here — not
// relying on a downstream `?? FULL` for a missing field — is what keeps a
// future caller that forwards this object from reading it as "nothing moved".
const changes = collectRemoteWorktreeBaseChanges(makeTarget(), [{ kind: 'overflow' }] as never)
expect(changes.overflow).toBe(true)
expect(changes.headIdentityScope).toEqual(FULL_HEAD_IDENTITY_SCOPE)
})
it('unions the head scopes of every event in a burst', () => {
const changes = collectLocalWorktreeBaseChanges(makeTarget(), [
{ type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD') },
@@ -65,21 +52,4 @@ describe('worktree base change collector', () => {
expect(changes.headIdentityScope).toEqual(EMPTY_HEAD_IDENTITY_SCOPE)
expect(changes.structureRepoIds).toEqual(['repo-1'])
})
it('narrows a rename to the entries on both sides', () => {
const changes = collectRemoteWorktreeBaseChanges(makeTarget(), [
{
kind: 'rename',
absolutePath: join(COMMON_DIR, 'worktrees', 'wt-new', 'HEAD'),
oldAbsolutePath: join(COMMON_DIR, 'worktrees', 'wt-old', 'HEAD')
}
] as never)
expect(changes.headIdentityScope).toEqual({
listing: false,
primary: false,
all: false,
entryNames: new Set(['wt-old', 'wt-new'])
})
})
})
@@ -1,11 +1,9 @@
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
import {
classifyWorktreeBaseChange,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import {
EMPTY_HEAD_IDENTITY_SCOPE,
FULL_HEAD_IDENTITY_SCOPE,
mergeHeadIdentityScopes,
type WorktreeHeadIdentityScope
} from './worktree-head-identity-scope'
@@ -16,7 +14,6 @@ type WorktreeBaseWatcherEvent = {
}
export type WorktreeBaseCollectedChanges = {
overflow: boolean
structureRepoIds: string[]
gitStatusRepoIds: string[]
headIdentityRepoIds: string[]
@@ -48,20 +45,6 @@ function emptyBuckets(): ChangeBuckets {
}
}
// Why: overflow means every event in the window was lost, so the scope must be
// stated as FULL here rather than left to a downstream `?? FULL` on an absent
// field — a caller that forwards this object must not read it as "nothing moved".
function overflowChanges(): WorktreeBaseCollectedChanges {
return {
overflow: true,
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: [],
headIdentityScope: FULL_HEAD_IDENTITY_SCOPE,
gitStatusScope: FULL_HEAD_IDENTITY_SCOPE
}
}
function addMatchingChange(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent,
@@ -86,7 +69,6 @@ function addMatchingChange(
function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges {
return {
overflow: false,
structureRepoIds: [...buckets.structureRepoIds],
gitStatusRepoIds: [...buckets.gitStatusRepoIds],
headIdentityRepoIds: [...buckets.headIdentityRepoIds],
@@ -105,24 +87,3 @@ export function collectLocalWorktreeBaseChanges(
}
return toCollectedChanges(buckets)
}
export function collectRemoteWorktreeBaseChanges(
target: WorktreeBaseWatchTarget,
events: FsChangeEvent[]
): WorktreeBaseCollectedChanges {
const buckets = emptyBuckets()
for (const event of events) {
if (event.kind === 'overflow') {
return overflowChanges()
}
if (event.kind === 'rename') {
if (event.oldAbsolutePath) {
addMatchingChange(target, { type: 'delete', path: event.oldAbsolutePath }, buckets)
}
addMatchingChange(target, { type: 'create', path: event.absolutePath }, buckets)
continue
}
addMatchingChange(target, { type: event.kind, path: event.absolutePath }, buckets)
}
return toCollectedChanges(buckets)
}
@@ -44,7 +44,6 @@ export type WorktreeBaseWatchTarget = {
key: string
kind: WorktreeBaseWatchKind
path: string
connectionId?: string
repos: Map<string, WorktreeBaseRepoWatchConfig>
/** Exact upstream leaf selected by accepted active-worktree status. */
gitStatusRefPaths?: ReadonlySet<string>
@@ -25,7 +25,7 @@ const ACTIVE_PATH = join('/work', 'feature')
const SIBLING_PATH = join('/work', 'other')
const HEAD = 'a'.repeat(40)
function makeWatch(connectionId?: string): WorktreeBaseNotificationWatch {
function makeWatch(): WorktreeBaseNotificationWatch {
const headIdentityRefresh = createWorktreeHeadIdentityRefreshState()
headIdentityRefresh.cache.primary = { worktreePath: PRIMARY_PATH, head: HEAD, branch: null }
headIdentityRefresh.cache.entries.set('Feature1', {
@@ -42,7 +42,6 @@ function makeWatch(connectionId?: string): WorktreeBaseNotificationWatch {
key: 'git-common:local',
kind: 'git-common',
path: COMMON_DIR,
...(connectionId ? { connectionId } : {}),
repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: notifications only call isDestroyed.
mainWindow: { isDestroyed: () => false } as never,
@@ -169,18 +168,9 @@ describe('scheduleWorktreeBaseNotification git status attribution', () => {
)
})
it('never attributes SSH watches, which keep no local head memo', async () => {
await flush(makeWatch('ssh-1'), [['worktrees', 'other', 'index']])
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(
expect.anything(),
'repo-1',
null
)
})
it('keeps notifying while SSH index writes never pause', async () => {
const watch = makeWatch('ssh-1')
// Raw provider events every 100 ms: a restarting debounce would never fire.
it('keeps notifying while index writes never pause', async () => {
const watch = makeWatch()
// Events every 100 ms: a restarting debounce would never fire.
for (let elapsed = 0; elapsed < 2_000; elapsed += 100) {
scheduleWorktreeBaseNotification(watch, { gitStatusRepoIds: ['repo-1'] })
await vi.advanceTimersByTimeAsync(100)
@@ -38,12 +38,12 @@ export function clearPendingWorktreeBaseNotifications(watch: WorktreeBaseNotific
}
export function supportsWorktreeHeadIdentityRefresh(watch: WorktreeBaseNotificationWatch): boolean {
return watch.kind === 'git-common' && !watch.connectionId
return watch.kind === 'git-common'
}
export function scheduleWorktreeBaseNotification(
watch: WorktreeBaseNotificationWatch,
changes: Partial<Omit<WorktreeBaseCollectedChanges, 'overflow'>>
changes: Partial<WorktreeBaseCollectedChanges>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
clearPendingWorktreeBaseNotifications(watch)
@@ -69,8 +69,8 @@ export function scheduleWorktreeBaseNotification(
watch.pendingGitStatusScope,
changes.gitStatusScope ?? FULL_HEAD_IDENTITY_SCOPE
)
// Why: never push an armed notification back; SSH feeds raw provider events, so sustained
// index writes would otherwise delay it indefinitely. Pending changes join the armed one.
// Why: never push an armed notification back; sustained index writes would otherwise
// delay it indefinitely. Pending changes join the armed one.
if (watch.notifyTimer) {
return
}
@@ -89,14 +89,14 @@ export function scheduleWorktreeBaseNotification(
)
const emitHeadIdentities = pendingStructure.length === 0
const headIdentityScope = watch.pendingHeadIdentityScope
// Resolved before the head refresh below can rewrite the memo. Only local
// watches keep one; SSH stays unattributed so every checkout still refreshes.
// Resolved before the head refresh below can rewrite the memo. Only git-common
// watches keep one; base watches stay unattributed so every checkout still refreshes.
const gitStatusWorktreePaths = supportsWorktreeHeadIdentityRefresh(watch)
? cachedWorktreePathsForScope(watch.headIdentityRefresh.cache, watch.pendingGitStatusScope)
: null
clearPendingWorktreeBaseNotifications(watch)
for (const repoId of pendingStructure) {
notifyWatchedWorktreeCatalogChanged(watch.mainWindow, repoId, watch.connectionId)
notifyWatchedWorktreeCatalogChanged(watch.mainWindow, repoId)
}
for (const repoId of sourceControlRepoIds) {
notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId, gitStatusWorktreePaths)
@@ -1,6 +1,5 @@
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
@@ -50,9 +49,8 @@ export function handleLocalWatchEvents(
// Why: after a dropped event batch nothing about the prior state can be
// trusted — widen unconditionally (structural + status + head-identity),
// same shape as the remote overflow branch below, bypassing the watcher-error
// cooldown so a burst of overflows during one bulk op cannot suppress the
// refresh the fleet actually needs.
// bypassing the watcher-error cooldown so a burst of overflows during one
// bulk op cannot suppress the refresh the fleet actually needs.
export function handleWatchOverflow(
watch: ActiveWatch,
getActiveWatches: () => Iterable<ActiveWatch>
@@ -63,28 +61,3 @@ export function handleWatchOverflow(
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
export function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
getActiveWatches
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
handleWatchOverflow(watch, getActiveWatches)
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
@@ -3,8 +3,7 @@ import { join, sep } from 'node:path'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
const { getSshFilesystemProviderMock, readFileMock, realpathMock, statMock } = vi.hoisted(() => ({
getSshFilesystemProviderMock: vi.fn(),
const { readFileMock, realpathMock, statMock } = vi.hoisted(() => ({
readFileMock: vi.fn(),
realpathMock: vi.fn(),
statMock: vi.fn()
@@ -16,10 +15,6 @@ vi.mock('node:fs/promises', () => ({
stat: statMock
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
}))
import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings,
@@ -30,7 +25,6 @@ const absolutePath = (...parts: string[]): string => join(sep, ...parts)
const WORKTREE_ROOT = absolutePath('workspace', 'worktrees')
const PROJECT_ROOT = absolutePath('workspace', 'projects')
const localDirectoryStat = { isDirectory: () => true }
const remoteDirectoryStat = { type: 'directory', size: 0, mtime: 0 }
const settings = {
workspaceDir: WORKTREE_ROOT,
nestWorkspaces: true
@@ -54,14 +48,6 @@ function makeStore(repos: Repo[]) {
}
}
function makeRemoteProvider() {
return {
stat: vi.fn(async () => remoteDirectoryStat),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false }))
}
}
describe('worktree base directory watch target resolution', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
@@ -69,7 +55,6 @@ describe('worktree base directory watch target resolution', () => {
statMock.mockReset().mockResolvedValue(localDirectoryStat)
realpathMock.mockReset().mockImplementation(async (path: string) => path)
readFileMock.mockReset().mockResolvedValue('')
getSshFilesystemProviderMock.mockReset().mockReturnValue(undefined)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
@@ -173,71 +158,18 @@ describe('worktree base directory watch target resolution', () => {
expect(repoIds).toContain(goodAfter.id)
})
it('keeps identical SSH paths isolated by connection and provider', async () => {
const providerA = makeRemoteProvider()
const providerB = makeRemoteProvider()
getSshFilesystemProviderMock.mockImplementation((connectionId: string) =>
connectionId === 'ssh-a' ? providerA : connectionId === 'ssh-b' ? providerB : undefined
)
const sharedPath = '/srv/project'
const repos = [
makeRepo(0, { path: sharedPath, connectionId: 'ssh-a' }),
makeRepo(1, { path: sharedPath, connectionId: 'ssh-b' })
]
const targets = await buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never)
expect(providerA.stat).toHaveBeenCalledTimes(2)
expect(providerA.realpath).toHaveBeenCalledTimes(2)
expect(providerB.stat).toHaveBeenCalledTimes(2)
expect(providerB.realpath).toHaveBeenCalledTimes(2)
expect([...targets.values()].map((target) => target.connectionId)).toEqual([
'ssh-a',
'ssh-a',
'ssh-b',
'ssh-b'
])
expect(new Set(targets.keys()).size).toBe(4)
})
it('watches SSH rows by their resolved target, and not a runtime row with a nested connection', async () => {
const provider = makeRemoteProvider()
getSshFilesystemProviderMock.mockImplementation((connectionId: string) =>
connectionId === 'ssh-a' ? provider : undefined
)
const repos = [
makeRepo(0, { path: '/srv/ssh-only', executionHostId: 'ssh:ssh-a' }),
makeRepo(1, {
path: '/srv/runtime',
executionHostId: 'runtime:env-1',
connectionId: 'ssh-a'
}),
makeRepo(2, { path: '/srv/legacy', connectionId: 'ssh-a' })
]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: target resolution reads only settings and repos.
const store = makeStore(repos) as never
const targets = [...(await buildWorktreeBaseDirectoryWatchTargets(store)).values()]
expect(new Set(targets.flatMap((target) => [...target.repos.keys()]))).toEqual(
new Set(['repo-0', 'repo-2'])
)
expect(new Set(targets.map((target) => target.connectionId))).toEqual(new Set(['ssh-a']))
})
it('skips folder workspaces, unavailable SSH providers, and WSL UNC roots', async () => {
it('skips folder workspaces, SSH repos, and WSL UNC roots', async () => {
const folder = makeRepo(0, { kind: 'folder' })
const unavailableSsh = makeRepo(1, { connectionId: 'missing', path: '/srv/missing' })
const ssh = makeRepo(1, { connectionId: 'ssh-a', path: '/srv/ssh' })
const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\project'
const wsl = makeRepo(2, { path: wslPath, worktreeBasePath: wslPath })
const targets = await buildWorktreeBaseDirectoryWatchTargets(
makeStore([folder, unavailableSsh, wsl]) as never
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: target resolution reads only settings and repos.
const store = makeStore([folder, ssh, wsl]) as never
const targets = await buildWorktreeBaseDirectoryWatchTargets(store)
expect(targets.size).toBe(0)
expect(statMock).not.toHaveBeenCalled()
expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('missing')
})
// A mirrored layout puts the working trees inside the distro while the gitdir
@@ -1,32 +1,18 @@
import { normalize } from 'node:path'
import { realpath, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import type { Store } from '../persistence'
import type { FileStat, IFilesystemProvider } from '../providers/filesystem-provider-contract'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
import {
getRepoDialableSshTargetId,
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID
} from '../../shared/execution-host'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { isFolderRepo } from '../../shared/repo-kind'
import {
isRuntimePathAbsolute,
isWindowsAbsolutePathLike,
getRuntimePathBasename,
normalizeRuntimePathForComparison,
resolveRuntimePath
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import {
computeWorkspaceRoot,
getWorktreePathSettings,
hasRepoWorktreeBasePath
} from './worktree-logic'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
import { shouldEmitBoundedWarning } from './bounded-warning-dedupe'
import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory'
import type {
@@ -38,28 +24,14 @@ import type {
const missingRootWarnings = new Set<string>()
const skippedWslWarnings = new Set<string>()
// Why: match existing worktree probe caps while bounding aggregate SSH filesystem RPC pressure.
// Why: match existing worktree probe caps.
export const WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY = 8
function normalizeWatchKey(pathValue: string): string {
return normalizeRuntimePathForComparison(normalize(pathValue))
}
async function canonicalizeExistingPath(
pathValue: string,
connectionId: string | undefined
): Promise<string> {
if (connectionId) {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return normalize(pathValue)
}
try {
return await provider.realpath(pathValue)
} catch {
return normalize(pathValue)
}
}
async function canonicalizeExistingPath(pathValue: string): Promise<string> {
try {
return await realpath(pathValue)
} catch {
@@ -67,22 +39,14 @@ async function canonicalizeExistingPath(
}
}
function isDirectoryStat(value: Stats | FileStat | undefined): boolean {
if (!value) {
return false
}
return 'type' in value ? value.type === 'directory' : value.isDirectory()
}
async function addTarget(
targets: Map<string, WorktreeBaseWatchTarget>,
kind: WorktreeBaseWatchKind,
pathValue: string,
config: WorktreeBaseRepoWatchConfig,
connectionId?: string
config: WorktreeBaseRepoWatchConfig
): Promise<void> {
const watchedPath = await canonicalizeExistingPath(pathValue, connectionId)
const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}`
const watchedPath = await canonicalizeExistingPath(pathValue)
const key = `${kind}:local:${normalizeWatchKey(watchedPath)}`
const existing = targets.get(key)
if (existing) {
existing.repos.set(config.repoId, config)
@@ -92,44 +56,10 @@ async function addTarget(
key,
kind,
path: watchedPath,
...(connectionId ? { connectionId } : {}),
repos: new Map([[config.repoId, config]])
})
}
function getRemoteProvider(connectionId: string | undefined): IFilesystemProvider | undefined {
return connectionId ? getSshFilesystemProvider(connectionId) : undefined
}
function isRuntimePathAbsoluteForRepo(repoPath: string, pathValue: string): boolean {
const pathFlavor =
isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(pathValue)
? 'windows'
: 'posix'
return isRuntimePathAbsolute(pathValue, pathFlavor)
}
function getBaseWatchLayout(
repo: Repo,
pathSettings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>,
connectionId: string | undefined
): { workspaceRoot: string; nestWorkspaces: boolean } {
if (
connectionId &&
!hasRepoWorktreeBasePath(repo) &&
isRuntimePathAbsoluteForRepo(repo.path, pathSettings.workspaceDir)
) {
// Why: SSH creates default worktrees beside the remote repo when the
// global workspace dir is a desktop-local absolute path.
return { workspaceRoot: resolveRuntimePath(repo.path, '..'), nestWorkspaces: false }
}
return {
workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings),
nestWorkspaces: pathSettings.nestWorkspaces
}
}
function warnSkippedWslRoot(repoId: string, workspaceRoot: string): void {
if (shouldEmitBoundedWarning(skippedWslWarnings, `${repoId}:${workspaceRoot}`)) {
console.warn(`[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}`)
@@ -140,19 +70,14 @@ async function maybeAddBaseTarget(
targets: Map<string, WorktreeBaseWatchTarget>,
repo: Repo,
settings: GlobalSettings,
mirrorDistro: string | undefined,
connectionId?: string
mirrorDistro: string | undefined
): Promise<void> {
const pathSettings = getWorktreePathSettings(repo, settings, mirrorDistro)
const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId)
const workspaceRoot = computeWorkspaceRoot(repo.path, pathSettings)
const config = {
repoId: repo.id,
repoName: getRuntimePathBasename(repo.path).replace(/\.git$/, ''),
nestWorkspaces
}
const remoteProvider = getRemoteProvider(connectionId)
if (connectionId && !remoteProvider) {
return
nestWorkspaces: pathSettings.nestWorkspaces
}
// Why: WSL UNC paths are unreliable for native watching. A repo inside the
// distro has nothing watchable at all; a Windows-drive repo whose worktrees
@@ -165,11 +90,8 @@ async function maybeAddBaseTarget(
warnSkippedWslRoot(repo.id, workspaceRoot)
} else {
try {
const rootStat = remoteProvider
? await remoteProvider.stat(workspaceRoot)
: await stat(workspaceRoot)
if (isDirectoryStat(rootStat)) {
await addTarget(targets, 'base', workspaceRoot, config, connectionId)
if ((await stat(workspaceRoot)).isDirectory()) {
await addTarget(targets, 'base', workspaceRoot, config)
}
} catch {
const key = normalizeWatchKey(workspaceRoot)
@@ -179,17 +101,9 @@ async function maybeAddBaseTarget(
}
}
const commonDir = await resolveWorktreeCommonGitDirectory(
repo,
remoteProvider
? {
stat: (path) => remoteProvider.stat(path),
readFile: async (path) => (await remoteProvider.readFile(path)).content
}
: undefined
)
const commonDir = await resolveWorktreeCommonGitDirectory(repo)
if (commonDir && !isWslUncPath(commonDir)) {
await addTarget(targets, 'git-common', commonDir, config, connectionId)
await addTarget(targets, 'git-common', commonDir, config)
}
}
@@ -202,11 +116,9 @@ async function resolveRepoTargets(
if (isFolderRepo(repo)) {
return targets
}
const sshTargetId = getRepoDialableSshTargetId(repo)
// Why: the desktop can only watch its own disk; an SSH repo has no local root to watch.
if (getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) {
await maybeAddBaseTarget(targets, repo, settings, mirrorDistro)
} else if (sshTargetId) {
await maybeAddBaseTarget(targets, repo, settings, mirrorDistro, sshTargetId)
}
return targets
}
@@ -27,10 +27,6 @@ vi.mock('./worktree-remote', () => ({
notifyWorktreesChanged: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
vi.mock('./worktree-head-identity-reader', () => ({
readGitCommonHeadIdentities: vi.fn(async () => ({ identities: [], complete: true })),
createWorktreeHeadIdentityCache: () => ({
@@ -42,7 +38,6 @@ vi.mock('./worktree-head-identity-reader', () => ({
})
}))
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
notifyWorktreeGitStatusMetadataChanged,
notifyWorktreeHeadIdentitiesChanged,
@@ -118,7 +113,6 @@ describe('worktree base directory watcher', () => {
watcherCallbacks.clear()
unsubscribeMocks.clear()
pollerOptions.clear()
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
mockHeadIdentities([])
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(
async (target, _getRepos, onEvents, options) => {
@@ -275,53 +269,6 @@ describe('worktree base directory watcher', () => {
expect(getPaths?.()).toEqual([])
})
it('filters SSH common-dir events to the active worktree upstream ref', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return vi.fn()
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
await setWorktreeGitStatusRefWatch(
{
worktreeId: 'repo-1::/home/alice/project',
worktreePath: '/home/alice/project',
executionHostId: 'ssh:ssh-1',
connectionId: 'ssh-1',
branch: 'refs/heads/feature',
upstreamName: 'origin/feature'
},
async () => 'refs/remotes/origin/feature'
)
remoteCallbacks.get('/home/alice/project/.git')?.([
{
kind: 'update',
absolutePath: '/home/alice/project/.git/refs/remotes/origin/unrelated'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
remoteCallbacks.get('/home/alice/project/.git')?.([
{
kind: 'update',
absolutePath: '/home/alice/project/.git/refs/remotes/origin/feature'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(...UNATTRIBUTED_STATUS)
})
it('invalidates exact resolution only when common config changes', async () => {
const worktreeId = `repo-1::${PROJECT_ROOT}`
const request = {
@@ -740,132 +687,21 @@ describe('worktree base directory watcher', () => {
expect(readGitCommonHeadIdentities).not.toHaveBeenCalled()
})
it('never reads head identities for SSH watches', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return vi.fn()
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
remoteCallbacks.get('/home/alice/project/.git')?.([
{
kind: 'update',
absolutePath: '/home/alice/project/.git/worktrees/wt/logs/HEAD'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(readGitCommonHeadIdentities).not.toHaveBeenCalled()
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
})
it('does not install local desktop watchers for runtime or folder repos', async () => {
await syncWorktreeBaseDirectoryWatchers(
makeStore([
makeRepo({ id: 'runtime', executionHostId: 'runtime:dev' }),
makeRepo({ id: 'folder', kind: 'folder' })
]) as never,
makeWindow() as never
)
it('does not install local desktop watchers for runtime, SSH or folder repos', async () => {
const repos = [
makeRepo({ id: 'runtime', executionHostId: 'runtime:dev' }),
makeRepo({ id: 'ssh', connectionId: 'ssh-1', path: '/home/alice/project' }),
makeRepo({ id: 'folder', kind: 'folder' })
]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the watcher reads only settings and repos.
const store = makeStore(repos) as never
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: no watcher installs, so the window is never read.
const mainWindow = makeWindow() as never
await syncWorktreeBaseDirectoryWatchers(store, mainWindow)
expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled()
})
it('uses the remote sibling root for default SSH worktree roots', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteUnwatch = vi.fn()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return remoteUnwatch
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled()
expect(remoteWatch).toHaveBeenCalledWith('/home/alice', expect.any(Function))
remoteCallbacks.get('/home/alice')?.([
{
kind: 'create',
absolutePath: '/home/alice/external-5104/.git'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
await disposeWorktreeBaseDirectoryWatchers()
expect(remoteUnwatch).toHaveBeenCalled()
})
it('treats remote index renames as status-only and overflow as structural', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return vi.fn()
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
const request = {
worktreeId: 'repo-1::/home/alice/project',
worktreePath: '/home/alice/project',
executionHostId: 'ssh:ssh-1',
connectionId: 'ssh-1',
branch: 'refs/heads/feature',
upstreamName: 'origin/feature'
}
const resolve = vi.fn(async () => 'refs/remotes/origin/feature')
await setWorktreeGitStatusRefWatch(request, resolve)
remoteCallbacks.get('/home/alice/project/.git')?.([
{
kind: 'rename',
oldAbsolutePath: '/home/alice/project/.git/worktrees/wt/index.lock',
absolutePath: '/home/alice/project/.git/worktrees/wt/index'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
await setWorktreeGitStatusRefWatch(request, resolve)
expect(resolve).toHaveBeenCalledOnce()
vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear()
remoteCallbacks.get('/home/alice/project/.git')?.([{ kind: 'overflow' }] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
await setWorktreeGitStatusRefWatch(request, resolve)
expect(resolve).toHaveBeenCalledTimes(2)
})
it('unsubscribes roots that disappear after repo settings change', async () => {
const repo = makeRepo()
const store = makeStore([repo])
@@ -1,6 +1,5 @@
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
createWorktreeHeadIdentityRefreshState,
disposeWorktreeHeadIdentityRefreshState,
@@ -29,7 +28,6 @@ import {
import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
import {
handleLocalWatchEvents,
handleRemoteWatchEvents,
handleWatchOverflow,
type ActiveWatch
} from './worktree-base-directory-watch-events'
@@ -75,27 +73,6 @@ async function subscribeTarget(
let activeWatch: ActiveWatch | null = null
const gitStatusRefPaths = new Set<string>()
applyActiveGitStatusRefBinding({ ...target, gitStatusRefPaths })
if (target.connectionId) {
const provider = getSshFilesystemProvider(target.connectionId)
if (!provider) {
throw new Error(`SSH filesystem provider unavailable for ${target.connectionId}`)
}
const unwatch = await provider.watch(target.path, (events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (!currentWatch || currentWatch.disposed) {
return
}
handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values())
})
activeWatch = createActiveWatch(
target,
mainWindow,
{ unsubscribe: async () => unwatch() },
gitStatusRefPaths
)
return activeWatch
}
// Why: a recursive native watcher here forced fseventsd to deliver every
// event under the whole workspace root (all worktrees) / whole common .git
// (objects included) just to observe a few shallow paths. The poller reads
+5 -27
View File
@@ -1,4 +1,3 @@
import type { Stats } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import type { Repo } from '../../shared/repo-types'
import {
@@ -6,22 +5,6 @@ import {
normalizeRuntimePathSeparators,
resolveRuntimePath
} from '../../shared/cross-platform-path'
import type { FileStat } from '../providers/filesystem-provider-contract'
type GitDirectoryStat = Stats | FileStat
type GitDirectoryAccess = {
stat?: (path: string) => Promise<GitDirectoryStat>
readFile?: (path: string) => Promise<string>
}
function isDirectoryStat(value: GitDirectoryStat): boolean {
return 'type' in value ? value.type === 'directory' : value.isDirectory()
}
function isFileStat(value: GitDirectoryStat): boolean {
return 'type' in value ? value.type === 'file' : value.isFile()
}
function runtimeDirname(pathValue: string): string {
const normalized = normalizeRuntimePathSeparators(pathValue).replace(/\/+$/, '')
@@ -35,22 +18,17 @@ function runtimeDirname(pathValue: string): string {
return normalized.slice(0, index)
}
export async function resolveWorktreeCommonGitDirectory(
repo: Repo,
access: GitDirectoryAccess = {}
): Promise<string | null> {
export async function resolveWorktreeCommonGitDirectory(repo: Repo): Promise<string | null> {
const dotGitPath = resolveRuntimePath(repo.path, '.git')
const statPath = access.stat ?? stat
const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8'))
try {
const dotGitStat = await statPath(dotGitPath)
if (isDirectoryStat(dotGitStat)) {
const dotGitStat = await stat(dotGitPath)
if (dotGitStat.isDirectory()) {
return dotGitPath
}
if (!isFileStat(dotGitStat)) {
if (!dotGitStat.isFile()) {
return null
}
const content = await readText(dotGitPath)
const content = await readFile(dotGitPath, 'utf8')
const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim()
if (!gitDir) {
return null
@@ -6,10 +6,7 @@ import {
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
export type GitStatusRefWatchTarget = Pick<
WorktreeBaseWatchTarget,
'kind' | 'connectionId' | 'repos' | 'path'
> & {
export type GitStatusRefWatchTarget = Pick<WorktreeBaseWatchTarget, 'kind' | 'repos' | 'path'> & {
gitStatusRefPaths: Set<string>
}
@@ -50,7 +47,7 @@ function watchMatchesBinding(
): boolean {
return (
watch.kind === 'git-common' &&
watch.connectionId === binding.connectionId &&
binding.connectionId === undefined &&
watch.repos.has(binding.repoId)
)
}
@@ -61,7 +58,7 @@ function watchMatchesResolution(
): boolean {
return (
watch.kind === 'git-common' &&
watch.connectionId === resolution.connectionId &&
resolution.connectionId === undefined &&
watch.repos.has(resolution.repoId)
)
}
@@ -3,14 +3,7 @@ import { getWorktreeWatcherRemoval, setWorktreeWatcherRemoval } from './worktree
/** The renderer-less default stays inert; installed ports route method calls. */
describe('WorktreeWatcherRemoval port', () => {
const METHODS = [
'closeLocal',
'restoreLocal',
'forgetLocal',
'closeRemote',
'restoreRemote',
'forgetRemote'
] as const
const METHODS = ['closeLocal', 'restoreLocal', 'forgetLocal'] as const
it('defaults to inert so a renderer-less host is honest, not broken', async () => {
setWorktreeWatcherRemoval(null)
@@ -27,10 +20,7 @@ describe('WorktreeWatcherRemoval port', () => {
setWorktreeWatcherRemoval({
closeLocal: async () => void calls.push('closeLocal'),
restoreLocal: async () => void calls.push('restoreLocal'),
forgetLocal: () => void calls.push('forgetLocal'),
closeRemote: async () => void calls.push('closeRemote'),
restoreRemote: async () => void calls.push('restoreRemote'),
forgetRemote: () => void calls.push('forgetRemote')
forgetLocal: () => void calls.push('forgetLocal')
})
for (const method of METHODS) {
await getWorktreeWatcherRemoval()[method]('conn', '/tmp/w' as never)
+1 -7
View File
@@ -17,18 +17,12 @@ export type WorktreeWatcherRemoval = {
closeLocal(worktreePath: string, deadline?: WatcherRemovalDeadline): Promise<void>
restoreLocal(worktreePath: string): Promise<void>
forgetLocal(worktreePath: string): void
closeRemote(connectionId: string, worktreePath: string): Promise<void>
restoreRemote(connectionId: string, worktreePath: string): Promise<void>
forgetRemote(connectionId: string, worktreePath: string): void
}
const inert: WorktreeWatcherRemoval = {
closeLocal: async () => {},
restoreLocal: async () => {},
forgetLocal: () => {},
closeRemote: async () => {},
restoreRemote: async () => {},
forgetRemote: () => {}
forgetLocal: () => {}
}
let current: WorktreeWatcherRemoval = inert
@@ -45,9 +45,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -32,9 +32,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -38,9 +38,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -38,9 +38,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -73,9 +73,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -29,9 +29,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -37,9 +37,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -31,9 +31,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -27,9 +27,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -38,9 +38,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -48,9 +48,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -7,7 +7,6 @@ import {
import {
listWorktreesMock,
removeWorktreeMock,
getSshFilesystemProviderMock,
deleteWorktreeHistoryDirMock,
advertisedUrlWatcherForgetWorktreeMock,
killAllProcessesForWorktreeMock,
@@ -43,9 +42,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -148,8 +144,6 @@ describe('registerWorktreeHandlers', () => {
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
// The whole point: local-only cleanup never dispatches to the SSH provider.
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
expect(listWorktreesMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
@@ -540,7 +534,6 @@ describe('worktree execution-host disambiguation', () => {
hooks: null,
mayNeedUpdate: false
})
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
expect(hasHooksFileMock).not.toHaveBeenCalled()
})
@@ -35,9 +35,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -32,9 +32,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -29,9 +29,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -35,9 +35,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -50,9 +50,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -37,9 +37,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -38,9 +38,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -62,9 +62,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -43,9 +43,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -39,9 +39,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -35,9 +35,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -28,9 +28,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -42,9 +42,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
-2
View File
@@ -52,7 +52,6 @@ import {
computeWorktreePathMock,
ensurePathWithinWorkspaceMock,
gitExecFileAsyncMock,
getSshFilesystemProviderMock,
deleteWorktreeHistoryDirMock,
advertisedUrlWatcherForgetWorktreeMock,
pruneCleanupScanSnapshotMock,
@@ -128,7 +127,6 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
computeWorktreePathMock,
ensurePathWithinWorkspaceMock,
gitExecFileAsyncMock,
getSshFilesystemProviderMock,
mainWindow.webContents.send,
store.getRepos,
store.getRepo,
@@ -82,7 +82,6 @@ export const loadHooksMock: ModuleMock = vi.fn()
export const computeWorktreePathMock: Mock<typeof computeWorktreePath> = vi.fn()
export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn()
export const gitExecFileAsyncMock: GitArgvMock = vi.fn()
export const getSshFilesystemProviderMock: ModuleMock = vi.fn()
export const deleteWorktreeHistoryDirMock: ModuleMock = vi.fn()
export const advertisedUrlWatcherForgetWorktreeMock: ModuleMock = vi.fn()
export const pruneCleanupScanSnapshotMock: ModuleMock = vi.fn().mockResolvedValue(undefined)
@@ -155,10 +154,6 @@ export const hostedReviewModuleMock = () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
})
export const sshFilesystemDispatchModuleMock = () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
})
export const worktreeSymlinksModuleMock = (): {
createWorktreeCopiedPaths: ModuleMock
createWorktreeLinkedPaths: ModuleMock
@@ -49,9 +49,6 @@ vi.mock('../github/client', async () =>
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
@@ -1,42 +0,0 @@
import type { IFilesystemProvider } from './filesystem-provider-contract'
// Why still here: only the remote file-watcher subsystem reads it, and nothing in production
// registers a provider; that subsystem and this table go together in their own change.
const sshProviders = new Map<string, IFilesystemProvider>()
// Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old
// transport (file watches) needs a signal to rebuild it — nothing else marks that boundary.
const registrationListeners = new Set<(connectionId: string) => void>()
export function onSshFilesystemProviderRegistered(
listener: (connectionId: string) => void
): () => void {
registrationListeners.add(listener)
return () => {
registrationListeners.delete(listener)
}
}
export function registerSshFilesystemProvider(
connectionId: string,
provider: IFilesystemProvider
): void {
sshProviders.set(connectionId, provider)
for (const listener of registrationListeners) {
try {
listener(connectionId)
} catch (error) {
// Why: relay establish must not fail because a subscriber threw.
console.warn('[ssh-filesystem] provider registration listener failed:', error)
}
}
}
export function unregisterSshFilesystemProvider(connectionId: string): void {
sshProviders.delete(connectionId)
}
export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined {
return sshProviders.get(connectionId)
}
@@ -457,64 +457,6 @@ describe('external worktree discovery for paired clients', () => {
expect(resultType(await reader.next('collision-events'))).toBe('reposChanged')
})
it('does not publish a host-blind event for a nested SSH watcher', async () => {
const runtime = new OrcaRuntimeService(makeStore() as never)
setWorktreeCatalogRemoteClientNotifier(runtime)
const userDataPath = mkdtempSync(join(tmpdir(), 'o-ewd-ssh-owner-'))
tempDirs.push(userDataPath)
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
servers.push(server)
await server.start()
const offer = server.createPairingOffer({
address: '127.0.0.1',
name: 'nested-ssh-client',
scope: 'runtime'
})
if (!offer.available) {
throw new Error('pairing_unavailable')
}
const client = await authenticate(offer.pairingUrl)
const reader = createReader(client)
sessions.push(client)
readers.push(reader)
send(client, { id: 'ssh-events', method: 'runtime.clientEvents.subscribe' })
await reader.next('ssh-events', (response) => resultType(response) === 'ready')
const sendToHostRenderer = vi.fn()
const watch = {
key: 'ssh:repo-collision',
kind: 'base' as const,
path: '/remote/worktrees',
connectionId: 'ssh-target-1',
repos: new Map(),
mainWindow: {
isDestroyed: () => false,
webContents: { send: sendToHostRenderer }
},
notifyTimer: null,
pendingStructureRepoIds: new Set<string>(),
pendingGitStatusRepoIds: new Set<string>(),
pendingHeadIdentityRepoIds: new Set<string>(),
pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE,
pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE,
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
vi.useFakeTimers()
scheduleWorktreeBaseNotification(watch as never, { structureRepoIds: [REPO_ID] })
await vi.advanceTimersByTimeAsync(250)
vi.useRealTimers()
expect(sendToHostRenderer).toHaveBeenCalledWith('worktrees:changed', { repoId: REPO_ID })
runtime.notifyReposChangedForRemoteClients()
expect(resultType(await reader.next('ssh-events'))).toBe('reposChanged')
})
it('keeps the shared runtime publication valid without a headed renderer', async () => {
vi.mocked(listWorktrees).mockResolvedValue([initialWorktree])
vi.mocked(listWorktreesStrict).mockResolvedValue([initialWorktree])
@@ -17,13 +17,13 @@ const { afterEach, applyAgentStatusHooksEnabledMock, assertWorktreeCleanForRemov
mocks
const { cancelLegacyWorkerTerminalRecoveryRetriesForTests, closeGitLabMRMock } = mocks
const { clearConfiguredWorktreeSharedDirectoriesCacheForTests } = mocks
const { closeLocalWatcherForWorktreePathMock, closeRemoteWatcherForWorktreePathMock } = mocks
const { closeLocalWatcherForWorktreePathMock } = mocks
const { computeWorktreePathMock, countGitHubWorkItemsMock, createGitHubIssueMock } = mocks
const { createGitLabIssueMock, createHostedReviewMock, createSetupRunnerScript } = mocks
const { createStackedHostedReviewMock, detectInstalledAgentsWithShellPathHydrationMock } = mocks
const { electronMocks, ensurePathWithinWorkspaceMock } = mocks
const { findExistingWorktreeSymlinkPathsMock, forceDeleteLocalBranchMock } = mocks
const { forgetLocalWatcherRemovalSnapshotMock, forgetRemoteWatcherRemovalSnapshotMock } = mocks
const { forgetLocalWatcherRemovalSnapshotMock } = mocks
const { describeCreatedWorktree } = mocks
const { getDefaultTabsLaunch, getEffectiveHooks } = mocks
const { getEffectiveHooksFromConfig, getGitHubPRCheckDetailsMock, getGitHubPRChecksMock } = mocks
@@ -53,8 +53,7 @@ const { removeWorktreeLinkedPathsMock, reopenGitLabMRMock, requestGitHubPRReview
const { rerunGitHubPRChecksMock, resetPlatform, resolveGitHubReviewThreadMock } = mocks
const { resolveGitLabMRDiscussionMock, resolveLocalGitUsernameMock, resolveSetupRunnerShell } =
mocks
const { restoreLocalWatcherAfterFailedRemovalMock, restoreRemoteWatcherAfterFailedRemovalMock } =
mocks
const { restoreLocalWatcherAfterFailedRemovalMock } = mocks
const { retryGitLabJobMock, runHook, scanLocalRepoWorktreesForResolutionMock } = mocks
const { setGitHubPRAutoMergeMock, setGitHubPRFileViewedMock, setRuntimeBrowserCommandsFactory } =
mocks
@@ -111,11 +110,8 @@ function resetRuntimeTestMocks(): void {
electronMocks.ipcMain.removeListener.mockClear()
electronMocks.ipcMain.emit.mockClear()
closeLocalWatcherForWorktreePathMock.mockReset().mockResolvedValue(undefined)
closeRemoteWatcherForWorktreePathMock.mockReset().mockResolvedValue(undefined)
restoreLocalWatcherAfterFailedRemovalMock.mockReset().mockResolvedValue(undefined)
restoreRemoteWatcherAfterFailedRemovalMock.mockReset().mockResolvedValue(undefined)
forgetLocalWatcherRemovalSnapshotMock.mockReset()
forgetRemoteWatcherRemovalSnapshotMock.mockReset()
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
vi.mocked(listWorktreesStrict).mockResolvedValue(MOCK_GIT_WORKTREES)
vi.mocked(describeCreatedWorktree).mockResolvedValue(undefined)
@@ -156,11 +156,8 @@ export const acknowledgeAgentPromptSubmit = setup.acknowledgeAgentPromptSubmit
export { electronMocks } from './orca-runtime-test-mocks/setup.spec'
export {
closeLocalWatcherForWorktreePathMock,
closeRemoteWatcherForWorktreePathMock,
restoreLocalWatcherAfterFailedRemovalMock,
restoreRemoteWatcherAfterFailedRemovalMock,
forgetLocalWatcherRemovalSnapshotMock,
forgetRemoteWatcherRemovalSnapshotMock,
scanLocalRepoWorktreesForResolutionMock
} from './orca-runtime-test-mocks/setup.spec'
export const MOCK_GIT_WORKTREES = setup.MOCK_GIT_WORKTREES
@@ -87,11 +87,8 @@ const electronMocks = vi.hoisted(() => {
})
const closeLocalWatcherForWorktreePathMock: TestMock = vi.hoisted(() => vi.fn())
const closeRemoteWatcherForWorktreePathMock: TestMock = vi.hoisted(() => vi.fn())
const restoreLocalWatcherAfterFailedRemovalMock: TestMock = vi.hoisted(() => vi.fn())
const restoreRemoteWatcherAfterFailedRemovalMock: TestMock = vi.hoisted(() => vi.fn())
const forgetLocalWatcherRemovalSnapshotMock: TestMock = vi.hoisted(() => vi.fn())
const forgetRemoteWatcherRemovalSnapshotMock: TestMock = vi.hoisted(() => vi.fn())
const scanLocalRepoWorktreesForResolutionMock: TestMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => electronMocks)
@@ -100,11 +97,8 @@ vi.mock('electron', () => electronMocks)
// would silently pass against the inert default. Same mocks, same expectations.
setWorktreeWatcherRemoval({
closeLocal: closeLocalWatcherForWorktreePathMock,
closeRemote: closeRemoteWatcherForWorktreePathMock,
restoreLocal: restoreLocalWatcherAfterFailedRemovalMock,
restoreRemote: restoreRemoteWatcherAfterFailedRemovalMock,
forgetLocal: forgetLocalWatcherRemovalSnapshotMock,
forgetRemote: forgetRemoteWatcherRemovalSnapshotMock
forgetLocal: forgetLocalWatcherRemovalSnapshotMock
})
const {
@@ -531,11 +525,8 @@ export {
findExistingWorktreeSymlinkPathsMock,
resolveLocalGitUsernameMock,
closeLocalWatcherForWorktreePathMock,
closeRemoteWatcherForWorktreePathMock,
restoreLocalWatcherAfterFailedRemovalMock,
restoreRemoteWatcherAfterFailedRemovalMock,
forgetLocalWatcherRemovalSnapshotMock,
forgetRemoteWatcherRemovalSnapshotMock,
scanLocalRepoWorktreesForResolutionMock,
MOCK_GIT_WORKTREES,
addSparseWorktreeMock,
@@ -8,11 +8,9 @@ import {
assertWorktreeCleanForRemoval,
beginWatcherInstall,
closeLocalWatcherForWorktreePathMock,
closeRemoteWatcherForWorktreePathMock,
deleteWorktreeHistoryDirMock,
forceDeleteLocalBranchMock,
forgetLocalWatcherRemovalSnapshotMock,
forgetRemoteWatcherRemovalSnapshotMock,
getEffectiveHooks,
gitRunner,
join,
@@ -24,7 +22,6 @@ import {
removeWorktree,
removeWorktreeLinkedPathsMock,
restoreLocalWatcherAfterFailedRemovalMock,
restoreRemoteWatcherAfterFailedRemovalMock,
rm,
runHook,
setPlatform,
@@ -89,13 +86,10 @@ describe('OrcaRuntimeService', () => {
})
expect(localProvider.listProcesses).not.toHaveBeenCalled()
expect(closeRemoteWatcherForWorktreePathMock).toHaveBeenCalledWith('ssh-1', TEST_WORKTREE_PATH)
// The remote directory survives, so its watchers are restored, not forgotten.
expect(restoreRemoteWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(
'ssh-1',
TEST_WORKTREE_PATH
)
expect(forgetRemoteWatcherRemovalSnapshotMock).not.toHaveBeenCalled()
// The remote path never had a desktop watcher, so none is closed, restored or forgotten.
expect(closeLocalWatcherForWorktreePathMock).not.toHaveBeenCalled()
expect(restoreLocalWatcherAfterFailedRemovalMock).not.toHaveBeenCalled()
expect(forgetLocalWatcherRemovalSnapshotMock).not.toHaveBeenCalled()
})
it('does not remove a runtime worktree when watcher teardown cannot release it', async () => {
@@ -21,14 +21,9 @@ export function createRuntimeFileWatcherRemoval(fileCommands: FileExplorerWatche
connectionId?: string,
deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline()
): Promise<void> => {
// Why: the desktop watcher only holds local roots; an SSH path never had one to close.
const results = await Promise.allSettled([
connectionId
? drainBeforeWatcherRemoval(
getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath),
deadline,
`remote watcher close for ${worktreePath}`
)
: getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline),
connectionId ? undefined : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline),
drainBeforeWatcherRemoval(
fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId),
deadline,
@@ -46,17 +41,13 @@ export function createRuntimeFileWatcherRemoval(fileCommands: FileExplorerWatche
const restore = async (worktreePath: string, connectionId?: string): Promise<void> => {
await Promise.all([
connectionId
? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath)
: getWorktreeWatcherRemoval().restoreLocal(worktreePath),
connectionId ? undefined : getWorktreeWatcherRemoval().restoreLocal(worktreePath),
fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId)
])
}
const forget = (worktreePath: string, connectionId?: string): void => {
if (connectionId) {
getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath)
} else {
if (!connectionId) {
getWorktreeWatcherRemoval().forgetLocal(worktreePath)
}
fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId)