Files
orca/src/main/ipc/filesystem-watcher-local-events.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

291 lines
10 KiB
TypeScript

import { stat } from 'node:fs/promises'
import type { Event as WatcherEvent } from '@parcel/watcher'
import type { FsChangeEvent, FsChangedPayload } from '../../shared/filesystem-entry-types'
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
} from '../../shared/filesystem-watch-batch-window'
import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch'
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
import type { WatchedRoot } from './filesystem-watcher-wsl'
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
import { normalizeWatcherEventPath } from './filesystem-watcher-paths'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import {
retainLocalWatcherPhysicalFailure,
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { createDebouncedBatch } from './filesystem-watcher-batch-control'
// ── Event coalescing ─────────────────────────────────────────────────
// Why: keep the last event per path in a flush window; delete→create emits both (delete cleans the subtree, create refreshes the parent), create→delete is dropped (§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 = normalizeWatcherEventPath(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: drop the stale delete when a later event supersedes delete→create, else output has a spurious delete for a file that still exists (§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: stat failure (EPERM, vanished file) → undefined; renderer treats it as a file event, the safe default (§4.4).
return undefined
}
}
// ── Flush and emit ───────────────────────────────────────────────────
function emitOverflowPayload(root: WatchedRoot): void {
const { rootPath } = root
const payload: FsChangedPayload = {
worktreePath: rootPath,
events: [{ kind: 'overflow', absolutePath: rootPath }]
}
for (const [, wc] of root.listeners) {
if (!wc.isDestroyed()) {
wc.send('fs:changed', payload)
}
}
}
async function flushBatch(root: WatchedRoot): Promise<void> {
if (root.batch.cancelled) {
return
}
if (root.batch.flushInFlight) {
root.batch.flushQueued = true
return
}
root.batch.flushInFlight = true
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
const overflowed = root.batch.overflowed
const rawEvents = root.batch.events.splice(0)
root.batch.overflowed = false
root.batch.firstEventAt = 0
try {
if ((rawEvents.length === 0 && !overflowed) || root.listeners.size === 0) {
return
}
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
// Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh.
if (!root.batch.cancelled) {
emitOverflowPayload(root)
}
return
}
const coalesced = coalesceEvents(rawEvents)
const events: FsChangeEvent[] = await Promise.all(
coalesced.map(async (evt) => {
// Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache.
const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)
return {
kind: evt.type,
absolutePath: evt.path,
isDirectory
}
})
)
if (root.batch.cancelled || root.listeners.size === 0) {
return
}
const payload: FsChangedPayload = {
worktreePath: root.rootPath,
events
}
for (const [, wc] of root.listeners) {
if (!wc.isDestroyed()) {
wc.send('fs:changed', payload)
}
}
} finally {
root.batch.flushInFlight = false
if (root.batch.flushQueued) {
root.batch.flushQueued = false
if (
!root.batch.cancelled &&
// Why: an armed timer still owns its debounce window; draining here would split related events across payloads.
!root.batch.timer &&
(root.batch.events.length > 0 || root.batch.overflowed)
) {
// Drain the queued batch only after the current payload has settled,
// preserving watcher event ordering without dropping a storm tail.
void flushBatch(root)
}
}
}
}
export function scheduleLocalBatchFlush(root: WatchedRoot): void {
if (root.batch.cancelled) {
return
}
if (root.batch.flushInFlight) {
root.batch.flushQueued = true
}
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 >= WATCH_BATCH_MAX_WAIT_MS) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
void flushBatch(root)
return
}
// Trailing-edge debounce: reset timer on each new event
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
// Why: clear the handle as it fires so `batch.timer` means "a debounce window is still open", which gates the queued drain.
root.batch.timer = setTimeout(() => {
root.batch.timer = null
void flushBatch(root)
}, WATCH_BATCH_TRAILING_MS)
}
// ── Watcher creation ─────────────────────────────────────────────────
export async function createLocalWatcher(
rootKey: string,
rootPath: string,
signal?: AbortSignal
): Promise<WatchedRoot> {
const root: WatchedRoot = {
subscription: null!,
listeners: new Map(),
batch: createDebouncedBatch(),
rootPath
}
try {
// Why: if the error callback cleaned up before subscribe() resolved, its returned subscription is orphaned and leaks a native handle.
let errorCleanedUp = false
const watcherOptions = {
...buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS),
// Why: Parcel probes Watchman first, which prints a shell-level "watchman not recognized" error on Windows; pin the backend to suppress it.
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
}
const markWatcherInterrupted = (): void => {
root.batch.overflowed = true
scheduleLocalBatchFlush(root)
}
// Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap.
root.subscription = await subscribeViaWatcherProcess(
rootPath,
(err, events) => {
if (err) {
// Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3).
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
emitOverflowPayload(root)
// Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3).
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
// Why: error callback can fire before subscribe() assigns root.subscription; guard against null so cleanup doesn't crash.
if (root.subscription) {
retainLocalWatcherPhysicalFailure(rootKey, err)
void trackDetachedLocalUnsubscribe(rootKey, root)
}
errorCleanedUp = true
watcherLifecycleState.watchedRoots.delete(rootKey)
return
}
if (root.batch.cancelled) {
return
}
queueWatcherEvents(root.batch, events)
scheduleLocalBatchFlush(root)
},
watcherOptions,
{
delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
// A child restart or bounded-queue overflow loses path precision; both need the same conservative renderer refresh.
onInterruption: markWatcherInterrupted,
onOverflow: markWatcherInterrupted,
signal
}
)
// Why: error callback already cleaned up watchedRoots before subscribe() resolved; unsubscribe this orphaned subscription so it doesn't leak.
if (errorCleanedUp) {
void trackDetachedLocalUnsubscribe(rootKey, root)
throw new Error(`Watcher for ${rootKey} errored during subscribe`)
}
} catch (err) {
// Why: watcher backend can throw synchronously on a deleted root/permission error; log rather than crash the main process (§7.3).
console.error(`[filesystem-watcher] failed to subscribe ${rootKey}:`, err)
throw err
}
return root
}