mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
Fix ssh watcher isolation (#8463)
* fix(ssh): isolate relay filesystem watchers
* Fix relay watcher fault-harness pid file and in-process fallback isolati
- Use exclusive ('wx') creation for the fault-harness pid file so a leaked
ORCA_WATCHER_CHILD_PID_FILE env var can't clobber an existing file, and
have the harness remove the file after reading a replacement pid.
- Force useInProcessVitestFallback to false in the relay watcher pool so a
leaked VITEST env var can never load the native watcher addon in-process
on the relay; fail closed instead when the isolated child is missing.
- Thread an injectable RelayWatcherProcessPool into FsHandler/
RelayFilesystemWatchRegistry for tests, and add coverage for both fixes.
This commit is contained in:
+18
-136
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: relay filesystem request handling shares
|
||||
path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */
|
||||
path expansion, file IO, search, streaming reads, and Space scans. */
|
||||
import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -34,17 +34,8 @@ import { RelayStreamRegistry } from './fs-stream-registry'
|
||||
import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
|
||||
import {
|
||||
WATCHER_IGNORE_DIRS,
|
||||
buildParcelWatcherIgnoreOptions
|
||||
} from '../main/ipc/filesystem-watcher-ignore'
|
||||
|
||||
type WatchState = {
|
||||
rootPath: string
|
||||
unwatchFn: (() => void) | null
|
||||
setupPromise: Promise<void> | null
|
||||
clients: Map<number, () => boolean>
|
||||
}
|
||||
import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
|
||||
import type { RelayWatcherProcessPool } from './relay-watcher-process-pool'
|
||||
|
||||
async function isDirectoryEntry(
|
||||
dirPath: string,
|
||||
@@ -85,15 +76,19 @@ function fileStatFromLstat(stats: Awaited<ReturnType<typeof lstat>>) {
|
||||
|
||||
export class FsHandler {
|
||||
private dispatcher: RelayDispatcher
|
||||
private watches = new Map<string, WatchState>()
|
||||
private watchRegistry: RelayFilesystemWatchRegistry
|
||||
private streamRegistry = new RelayStreamRegistry()
|
||||
private listFilesScans = new ListFilesScanCoordinator()
|
||||
|
||||
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
|
||||
constructor(
|
||||
dispatcher: RelayDispatcher,
|
||||
_context: RelayContext,
|
||||
watcherPool?: RelayWatcherProcessPool
|
||||
) {
|
||||
this.dispatcher = dispatcher
|
||||
this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
|
||||
this.registerHandlers()
|
||||
this.dispatcher.onClientDetached?.((clientId) => {
|
||||
this.releaseClientWatches(clientId)
|
||||
this.dispatcher.onClientDetached?.(() => {
|
||||
// Why: a detached client's fs.streamAck frames will never arrive; wake
|
||||
// any pump parked on the ack window so it re-checks staleness and exits
|
||||
// instead of stranding its open file handle.
|
||||
@@ -122,8 +117,12 @@ export class FsHandler {
|
||||
this.dispatcher.onRequest('fs.search', (p) => this.search(p))
|
||||
this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
|
||||
this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
|
||||
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
|
||||
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
|
||||
this.dispatcher.onRequest('fs.watch', (p, context) =>
|
||||
this.watchRegistry.watch(expandTilde(p.rootPath as string), context)
|
||||
)
|
||||
this.dispatcher.onNotification('fs.unwatch', (p, context) =>
|
||||
this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context)
|
||||
)
|
||||
this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
|
||||
this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
|
||||
}
|
||||
@@ -414,125 +413,8 @@ export class FsHandler {
|
||||
return scanWorkspaceSpaceDirectory(rootPath, context)
|
||||
}
|
||||
|
||||
private async watch(params: Record<string, unknown>, context?: RequestContext) {
|
||||
const rootPath = expandTilde(params.rootPath as string)
|
||||
|
||||
this.releaseStaleWatches()
|
||||
|
||||
const existing = this.watches.get(rootPath)
|
||||
if (existing) {
|
||||
if ([...existing.clients.values()].some((isStale) => !isStale())) {
|
||||
existing.clients.set(context?.clientId ?? 0, context?.isStale ?? (() => false))
|
||||
if (existing.setupPromise) {
|
||||
await existing.setupPromise
|
||||
}
|
||||
return
|
||||
}
|
||||
existing.unwatchFn?.()
|
||||
this.watches.delete(rootPath)
|
||||
}
|
||||
|
||||
if (this.watches.size >= 20) {
|
||||
throw new Error('Maximum number of file watchers reached')
|
||||
}
|
||||
|
||||
const watchState: WatchState = {
|
||||
rootPath,
|
||||
unwatchFn: null,
|
||||
setupPromise: null,
|
||||
clients: new Map([[context?.clientId ?? 0, context?.isStale ?? (() => false)]])
|
||||
}
|
||||
this.watches.set(rootPath, watchState)
|
||||
|
||||
const setupPromise = (async () => {
|
||||
const watcher = await import('@parcel/watcher')
|
||||
const subscription = await watcher.subscribe(
|
||||
rootPath,
|
||||
(err, events) => {
|
||||
if (err) {
|
||||
this.dispatcher.notify('fs.changed', {
|
||||
events: [{ kind: 'overflow', absolutePath: rootPath }]
|
||||
})
|
||||
return
|
||||
}
|
||||
const mapped = events.map((evt) => ({
|
||||
kind: evt.type,
|
||||
absolutePath: evt.path
|
||||
}))
|
||||
this.dispatcher.notify('fs.changed', { events: mapped })
|
||||
},
|
||||
// Why: align remote watchers with the shared nested exclusion so
|
||||
// generated trees neither exhaust inotify nor trigger slow glob regexes.
|
||||
buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
)
|
||||
watchState.unwatchFn = () => {
|
||||
void subscription.unsubscribe()
|
||||
}
|
||||
if (
|
||||
[...watchState.clients.values()].every((isStale) => isStale()) ||
|
||||
this.watches.get(rootPath) !== watchState
|
||||
) {
|
||||
// Why: if the only requesting client reconnects while watcher setup is
|
||||
// in flight, no client can later balance it with fs.unwatch. Tear down
|
||||
// only this request's subscription so a newer replacement watch for the
|
||||
// same root is not removed.
|
||||
void subscription.unsubscribe()
|
||||
if (this.watches.get(rootPath) === watchState) {
|
||||
this.watches.delete(rootPath)
|
||||
}
|
||||
}
|
||||
})()
|
||||
watchState.setupPromise = setupPromise
|
||||
|
||||
try {
|
||||
await setupPromise
|
||||
} catch {
|
||||
if (this.watches.get(rootPath) === watchState) {
|
||||
this.watches.delete(rootPath)
|
||||
}
|
||||
// @parcel/watcher not available -- polling fallback would go here
|
||||
process.stderr.write('[relay] File watcher not available, fs.changed events disabled\n')
|
||||
}
|
||||
}
|
||||
|
||||
private unwatch(params: Record<string, unknown>, context?: RequestContext): void {
|
||||
const rootPath = expandTilde(params.rootPath as string)
|
||||
const state = this.watches.get(rootPath)
|
||||
if (state) {
|
||||
this.releaseWatchClient(rootPath, state, context?.clientId ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private releaseClientWatches(clientId: number): void {
|
||||
for (const [rootPath, state] of this.watches) {
|
||||
this.releaseWatchClient(rootPath, state, clientId)
|
||||
}
|
||||
}
|
||||
|
||||
private releaseStaleWatches(): void {
|
||||
for (const [rootPath, state] of this.watches) {
|
||||
if ([...state.clients.values()].some((isStale) => !isStale())) {
|
||||
continue
|
||||
}
|
||||
state.unwatchFn?.()
|
||||
this.watches.delete(rootPath)
|
||||
}
|
||||
}
|
||||
|
||||
private releaseWatchClient(rootPath: string, state: WatchState, clientId: number): void {
|
||||
state.clients.delete(clientId)
|
||||
if (state.clients.size > 0) {
|
||||
return
|
||||
}
|
||||
state.unwatchFn?.()
|
||||
this.watches.delete(rootPath)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const [, state] of this.watches) {
|
||||
state.unwatchFn?.()
|
||||
}
|
||||
this.watches.clear()
|
||||
this.watchRegistry.dispose()
|
||||
void this.streamRegistry.disposeAll()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user