Files
orca/src/main/ipc/remote-watcher-event-batch.ts
T
JinjingandOrca c5c10e1203 Fix POSIX path coalescing and file explorer watch regressions (#12004)
* fix(watch/relay): bound remote watcher fan-out and read the live relay grace

Three P1 fixes from the SSH/remote freeze audit:

- Remote watchers now debounce on the same 150/500 window as local ones
  (finding D), and every teardown path drops the trailing flush timer
  instead of letting it fire into a dead watch. The deferred send is
  wrapped so a frame disposed mid-window can't escape as a fatal
  main-process exception.
- File Explorer refreshes are scheduled and concurrency-capped rather
  than fanned out unbounded over expanded dirs (finding C). Local
  transports use a zero window, since main already coalesced the burst.
- relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the
  launch-time argv closure, so a grace raised after launch is honored.
  The branch selection moves to relay-grace-branch.ts because relay.ts
  has no exports and calls main() at import, making it untestable.
  Consequence: a host-sleep relay holding zero PTYs now exits after the
  idle cap. Pinned by test and documented in
  docs/reference/relay-grace-time-reconfiguration.md.

Also drops the duplicated 150/500/5000 constants in the runtime-RPC
batcher in favor of the shared window module.

* docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit

Co-authored-by: Orca <help@stably.ai>

* refactor(file-explorer): use useMemo for paths; remove relay reference

Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle.

* rm design doc

* fix(watcher/explorer/relay): coalesce POSIX paths by byte identity; pres

- Remote watcher event coalescing now keeps NFC/NFD-distinct POSIX paths separate while still folding Windows path spellings, fixing cache invalidation when recreating directories with different Unicode compositions.
- Relay grace reconfiguration now preserves shutdown-deferred state when grace is set to zero, preventing premature shutdown when the grace timer is reconfigured mid-flight.
- File explorer refresh refactored from fixed-wave batching to concurrent task execution with `forEachWithConcurrency`, batching results every N settled reads instead of every wave, and reporting whether cancellation discarded pending work.
- Watch handler now resyncs when events arrive after disposal, ensuring refresh requests aren't lost when events race cleanup during worktree switches.

* fix(watcher/explorer): resilient commit batches on callback error

Cache writes precede callbacks, so a throwing callback cannot strand
the batch with stale marks. All callbacks complete despite errors,
with the first error thrown after.

- Fix commitBatchSize calculation for empty dirs
- Add scheduler discard-report test
- Move disposed variable before handleFsChanged
- Expand batching-strategy comments

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-01 14:49:33 -07:00

155 lines
4.5 KiB
TypeScript

import type { FsChangeEvent } from '../../shared/types'
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
export type RemoteWatcherEventBatchOptions = {
rootPath: string
deliver: (events: FsChangeEvent[]) => void
trailingMs: number
maxWaitMs: number
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,
trailingMs,
maxWaitMs,
maxEvents
}: RemoteWatcherEventBatchOptions): RemoteWatcherEventBatch {
let buffered: FsChangeEvent[] = []
let overflowed = false
let firstEventAt = 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
firstEventAt = 0
if (latched) {
deliver([{ kind: 'overflow', absolutePath: rootPath }])
return
}
const coalesced = coalesceRemoteEvents(pending, rootPath)
if (coalesced.length > 0) {
deliver(coalesced)
}
}
function schedule(): void {
const now = Date.now()
if (firstEventAt === 0) {
firstEventAt = now
}
if (now - firstEventAt >= maxWaitMs) {
flush()
return
}
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(flush, trailingMs)
}
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
firstEventAt = 0
}
}
}