mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
* fix watcher lifecycle cancellation bounds * fix(review): guard remote watcher installs against post-shutdown resurrection closeAllWatchers aborted the in-flight install *tokens* it could see, but a same-key joiner awaiting a 'cancelled' resolution (and a fired retry tick) calls installRemoteWatcher directly and, on the fresh-generation recursion, builds a brand-new non-aborted AbortController and calls provider.watch() after teardown — leaking an SSH watcher into the just-cleared remoteWatchers map. Latch the subsystem shut in closeAllWatchers and refuse installs while latched; a genuine new fs:watchWorktree clears it. Adds a regression test (fails without the latch) plus a test for the same-tick handoff-revival guard that had no coverage. Also extract the duplicated isolated-quarantine-vs-fuse branch shared by retireSlot and releaseFailedRoot into quarantineOrFuse (behavior-preserving). * Add lifecycle generation guard to refuse stale remote-watcher joiners - A boolean latch alone can't distinguish a pre-shutdown joiner from a fresh call once a genuine new watch reopens the subsystem, letting a stale joiner recurse and register a post-shutdown provider.watch() - Each installRemoteWatcher call now captures a generation counter that closeAllWatchers bumps, so a waiter that resumes after a later shutdown+reopen is refused instead of resurrecting - Adds a regression test covering the shutdown-then-reopen race
1060 lines
38 KiB
TypeScript
1060 lines
38 KiB
TypeScript
/* eslint-disable max-lines -- Why: filesystem-watcher centralizes native
|
|
(@parcel/watcher), WSL-native snapshot, and SSH remote watcher lifecycles in
|
|
one module so subscription/cleanup invariants stay auditable from a single
|
|
file. Splitting by transport would scatter the shared debounce/coalesce
|
|
helpers and the common batch-flush path across three files. */
|
|
import { ipcMain, type WebContents } from 'electron'
|
|
import * as path from 'node:path'
|
|
import { stat } from 'node:fs/promises'
|
|
import type { Event as WatcherEvent } from '@parcel/watcher'
|
|
import type { FsChangeEvent, FsChangedPayload } from '../../shared/types'
|
|
import { isWslPath } from '../wsl'
|
|
import { createWslWatcher } from './filesystem-watcher-wsl'
|
|
import type { WatchedRoot } from './filesystem-watcher-wsl'
|
|
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
|
import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch'
|
|
import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process'
|
|
// Why: high-churn directories are suppressed at the native watcher level so
|
|
// events never leave the OS/daemon. This list is separate from the File
|
|
// Explorer display filter (which only hides rows). Directories like `dist`
|
|
// and `build` remain visible in the tree but will not auto-refresh.
|
|
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
|
|
|
|
// ── Debounce helpers ─────────────────────────────────────────────────
|
|
|
|
const DEBOUNCE_TRAILING_MS = 150
|
|
const DEBOUNCE_MAX_WAIT_MS = 500
|
|
|
|
// ── Per-root watcher state ───────────────────────────────────────────
|
|
// WatchedRoot and WatcherSubscription are defined in filesystem-watcher-wsl.ts
|
|
// and re-used here so both native and WSL watchers share the same shape.
|
|
|
|
// ── Module state ─────────────────────────────────────────────────────
|
|
|
|
const watchedRoots = new Map<string, WatchedRoot>()
|
|
|
|
// Why: roots that failed watcher creation (e.g. WSL UNC paths where
|
|
// @parcel/watcher's ReadDirectoryChangesW doesn't work) are cached so
|
|
// we don't retry on every worktree switch and spam the console with
|
|
// repeated "Failed to read changes" / "watchman not found" errors.
|
|
const UNWATCHABLE_ROOT_CACHE_MAX = 256
|
|
const unwatchableRoots = new Set<string>()
|
|
|
|
function rememberUnwatchableRoot(rootKey: string): void {
|
|
// Why: missing/deleted worktrees can churn through unique paths during a long
|
|
// session; keep retry suppression useful without retaining every failed path.
|
|
unwatchableRoots.delete(rootKey)
|
|
unwatchableRoots.add(rootKey)
|
|
while (unwatchableRoots.size > UNWATCHABLE_ROOT_CACHE_MAX) {
|
|
const oldest = unwatchableRoots.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
unwatchableRoots.delete(oldest)
|
|
}
|
|
}
|
|
|
|
// Why: watcher cleanup is keyed to the renderer WebContents, not to a specific
|
|
// watched root. One listener per sender avoids MaxListeners warnings when a
|
|
// workspace has many local and SSH-backed worktrees open.
|
|
const senderCleanupRegistered = new Set<number>()
|
|
|
|
// Why: on Windows, tearing down and recreating @parcel/watcher subscriptions
|
|
// is expensive (ReadDirectoryChangesW setup + antivirus scanning can take
|
|
// 500 ms+). A 30 s grace period lets rapid worktree switches reuse the
|
|
// existing watcher instead of paying the creation cost on every switch.
|
|
// Key: rootKey, Value: pending teardown timer.
|
|
const WATCHER_TEARDOWN_GRACE_MS = 30_000
|
|
const pendingTeardowns = new Map<string, ReturnType<typeof setTimeout>>()
|
|
// Why: @parcel/watcher unsubscribe completes native async work. Sender-destroy
|
|
// cleanup can start it before app shutdown, so will-quit must still await it.
|
|
const pendingLocalUnsubscribes = new Set<Promise<void>>()
|
|
type LocalWatcherInstallToken = {
|
|
cancelled: boolean
|
|
listeners: Map<number, WebContents>
|
|
abortController: AbortController
|
|
}
|
|
type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
|
|
// Why: native watcher creation is async. Concurrent local watch requests for
|
|
// the same root must share one install or later resolves can orphan listeners.
|
|
const inFlightLocalInstalls = new Map<string, LocalWatcherInstallToken>()
|
|
const pendingLocalInstallPromises = new Map<string, Promise<LocalWatcherInstallResult>>()
|
|
|
|
function addInFlightLocalInstallListener(
|
|
token: LocalWatcherInstallToken,
|
|
sender: WebContents
|
|
): void {
|
|
if (sender.isDestroyed()) {
|
|
return
|
|
}
|
|
token.listeners.set(sender.id, sender)
|
|
token.cancelled = false
|
|
registerSenderCleanup(sender)
|
|
}
|
|
|
|
function cleanupInFlightLocalInstallsForSender(senderId: number): void {
|
|
for (const token of inFlightLocalInstalls.values()) {
|
|
token.listeners.delete(senderId)
|
|
if (token.listeners.size === 0) {
|
|
token.cancelled = true
|
|
// Why: match closeLocalWatcherForWorktreePath / closeAllWatchers — abort
|
|
// so a pending native/forked subscription stops early, not at completion.
|
|
token.abortController.abort()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Path normalization ───────────────────────────────────────────────
|
|
|
|
function normalizeRootPath(rootPath: string): string {
|
|
let resolved = path.resolve(rootPath)
|
|
// Why: on Windows, watcher events may report lowercase drive letters while
|
|
// stored worktree paths use uppercase. Normalizing here ensures the renderer's
|
|
// POSIX normalization produces casing-consistent results (see design §4.4).
|
|
if (/^[a-zA-Z]:/.test(resolved)) {
|
|
resolved = resolved.charAt(0).toUpperCase() + resolved.slice(1)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
function normalizeEventPath(eventPath: string): string {
|
|
let resolved = path.resolve(eventPath)
|
|
if (/^[a-zA-Z]:/.test(resolved)) {
|
|
resolved = resolved.charAt(0).toUpperCase() + resolved.slice(1)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// ── Event coalescing ─────────────────────────────────────────────────
|
|
// Why: within a single flush window the same path can appear multiple times.
|
|
// Keep the last event per path, except: delete→create emits both (the delete
|
|
// triggers subtree cleanup, the create triggers parent refresh); create→delete
|
|
// is dropped entirely (net no-op). See design §4.4.
|
|
|
|
function coalesceEvents(
|
|
raw: WatcherEvent[]
|
|
): { type: 'create' | 'update' | 'delete'; path: string }[] {
|
|
const lastByPath = new Map<string, { type: 'create' | 'update' | 'delete'; index: number }>()
|
|
const deleteBeforeCreate = new Set<string>()
|
|
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const evt = raw[i]
|
|
const p = normalizeEventPath(evt.path)
|
|
const prev = lastByPath.get(p)
|
|
|
|
if (prev) {
|
|
// delete followed by create → emit both
|
|
if (prev.type === 'delete' && evt.type === 'create') {
|
|
deleteBeforeCreate.add(p)
|
|
}
|
|
// create followed by delete → net no-op, remove both
|
|
if (prev.type === 'create' && evt.type === 'delete') {
|
|
lastByPath.delete(p)
|
|
deleteBeforeCreate.delete(p)
|
|
continue
|
|
}
|
|
}
|
|
|
|
lastByPath.set(p, { type: evt.type, index: i })
|
|
|
|
// Why: if a later event (e.g. update) supersedes a delete→create sequence,
|
|
// the stale delete must be dropped. Otherwise the final output would include
|
|
// a spurious delete + the new event type (e.g. delete→create→update would
|
|
// emit delete+update, but the file exists so the delete is wrong). See §4.4.
|
|
if (evt.type !== 'create' && deleteBeforeCreate.has(p)) {
|
|
deleteBeforeCreate.delete(p)
|
|
}
|
|
}
|
|
|
|
const result: { type: 'create' | 'update' | 'delete'; path: string }[] = []
|
|
|
|
// Emit delete events first for paths that have delete→create
|
|
for (const p of deleteBeforeCreate) {
|
|
result.push({ type: 'delete', path: p })
|
|
}
|
|
|
|
// Emit the last event for each path
|
|
for (const [p, entry] of lastByPath) {
|
|
result.push({ type: entry.type, path: p })
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ── Stat helper for isDirectory ──────────────────────────────────────
|
|
|
|
async function tryStatIsDirectory(filePath: string): Promise<boolean | undefined> {
|
|
try {
|
|
const s = await stat(filePath)
|
|
return s.isDirectory()
|
|
} catch {
|
|
// Why: if stat fails (EPERM, vanished temp file), return undefined.
|
|
// The renderer treats undefined the same as a file event (parent-only
|
|
// invalidation), which is the safe default. See design §4.4.
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// ── Flush and emit ───────────────────────────────────────────────────
|
|
|
|
function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
|
|
const payload: FsChangedPayload = {
|
|
worktreePath: rootKey,
|
|
events: [{ kind: 'overflow', absolutePath: rootKey }]
|
|
}
|
|
for (const [, wc] of root.listeners) {
|
|
if (!wc.isDestroyed()) {
|
|
wc.send('fs:changed', payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
|
|
const overflowed = root.batch.overflowed
|
|
const rawEvents = root.batch.events.splice(0)
|
|
root.batch.overflowed = false
|
|
root.batch.timer = null
|
|
root.batch.firstEventAt = 0
|
|
|
|
if ((rawEvents.length === 0 && !overflowed) || root.listeners.size === 0) {
|
|
return
|
|
}
|
|
|
|
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
|
|
// Why: deletion storms can be valid but too large to coalesce/stat/send
|
|
// per path. One overflow asks the renderer for the same conservative refresh.
|
|
emitOverflowPayload(rootKey, root)
|
|
return
|
|
}
|
|
|
|
const coalesced = coalesceEvents(rawEvents)
|
|
|
|
// Build the payload with isDirectory info
|
|
const events: FsChangeEvent[] = await Promise.all(
|
|
coalesced.map(async (evt) => {
|
|
// Why: for delete events the path no longer exists on disk, so stat is
|
|
// not possible. Set isDirectory to undefined and let the renderer infer
|
|
// from dirCache (if the deleted path is a dirCache key, it's a directory).
|
|
const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)
|
|
|
|
return {
|
|
kind: evt.type,
|
|
absolutePath: evt.path,
|
|
isDirectory
|
|
}
|
|
})
|
|
)
|
|
|
|
const payload: FsChangedPayload = {
|
|
worktreePath: rootKey,
|
|
events
|
|
}
|
|
|
|
for (const [, wc] of root.listeners) {
|
|
if (!wc.isDestroyed()) {
|
|
wc.send('fs:changed', payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
|
|
const now = Date.now()
|
|
|
|
if (root.batch.firstEventAt === 0) {
|
|
root.batch.firstEventAt = now
|
|
}
|
|
|
|
// If we've exceeded the max wait, flush immediately
|
|
if (now - root.batch.firstEventAt >= DEBOUNCE_MAX_WAIT_MS) {
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
void flushBatch(rootKey, root)
|
|
return
|
|
}
|
|
|
|
// Trailing-edge debounce: reset timer on each new event
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS)
|
|
}
|
|
|
|
// ── Watcher creation ─────────────────────────────────────────────────
|
|
|
|
async function createWatcher(
|
|
rootKey: string,
|
|
rootPath: string,
|
|
signal?: AbortSignal
|
|
): Promise<WatchedRoot> {
|
|
const root: WatchedRoot = {
|
|
subscription: null!,
|
|
listeners: new Map(),
|
|
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }
|
|
}
|
|
|
|
try {
|
|
// Why: track whether the error callback already ran cleanup before
|
|
// subscribe() resolved. If it did, the subscription object returned
|
|
// by subscribe() would be orphaned (never stored in watchedRoots and
|
|
// therefore never unsubscribed), leaking a native file-watcher handle.
|
|
let errorCleanedUp = false
|
|
|
|
const watcherOptions = {
|
|
...buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS),
|
|
// Why: Parcel checks Watchman before the native Windows backend by
|
|
// default, and Windows prints a shell-level "watchman not recognized"
|
|
// error for that probe. Pinning the backend keeps local watches quiet.
|
|
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
|
|
}
|
|
|
|
const markWatcherInterrupted = (): void => {
|
|
root.batch.overflowed = true
|
|
scheduleBatchFlush(rootKey, root)
|
|
}
|
|
|
|
// Why: subscriptions run in a forked watcher process (issue #7547 —
|
|
// watcher.node teardown races fail-fast the hosting process). A watcher
|
|
// crash there is recovered by resubscribing; onInterruption marks the
|
|
// batch overflowed so the renderer refreshes past the event gap.
|
|
root.subscription = await subscribeViaWatcherProcess(
|
|
rootPath,
|
|
(err, events) => {
|
|
if (err) {
|
|
// Why: watcher errors (including watched-root deletion) are treated
|
|
// as overflow so the renderer conservatively refreshes all visible
|
|
// tree state rather than trusting possibly-invalid caches (§7.2, §7.3).
|
|
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
|
|
emitOverflowPayload(rootKey, root)
|
|
// Why: after a watcher error the native subscription may be invalid
|
|
// (e.g. watched root was deleted). Tear down the dead watcher so we
|
|
// don't leave a dangling subscription for a root that no longer
|
|
// exists on disk (§7.3).
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
// Why: the error callback can fire before `watcher.subscribe()`
|
|
// resolves and assigns root.subscription (e.g. the watched root
|
|
// is deleted or inaccessible at startup). Guard against null so
|
|
// the cleanup path doesn't crash the main process.
|
|
if (root.subscription) {
|
|
void trackLocalUnsubscribe(rootKey, root)
|
|
}
|
|
errorCleanedUp = true
|
|
watchedRoots.delete(rootKey)
|
|
return
|
|
}
|
|
|
|
queueWatcherEvents(root.batch, events)
|
|
scheduleBatchFlush(rootKey, root)
|
|
},
|
|
watcherOptions,
|
|
{
|
|
delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
|
|
// A child restart or bounded-queue overflow loses path precision; both
|
|
// require the same conservative renderer refresh.
|
|
onInterruption: markWatcherInterrupted,
|
|
onOverflow: markWatcherInterrupted,
|
|
signal
|
|
}
|
|
)
|
|
|
|
// Why: if the error callback already fired and cleaned up watchedRoots
|
|
// before subscribe() resolved, the subscription we just received is
|
|
// orphaned. Unsubscribe it immediately to avoid leaking a native
|
|
// file-watcher handle that no code path would ever clean up.
|
|
if (errorCleanedUp) {
|
|
void trackLocalUnsubscribe(rootKey, root)
|
|
throw new Error(`Watcher for ${rootKey} errored during subscribe`)
|
|
}
|
|
} catch (err) {
|
|
// Why: if the watcher backend throws synchronously on a deleted root
|
|
// or permission error, log rather than crashing the main process (§7.3).
|
|
console.error(`[filesystem-watcher] failed to subscribe ${rootKey}:`, err)
|
|
throw err
|
|
}
|
|
|
|
return root
|
|
}
|
|
|
|
// ── Subscribe / Unsubscribe ──────────────────────────────────────────
|
|
|
|
function cleanupLocalWatchersForSender(senderId: number): void {
|
|
cleanupInFlightLocalInstallsForSender(senderId)
|
|
for (const [key, watchedRoot] of watchedRoots) {
|
|
if (watchedRoot.listeners.has(senderId)) {
|
|
watchedRoot.listeners.delete(senderId)
|
|
if (watchedRoot.listeners.size === 0) {
|
|
// Cancel any pending grace-period teardown for this root.
|
|
const pending = pendingTeardowns.get(key)
|
|
if (pending) {
|
|
clearTimeout(pending)
|
|
pendingTeardowns.delete(key)
|
|
}
|
|
if (watchedRoot.batch.timer) {
|
|
clearTimeout(watchedRoot.batch.timer)
|
|
}
|
|
trackLocalUnsubscribe(key, watchedRoot)
|
|
watchedRoots.delete(key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise<void> {
|
|
const unsubscribePromise = Promise.resolve()
|
|
.then(() => root.subscription.unsubscribe())
|
|
.catch((err: unknown) => {
|
|
console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err)
|
|
})
|
|
.finally(() => {
|
|
pendingLocalUnsubscribes.delete(unsubscribePromise)
|
|
})
|
|
pendingLocalUnsubscribes.add(unsubscribePromise)
|
|
return unsubscribePromise
|
|
}
|
|
|
|
function registerSenderCleanup(sender: WebContents): void {
|
|
if (senderCleanupRegistered.has(sender.id)) {
|
|
return
|
|
}
|
|
senderCleanupRegistered.add(sender.id)
|
|
sender.once('destroyed', () => {
|
|
senderCleanupRegistered.delete(sender.id)
|
|
cleanupLocalWatchersForSender(sender.id)
|
|
cleanupRemoteWatchersForSender(sender.id)
|
|
})
|
|
}
|
|
|
|
function addLocalWatchListener(rootKey: string, sender: WebContents): void {
|
|
const root = watchedRoots.get(rootKey)
|
|
if (!root || sender.isDestroyed()) {
|
|
return
|
|
}
|
|
root.listeners.set(sender.id, sender)
|
|
registerSenderCleanup(sender)
|
|
}
|
|
|
|
async function subscribe(worktreePath: string, sender: WebContents): Promise<void> {
|
|
const rootKey = normalizeRootPath(worktreePath)
|
|
if (sender.isDestroyed()) {
|
|
return
|
|
}
|
|
|
|
// Don't retry roots that already failed — avoids repeated error spam.
|
|
if (unwatchableRoots.has(rootKey)) {
|
|
rememberUnwatchableRoot(rootKey)
|
|
return
|
|
}
|
|
|
|
let root = watchedRoots.get(rootKey)
|
|
|
|
// Cancel any pending grace-period teardown — a new listener arrived.
|
|
const pendingTeardown = pendingTeardowns.get(rootKey)
|
|
if (pendingTeardown) {
|
|
clearTimeout(pendingTeardown)
|
|
pendingTeardowns.delete(rootKey)
|
|
}
|
|
|
|
if (root) {
|
|
addLocalWatchListener(rootKey, sender)
|
|
return
|
|
}
|
|
|
|
const pendingInstall = pendingLocalInstallPromises.get(rootKey)
|
|
if (pendingInstall) {
|
|
const inFlight = inFlightLocalInstalls.get(rootKey)
|
|
if (inFlight) {
|
|
// Why: an unwatch may cancel an install while another renderer is still
|
|
// awaiting the same root; a new live listener should keep it alive.
|
|
addInFlightLocalInstallListener(inFlight, sender)
|
|
}
|
|
const result = await pendingInstall
|
|
if (
|
|
result === 'installed' &&
|
|
watchedRoots.has(rootKey) &&
|
|
!sender.isDestroyed() &&
|
|
(!inFlight || inFlight.listeners.has(sender.id))
|
|
) {
|
|
addLocalWatchListener(rootKey, sender)
|
|
}
|
|
return
|
|
}
|
|
|
|
const cancelToken: LocalWatcherInstallToken = {
|
|
cancelled: false,
|
|
listeners: new Map(),
|
|
abortController: new AbortController()
|
|
}
|
|
inFlightLocalInstalls.set(rootKey, cancelToken)
|
|
addInFlightLocalInstallListener(cancelToken, sender)
|
|
const installPromise = doInstallLocalWatcher(rootKey, worktreePath, cancelToken)
|
|
pendingLocalInstallPromises.set(rootKey, installPromise)
|
|
try {
|
|
await installPromise
|
|
} finally {
|
|
if (pendingLocalInstallPromises.get(rootKey) === installPromise) {
|
|
pendingLocalInstallPromises.delete(rootKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function doInstallLocalWatcher(
|
|
rootKey: string,
|
|
worktreePath: string,
|
|
cancelToken: LocalWatcherInstallToken
|
|
): Promise<LocalWatcherInstallResult> {
|
|
let root: WatchedRoot
|
|
try {
|
|
const s = await stat(rootKey)
|
|
if (!s.isDirectory()) {
|
|
console.warn(`[filesystem-watcher] not a directory: ${rootKey}`)
|
|
rememberUnwatchableRoot(rootKey)
|
|
return 'unavailable'
|
|
}
|
|
} catch {
|
|
console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`)
|
|
rememberUnwatchableRoot(rootKey)
|
|
return 'unavailable'
|
|
}
|
|
|
|
try {
|
|
// Why: WSL paths use one snapshot subprocess inside the Linux distro so
|
|
// `wsl --shutdown` can kill it; native Windows paths use @parcel/watcher.
|
|
root = isWslPath(worktreePath)
|
|
? await createWslWatcher(
|
|
rootKey,
|
|
worktreePath,
|
|
{
|
|
ignoreDirs: WATCHER_IGNORE_DIRS,
|
|
scheduleBatchFlush,
|
|
watchedRoots
|
|
},
|
|
cancelToken.abortController.signal
|
|
)
|
|
: await createWatcher(rootKey, rootKey, cancelToken.abortController.signal)
|
|
} catch {
|
|
// Why: createWatcher / createWslWatcher already logged the error. Swallow
|
|
// it here so the renderer's watchWorktree call resolves without crashing.
|
|
if (cancelToken.cancelled) {
|
|
return 'cancelled'
|
|
}
|
|
rememberUnwatchableRoot(rootKey)
|
|
return 'unavailable'
|
|
} finally {
|
|
if (inFlightLocalInstalls.get(rootKey) === cancelToken) {
|
|
inFlightLocalInstalls.delete(rootKey)
|
|
}
|
|
}
|
|
|
|
const liveListeners = new Map(
|
|
Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed())
|
|
)
|
|
if (cancelToken.cancelled || liveListeners.size === 0) {
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
void trackLocalUnsubscribe(rootKey, root)
|
|
return 'cancelled'
|
|
}
|
|
|
|
root.listeners = liveListeners
|
|
watchedRoots.set(rootKey, root)
|
|
for (const listener of liveListeners.values()) {
|
|
registerSenderCleanup(listener)
|
|
}
|
|
return 'installed'
|
|
}
|
|
|
|
function unsubscribe(worktreePath: string, senderId: number): void {
|
|
const rootKey = normalizeRootPath(worktreePath)
|
|
const inFlight = inFlightLocalInstalls.get(rootKey)
|
|
if (inFlight) {
|
|
inFlight.listeners.delete(senderId)
|
|
inFlight.cancelled = inFlight.listeners.size === 0
|
|
// Why: same early-cancel as closeLocalWatcherForWorktreePath — last normal
|
|
// disconnect must abort the pending native/forked install, not let it finish.
|
|
if (inFlight.cancelled) {
|
|
inFlight.abortController.abort()
|
|
}
|
|
}
|
|
|
|
const root = watchedRoots.get(rootKey)
|
|
if (!root) {
|
|
return
|
|
}
|
|
|
|
root.listeners.delete(senderId)
|
|
|
|
// Defer watcher teardown when the last subscriber leaves so rapid
|
|
// worktree switches can reuse the existing native watcher.
|
|
if (root.listeners.size === 0) {
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
|
|
// Why: duplicate renderer cleanup can call unwatch more than once for a
|
|
// root; keep one tracked grace timer instead of leaking overwritten timers.
|
|
if (pendingTeardowns.has(rootKey)) {
|
|
return
|
|
}
|
|
|
|
const teardownTimer = setTimeout(() => {
|
|
pendingTeardowns.delete(rootKey)
|
|
// Re-check: a new listener may have arrived during the grace period.
|
|
const currentRoot = watchedRoots.get(rootKey)
|
|
if (!currentRoot || currentRoot.listeners.size > 0) {
|
|
return
|
|
}
|
|
void trackLocalUnsubscribe(rootKey, currentRoot)
|
|
watchedRoots.delete(rootKey)
|
|
}, WATCHER_TEARDOWN_GRACE_MS)
|
|
|
|
pendingTeardowns.set(rootKey, teardownTimer)
|
|
}
|
|
}
|
|
|
|
export async function closeLocalWatcherForWorktreePath(worktreePath: string): Promise<void> {
|
|
const rootKey = normalizeRootPath(worktreePath)
|
|
const pendingTeardown = pendingTeardowns.get(rootKey)
|
|
if (pendingTeardown) {
|
|
clearTimeout(pendingTeardown)
|
|
pendingTeardowns.delete(rootKey)
|
|
}
|
|
|
|
const inFlight = inFlightLocalInstalls.get(rootKey)
|
|
if (inFlight) {
|
|
// Why: Windows keeps watched directories locked; deletion must be able to
|
|
// cancel an in-flight subscription before Git tries to remove the tree.
|
|
inFlight.listeners.clear()
|
|
inFlight.cancelled = true
|
|
inFlight.abortController.abort()
|
|
}
|
|
await pendingLocalInstallPromises.get(rootKey)?.catch(() => undefined)
|
|
|
|
const root = watchedRoots.get(rootKey)
|
|
if (!root) {
|
|
return
|
|
}
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
watchedRoots.delete(rootKey)
|
|
await trackLocalUnsubscribe(rootKey, root)
|
|
}
|
|
|
|
// Remote watcher state
|
|
type RemoteWatcherState = {
|
|
unwatch: () => void
|
|
listeners: Map<number, WebContents>
|
|
}
|
|
|
|
type RemoteWatcherInstallToken = {
|
|
cancelled: boolean
|
|
listeners: Map<number, WebContents>
|
|
abortController: AbortController
|
|
abortScheduled: boolean
|
|
}
|
|
|
|
// Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state.
|
|
const remoteWatchers = new Map<string, RemoteWatcherState>()
|
|
const loggedUnavailableRemoteWatchers = new Set<string>()
|
|
const pendingRemoteWatcherRetries = new Map<string, ReturnType<typeof setTimeout>>()
|
|
// Why: track in-flight `provider.watch()` calls so last-listener cleanup can
|
|
// abort relay setup, while late success is still unwatched instead of leaked.
|
|
const inFlightRemoteInstalls = new Map<string, RemoteWatcherInstallToken>()
|
|
// Why: dedupe concurrent installRemoteWatcher calls for the same key so
|
|
// overlapping fs:watchWorktree IPCs share one native watcher and one listener
|
|
// map, instead of each call independently invoking provider.watch() and
|
|
// overwriting the per-key state on resolution.
|
|
const pendingRemoteInstallPromises = new Map<string, Promise<RemoteWatcherInstallResult>>()
|
|
// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner
|
|
// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing
|
|
// the token-abort loop. A genuine new fs:watchWorktree clears the latch.
|
|
let remoteWatchersClosed = false
|
|
// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a
|
|
// fresh call once a genuine new watch reopens the subsystem. Each call captures
|
|
// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited
|
|
// across a shutdown+reopen recurses on a stale generation and is refused.
|
|
let remoteWatcherLifecycleGeneration = 0
|
|
const REMOTE_WATCH_RETRY_MS = 1_000
|
|
const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000
|
|
|
|
function addInFlightRemoteInstallListener(
|
|
token: RemoteWatcherInstallToken,
|
|
sender: WebContents
|
|
): void {
|
|
if (sender.isDestroyed() || token.abortController.signal.aborted) {
|
|
return
|
|
}
|
|
token.listeners.set(sender.id, sender)
|
|
token.cancelled = false
|
|
registerSenderCleanup(sender)
|
|
}
|
|
|
|
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()
|
|
}
|
|
})
|
|
}
|
|
|
|
function cleanupInFlightRemoteInstallsForSender(senderId: number): void {
|
|
for (const token of inFlightRemoteInstalls.values()) {
|
|
token.listeners.delete(senderId)
|
|
cancelInFlightRemoteInstallIfUnowned(token)
|
|
}
|
|
}
|
|
|
|
function addRemoteWatchListener(key: string, sender: WebContents): void {
|
|
const state = remoteWatchers.get(key)
|
|
if (!state) {
|
|
return
|
|
}
|
|
state.listeners.set(sender.id, sender)
|
|
registerSenderCleanup(sender)
|
|
}
|
|
|
|
function releaseRemoteWatchListener(key: string, senderId: number): void {
|
|
const state = remoteWatchers.get(key)
|
|
if (!state) {
|
|
return
|
|
}
|
|
state.listeners.delete(senderId)
|
|
if (state.listeners.size > 0) {
|
|
return
|
|
}
|
|
state.unwatch()
|
|
remoteWatchers.delete(key)
|
|
}
|
|
|
|
function cleanupRemoteWatchersForSender(senderId: number): void {
|
|
cleanupInFlightRemoteInstallsForSender(senderId)
|
|
for (const key of Array.from(remoteWatchers.keys())) {
|
|
releaseRemoteWatchListener(key, senderId)
|
|
}
|
|
}
|
|
|
|
type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
|
|
|
|
async function installRemoteWatcher(
|
|
sender: WebContents,
|
|
connectionId: string,
|
|
worktreePath: string,
|
|
generation = remoteWatcherLifecycleGeneration
|
|
): Promise<RemoteWatcherInstallResult> {
|
|
// Why: refuse installs racing in after teardown (joiner recursion, fired retry
|
|
// tick) so provider.watch() is never called and registered post-shutdown. The
|
|
// generation guard also refuses a waiter that captured an earlier lifecycle,
|
|
// even after a new watch reopened the subsystem.
|
|
if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) {
|
|
return 'cancelled'
|
|
}
|
|
const provider = getSshFilesystemProvider(connectionId)
|
|
if (!provider || sender.isDestroyed()) {
|
|
return 'unavailable'
|
|
}
|
|
|
|
const key = `${connectionId}:${worktreePath}`
|
|
const existing = remoteWatchers.get(key)
|
|
if (existing) {
|
|
addRemoteWatchListener(key, sender)
|
|
return 'installed'
|
|
}
|
|
// Why: a second concurrent fs:watchWorktree for the same key must share the
|
|
// first call's provider.watch() instead of starting its own. Without this,
|
|
// both calls would create distinct native watchers and the second's resolve
|
|
// would overwrite the per-key state, dropping the first's unwatch handle
|
|
// and erasing its sender from the listener map.
|
|
const pendingInstall = pendingRemoteInstallPromises.get(key)
|
|
if (pendingInstall) {
|
|
const inFlight = inFlightRemoteInstalls.get(key)
|
|
const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted
|
|
if (canJoinInstall) {
|
|
// Why: a new watcher can join after all previous pending listeners
|
|
// unwatched but before provider.watch() resolves; revive that install
|
|
// instead of inheriting the stale cancellation.
|
|
addInFlightRemoteInstallListener(inFlight, sender)
|
|
}
|
|
const result = await pendingInstall
|
|
if (
|
|
result === 'installed' &&
|
|
remoteWatchers.has(key) &&
|
|
!sender.isDestroyed() &&
|
|
(!inFlight || inFlight.listeners.has(sender.id))
|
|
) {
|
|
addRemoteWatchListener(key, sender)
|
|
}
|
|
if (
|
|
result === 'cancelled' &&
|
|
!canJoinInstall &&
|
|
!sender.isDestroyed() &&
|
|
generation === remoteWatcherLifecycleGeneration
|
|
) {
|
|
// Why: AbortSignal cannot be revived. A listener arriving after physical
|
|
// cancellation waits out that generation, then owns a fresh install.
|
|
if (pendingRemoteInstallPromises.get(key) === pendingInstall) {
|
|
pendingRemoteInstallPromises.delete(key)
|
|
}
|
|
return installRemoteWatcher(sender, connectionId, worktreePath, generation)
|
|
}
|
|
return result
|
|
}
|
|
const cancelToken: RemoteWatcherInstallToken = {
|
|
cancelled: false,
|
|
listeners: new Map(),
|
|
abortController: new AbortController(),
|
|
abortScheduled: false
|
|
}
|
|
inFlightRemoteInstalls.set(key, cancelToken)
|
|
addInFlightRemoteInstallListener(cancelToken, sender)
|
|
const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken)
|
|
pendingRemoteInstallPromises.set(key, installPromise)
|
|
try {
|
|
return await installPromise
|
|
} finally {
|
|
if (pendingRemoteInstallPromises.get(key) === installPromise) {
|
|
pendingRemoteInstallPromises.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function doInstallRemoteWatcher(
|
|
provider: NonNullable<ReturnType<typeof getSshFilesystemProvider>>,
|
|
key: string,
|
|
worktreePath: string,
|
|
cancelToken: RemoteWatcherInstallToken
|
|
): Promise<RemoteWatcherInstallResult> {
|
|
let unwatch: () => void
|
|
try {
|
|
unwatch = await provider.watch(
|
|
worktreePath,
|
|
(events) => {
|
|
const state = remoteWatchers.get(key)
|
|
if (!state) {
|
|
return
|
|
}
|
|
for (const listener of state.listeners.values()) {
|
|
if (listener.isDestroyed()) {
|
|
continue
|
|
}
|
|
listener.send('fs:changed', {
|
|
worktreePath,
|
|
events
|
|
} satisfies FsChangedPayload)
|
|
}
|
|
},
|
|
{ signal: cancelToken.abortController.signal }
|
|
)
|
|
} catch (err) {
|
|
if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) {
|
|
return 'cancelled'
|
|
}
|
|
console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err)
|
|
return 'unavailable'
|
|
} finally {
|
|
if (inFlightRemoteInstalls.get(key) === cancelToken) {
|
|
inFlightRemoteInstalls.delete(key)
|
|
}
|
|
}
|
|
const liveListeners = new Map(
|
|
Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed())
|
|
)
|
|
if (cancelToken.cancelled || liveListeners.size === 0) {
|
|
try {
|
|
unwatch()
|
|
} catch (err) {
|
|
console.error(`[filesystem-watcher] remote unwatch (post-cancel) error for ${key}:`, err)
|
|
}
|
|
return 'cancelled'
|
|
}
|
|
remoteWatchers.set(key, { unwatch, listeners: liveListeners })
|
|
for (const listener of liveListeners.values()) {
|
|
registerSenderCleanup(listener)
|
|
}
|
|
loggedUnavailableRemoteWatchers.delete(key)
|
|
return 'installed'
|
|
}
|
|
|
|
function scheduleRemoteWatcherRetry(
|
|
sender: WebContents,
|
|
connectionId: string,
|
|
worktreePath: string,
|
|
startedAt = Date.now()
|
|
): void {
|
|
const key = `${connectionId}:${worktreePath}`
|
|
if (pendingRemoteWatcherRetries.has(key)) {
|
|
return
|
|
}
|
|
|
|
if (Date.now() - startedAt >= REMOTE_WATCH_RETRY_TIMEOUT_MS || sender.isDestroyed()) {
|
|
pendingRemoteWatcherRetries.delete(key)
|
|
loggedUnavailableRemoteWatchers.delete(key)
|
|
// Why: the original `fs:watchWorktree` handler resolved successfully
|
|
// when the retry was first scheduled, so the renderer believes the
|
|
// watch is live. After giving up, emit a one-shot overflow so the
|
|
// renderer falls back to a manual refresh instead of waiting forever.
|
|
if (!sender.isDestroyed()) {
|
|
console.warn(
|
|
`[filesystem-watcher] giving up SSH watch retry for ${worktreePath} on connection ${connectionId} after ${REMOTE_WATCH_RETRY_TIMEOUT_MS}ms`
|
|
)
|
|
sender.send('fs:changed', {
|
|
worktreePath,
|
|
events: [{ kind: 'overflow', absolutePath: worktreePath }]
|
|
} satisfies FsChangedPayload)
|
|
}
|
|
return
|
|
}
|
|
|
|
const retryTimer = setTimeout(() => {
|
|
pendingRemoteWatcherRetries.delete(key)
|
|
void installRemoteWatcher(sender, connectionId, worktreePath)
|
|
.then((result) => {
|
|
// Why: 'cancelled' means an unwatch/shutdown raced with this install
|
|
// attempt. Re-arming the retry would reschedule for a worktree the
|
|
// renderer explicitly stopped watching, eventually firing a stale
|
|
// overflow when the 60s window expires.
|
|
if (result === 'unavailable') {
|
|
scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt)
|
|
})
|
|
}, REMOTE_WATCH_RETRY_MS)
|
|
pendingRemoteWatcherRetries.set(key, retryTimer)
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────
|
|
|
|
export function registerFilesystemWatcherHandlers(): void {
|
|
ipcMain.handle(
|
|
'fs:watchWorktree',
|
|
async (event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
|
if (args.connectionId) {
|
|
// Why: a real new watch reopens the subsystem after closeAllWatchers
|
|
// latched it shut (also how tests reset between cases).
|
|
remoteWatchersClosed = false
|
|
const key = `${args.connectionId}:${args.worktreePath}`
|
|
const result = await installRemoteWatcher(
|
|
event.sender,
|
|
args.connectionId,
|
|
args.worktreePath
|
|
)
|
|
if (result === 'unavailable') {
|
|
if (!loggedUnavailableRemoteWatchers.has(key)) {
|
|
loggedUnavailableRemoteWatchers.add(key)
|
|
console.warn(
|
|
`[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}`
|
|
)
|
|
}
|
|
scheduleRemoteWatcherRetry(event.sender, args.connectionId, args.worktreePath)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
await subscribe(args.worktreePath, event.sender)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:unwatchWorktree',
|
|
(_event, args: { worktreePath: string; connectionId?: string }): void => {
|
|
if (args.connectionId) {
|
|
const key = `${args.connectionId}:${args.worktreePath}`
|
|
const retryTimer = pendingRemoteWatcherRetries.get(key)
|
|
if (retryTimer) {
|
|
clearTimeout(retryTimer)
|
|
pendingRemoteWatcherRetries.delete(key)
|
|
}
|
|
// Why: a `provider.watch()` call may still be in flight from a
|
|
// retry tick. Mark it cancelled so installRemoteWatcher discards
|
|
// the unwatch handle when the promise finally resolves, instead
|
|
// of leaving the renderer with a watcher it asked to stop.
|
|
const inFlight = inFlightRemoteInstalls.get(key)
|
|
if (inFlight) {
|
|
inFlight.listeners.delete(_event.sender.id)
|
|
cancelInFlightRemoteInstallIfUnowned(inFlight)
|
|
}
|
|
loggedUnavailableRemoteWatchers.delete(key)
|
|
releaseRemoteWatchListener(key, _event?.sender?.id ?? 0)
|
|
return
|
|
}
|
|
const senderId = _event.sender.id
|
|
unsubscribe(args.worktreePath, senderId)
|
|
}
|
|
)
|
|
}
|
|
|
|
/** Tear down all watchers on app shutdown. */
|
|
export async function closeAllWatchers(): Promise<void> {
|
|
senderCleanupRegistered.clear()
|
|
unwatchableRoots.clear()
|
|
|
|
// Cancel any pending grace-period teardowns — we're tearing down everything.
|
|
for (const timer of pendingTeardowns.values()) {
|
|
clearTimeout(timer)
|
|
}
|
|
pendingTeardowns.clear()
|
|
|
|
for (const timer of pendingRemoteWatcherRetries.values()) {
|
|
clearTimeout(timer)
|
|
}
|
|
pendingRemoteWatcherRetries.clear()
|
|
loggedUnavailableRemoteWatchers.clear()
|
|
// Why: latch the subsystem shut and drop the dedup map so a late install that
|
|
// begins after teardown is refused instead of registering post-shutdown. Bump
|
|
// the generation so a waiter that resumes after a later reopen still recurses
|
|
// on a stale lifecycle and is refused.
|
|
remoteWatchersClosed = true
|
|
remoteWatcherLifecycleGeneration += 1
|
|
pendingRemoteInstallPromises.clear()
|
|
// Why: cancel any in-flight provider.watch() calls so their resolved
|
|
// unwatch handles are discarded instead of being installed after shutdown.
|
|
for (const token of inFlightRemoteInstalls.values()) {
|
|
token.listeners.clear()
|
|
token.cancelled = true
|
|
token.abortController.abort()
|
|
}
|
|
for (const token of inFlightLocalInstalls.values()) {
|
|
token.listeners.clear()
|
|
token.cancelled = true
|
|
token.abortController.abort()
|
|
}
|
|
|
|
for (const [rootKey, root] of watchedRoots) {
|
|
if (root.batch.timer) {
|
|
clearTimeout(root.batch.timer)
|
|
}
|
|
await trackLocalUnsubscribe(rootKey, root)
|
|
}
|
|
watchedRoots.clear()
|
|
await Promise.allSettled(Array.from(pendingLocalUnsubscribes))
|
|
// Why: with every local subscription released, drop the forked watcher
|
|
// process outright — process death frees any remaining native handles
|
|
// without running watcher.node's crash-prone async teardown in this process.
|
|
disposeWatcherProcess()
|
|
|
|
// Why: remote watchers are tracked separately from local @parcel/watcher
|
|
// subscriptions. Without cleaning them up here, their unwatch callbacks
|
|
// would never fire, leaving the relay polling for FS changes after the
|
|
// app has shut down.
|
|
for (const [key, state] of remoteWatchers) {
|
|
try {
|
|
state.unwatch()
|
|
} catch (err) {
|
|
console.error(`[filesystem-watcher] remote unwatch error for ${key}:`, err)
|
|
}
|
|
}
|
|
remoteWatchers.clear()
|
|
}
|