mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
* 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>
155 lines
4.5 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|