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:
Jinjing
2026-07-12 21:34:59 -07:00
committed by GitHub
parent a5faf19631
commit e3c47eff17
22 changed files with 1092 additions and 194 deletions
@@ -1,4 +1,5 @@
import { fork, type ChildProcess } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import {
createWatcherCanaryDirectory,
removeWatcherCanaryDirectory
@@ -33,6 +34,16 @@ export function launchWatcherChild(
console.error('[parcel-watcher-process] failed to fork watcher process:', error)
return null
}
const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE
if (faultHarnessPidFile && child.pid) {
try {
// Why: exclusive creation lets the harness identify the child without a
// leaked test-only environment variable clobbering an existing file.
writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' })
} catch {
// Fault-injection observability must never affect watcher availability.
}
}
child.stderr?.on('data', (chunk: Buffer) => {
console.error('[parcel-watcher-process]', String(chunk).trimEnd())
})
@@ -100,6 +100,21 @@ export function resolvePendingWatcherUnsubscribes(
pendingUnsubscribes.clear()
}
export function disposeWatcherSupervisorSubscriptions(
records: Map<number, WatcherProcessSubscriptionRecord>,
pendingUnsubscribes: Map<number, () => void>,
cancelledSubscribesAwaitingChild: Set<number>,
error: Error
): void {
for (const record of records.values()) {
resetPendingSubscribeAttempt(record)
takePendingSubscribe(record)?.reject(error)
}
resolvePendingWatcherUnsubscribes(pendingUnsubscribes)
cancelledSubscribesAwaitingChild.clear()
records.clear()
}
export type CreateHostWatcherSubscriptionOptions = {
record: WatcherProcessSubscriptionRecord
records: Map<number, WatcherProcessSubscriptionRecord>
@@ -20,6 +20,13 @@ export class WatcherProcessFailure extends Error {
}
}
export function watcherHostFailure(
message: string,
code: WatcherProcessFailureCode
): WatcherProcessFailure {
return new WatcherProcessFailure(message, 'supervisor', code)
}
export function isWatcherProcessFailure(error: unknown): error is WatcherProcessFailure {
return error instanceof WatcherProcessFailure
}
@@ -0,0 +1,4 @@
export type WatcherProcessSupervisorOptions = {
entryPath?: string
useInProcessVitestFallback?: boolean
}
@@ -5,6 +5,7 @@ import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory'
import { launchWatcherChild } from './parcel-watcher-child-launch'
import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse'
import {
disposeWatcherSupervisorSubscriptions,
failAllWatcherSubscriptions,
handleWatcherHostMessage,
reportWatcherTerminalError,
@@ -16,7 +17,8 @@ import {
startPendingSubscribeTimeout,
takePendingSubscribe
} from './parcel-watcher-pending-subscribe'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import { watcherHostFailure } from './parcel-watcher-process-failure'
import type { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
HostToWatcherMessage,
WatcherProcessSubscribeOptions,
@@ -28,6 +30,7 @@ import type {
WatcherProcessSubscription,
WatcherProcessSubscriptionRecord
} from './parcel-watcher-process-subscription'
import type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
import { subscribeThroughWatcherSupervisor } from './parcel-watcher-supervisor-subscribe'
export type {
@@ -35,6 +38,7 @@ export type {
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
export type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
export class WatcherProcessSupervisor {
private child: ChildProcess | null = null
@@ -45,6 +49,9 @@ export class WatcherProcessSupervisor {
private readonly records = new Map<number, WatcherProcessSubscriptionRecord>()
private readonly pendingUnsubscribes = new Map<number, () => void>()
private readonly cancelledSubscribesAwaitingChild = new Set<number>()
constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {}
subscribe(
dir: string,
callback: WatcherProcessCallback,
@@ -57,6 +64,8 @@ export class WatcherProcessSupervisor {
opts,
hooks,
shutdownRequested: this.shutdownRequested,
entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(),
useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true,
allocateId: () => this.nextSubscriptionId++,
records: this.records,
pendingUnsubscribes: this.pendingUnsubscribes,
@@ -73,19 +82,13 @@ export class WatcherProcessSupervisor {
this.shutdownRequested = true
const proc = this.child
this.child = null
const error = new WatcherProcessFailure(
'file watcher supervisor disposed',
'supervisor',
'supervisor_disposed'
const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed')
disposeWatcherSupervisorSubscriptions(
this.records,
this.pendingUnsubscribes,
this.cancelledSubscribesAwaitingChild,
error
)
for (const record of this.records.values()) {
resetPendingSubscribeAttempt(record)
const pending = takePendingSubscribe(record)
pending?.reject(error)
}
resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
this.cancelledSubscribesAwaitingChild.clear()
this.records.clear()
proc?.kill()
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
}
@@ -96,7 +99,9 @@ export class WatcherProcessSupervisor {
this.crashFuse.reset()
}
private ensureWatcherProcess(entryPath = getWatcherProcessEntryPath()): ChildProcess | null {
private ensureWatcherProcess(
entryPath = this.options.entryPath ?? getWatcherProcessEntryPath()
): ChildProcess | null {
if (this.shutdownRequested) {
return null
}
@@ -200,11 +205,7 @@ export class WatcherProcessSupervisor {
)
failAllWatcherSubscriptions(
this.records,
new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
watcherHostFailure('file watcher process crashed repeatedly', 'supervisor_crash_fuse')
)
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return
@@ -279,9 +280,8 @@ export class WatcherProcessSupervisor {
if (!replacement) {
failAllWatcherSubscriptions(
this.records,
new WatcherProcessFailure(
watcherHostFailure(
'file watcher process unavailable after subscription cancellation',
'supervisor',
'process_unavailable'
)
)
+31 -10
View File
@@ -4,21 +4,28 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { forkMock, existsSyncMock, mkdtempSyncMock, parcelSubscribeMock, rmSyncMock } = vi.hoisted(
() => ({
forkMock: vi.fn(),
existsSyncMock: vi.fn(),
mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'),
parcelSubscribeMock: vi.fn(),
rmSyncMock: vi.fn()
})
)
const {
forkMock,
existsSyncMock,
mkdtempSyncMock,
parcelSubscribeMock,
rmSyncMock,
writeFileSyncMock
} = vi.hoisted(() => ({
forkMock: vi.fn(),
existsSyncMock: vi.fn(),
mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'),
parcelSubscribeMock: vi.fn(),
rmSyncMock: vi.fn(),
writeFileSyncMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ fork: forkMock }))
vi.mock('node:fs', () => ({
existsSync: existsSyncMock,
mkdtempSync: mkdtempSyncMock,
rmSync: rmSyncMock
rmSync: rmSyncMock,
writeFileSync: writeFileSyncMock
}))
vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock }))
@@ -35,6 +42,7 @@ type SentMessage = { op: string; id: number; dir?: string }
class FakeChild extends EventEmitter {
connected = true
pid = 1234
sent: SentMessage[] = []
stderr = new EventEmitter()
kill = vi.fn(() => {
@@ -94,6 +102,19 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
it('creates the fault-harness pid file without clobbering an existing path', async () => {
vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid')
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const child = currentChild()
expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', {
flag: 'wx'
})
ackSubscribe(child)
await promise
})
it('forwards watcher errors to the callback', async () => {
const callback = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {})
@@ -1,6 +1,5 @@
import type { ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions'
import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback'
import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe'
@@ -22,6 +21,8 @@ type WatcherSupervisorSubscribeOptions = {
opts: WatcherProcessSubscribeOptions
hooks: WatcherProcessHooks
shutdownRequested: boolean
entryPath: string
useInProcessVitestFallback: boolean
allocateId: () => number
records: Map<number, WatcherProcessSubscriptionRecord>
pendingUnsubscribes: Map<number, () => void>
@@ -42,6 +43,8 @@ export function subscribeThroughWatcherSupervisor({
opts,
hooks,
shutdownRequested,
entryPath,
useInProcessVitestFallback,
allocateId,
records,
pendingUnsubscribes,
@@ -72,10 +75,9 @@ export function subscribeThroughWatcherSupervisor({
}
// Why: under Vitest we cannot fork a real watcher child, so exercise the
// subscription path in-process (against mocked @parcel/watcher) instead.
if (process.env.VITEST) {
if (process.env.VITEST && useInProcessVitestFallback) {
return subscribeWithInProcessWatcher(dir, callback, opts, hooks)
}
const entryPath = getWatcherProcessEntryPath()
if (!existsSync(entryPath)) {
return Promise.reject(
new WatcherProcessFailure(
@@ -108,11 +108,12 @@ describe('isRelayAlreadyInstalled', () => {
).rejects.toBe(sessionLimitError)
})
it('checks for relay.js AND .install-complete in addition to the dir', async () => {
it('checks for both relay process artifacts and .install-complete', async () => {
mockExec.mockResolvedValueOnce('OK')
await isRelayAlreadyInstalled(conn, '/r')
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(cmd).toContain('relay.js')
expect(cmd).toContain('relay-watcher.js')
expect(cmd).toContain('.install-complete')
})
})
+3 -4
View File
@@ -126,10 +126,9 @@ export function computeRemoteRelayDir(
/**
* Probe whether a fully-installed relay already exists at remoteRelayDir.
*
* "Fully installed" means: the directory exists, contains relay.js, AND
* contains the .install-complete sentinel written at the end of a successful
* install. A directory missing .install-complete is either mid-install (lock
* held) or a crashed-install partial — either way we re-run the deploy.
* "Fully installed" means: the directory contains relay.js, its isolated
* relay-watcher.js child, and the .install-complete sentinel written at the
* end of a successful install. Missing artifacts force a complete re-deploy.
*/
export async function isRelayAlreadyInstalled(
conn: SshConnection,
+4 -1
View File
@@ -59,11 +59,13 @@ export function probeRelayInstalledCommand(
remoteRelayDir: string
): string {
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
if (!isWindowsRemoteHost(host)) {
return (
`test -d ${shellEscape(remoteRelayDir)} ` +
`&& test -f ${shellEscape(relayJs)} ` +
`&& test -f ${shellEscape(relayWatcherJs)} ` +
`&& test -f ${shellEscape(installComplete)} ` +
`&& echo OK || echo MISSING`
)
@@ -72,8 +74,9 @@ export function probeRelayInstalledCommand(
[
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
`$relay = ${powerShellLiteral(relayJs)}`,
`$watcher = ${powerShellLiteral(relayWatcherJs)}`,
`$complete = ${powerShellLiteral(installComplete)}`,
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
].join('; ')
)
}
+6 -1
View File
@@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback'
const { mockSubscribe } = vi.hoisted(() => ({
mockSubscribe: vi.fn()
@@ -121,7 +122,11 @@ describe('FsHandler', () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-'))
dispatcher = createMockDispatcher()
const ctx = new RelayContext()
handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx)
handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx, {
dispose: vi.fn(),
forgetRoot: vi.fn(),
subscribe: subscribeWithInProcessWatcher
})
})
afterEach(async () => {
+18 -136
View File
@@ -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()
}
}
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from '../main/ipc/parcel-watcher-process-subscription'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
import { createRelayWatcherProcessPool } from './relay-watcher-process-pool'
type InstalledWatch = {
callback: WatcherProcessCallback
hooks: WatcherProcessHooks
unsubscribe: ReturnType<typeof vi.fn<() => Promise<void>>>
}
class FakeWatcherPool {
readonly installed: InstalledWatch[] = []
readonly dispose = vi.fn()
readonly forgetRoot = vi.fn()
async subscribe(
_rootPath: string,
callback: WatcherProcessCallback,
_options: object,
hooks: WatcherProcessHooks
): Promise<WatcherProcessSubscription> {
const unsubscribe = vi.fn(async () => undefined)
this.installed.push({ callback, hooks, unsubscribe })
return { unsubscribe }
}
}
function createDispatcher() {
const detached = new Set<(clientId: number) => void>()
return {
notify: vi.fn(),
onClientDetached: vi.fn((listener: (clientId: number) => void) => {
detached.add(listener)
return () => detached.delete(listener)
})
}
}
function context(clientId: number): RequestContext {
return { clientId, isStale: () => false }
}
describe('RelayFilesystemWatchRegistry', () => {
let dispatcher: ReturnType<typeof createDispatcher>
let pool: FakeWatcherPool
let registry: RelayFilesystemWatchRegistry
beforeEach(() => {
dispatcher = createDispatcher()
pool = new FakeWatcherPool()
registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool)
})
it('emits overflow around child replacement and resumes ordered event delivery', async () => {
await registry.watch('/repo', context(1))
const first = pool.installed[0]
first.callback(null, [{ type: 'create', path: '/repo/before.txt' }])
first.hooks.onInterruption?.()
first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }])
expect(dispatcher.notify.mock.calls).toEqual([
['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }],
['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }],
['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }]
])
})
it('moves a terminal shard failure into recovery without dropping shared clients', async () => {
await registry.watch('/repo', context(1))
await registry.watch('/repo', context(2))
const first = pool.installed[0]
first.hooks.onTerminalError?.(
new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
)
await Promise.resolve()
expect(pool.installed).toHaveLength(2)
pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }])
expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', {
events: [{ kind: 'overflow', absolutePath: '/repo' }]
})
expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', {
events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }]
})
registry.unwatch('/repo', context(1))
expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled()
registry.unwatch('/repo', context(2))
expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1)
})
it('aborts a pending crawl only after the last same-root client leaves', async () => {
let sharedSignal: AbortSignal | undefined
let rejectSubscribe: ((error: Error) => void) | undefined
vi.spyOn(pool, 'subscribe').mockImplementation(
(_rootPath, _callback, _options, hooks): Promise<WatcherProcessSubscription> =>
new Promise<WatcherProcessSubscription>((_resolve, reject) => {
sharedSignal = hooks.signal
rejectSubscribe = reject
hooks.signal?.addEventListener(
'abort',
() =>
reject(
new WatcherProcessFailure(
'file watcher subscription aborted',
'subscription',
'subscribe_aborted'
)
),
{ once: true }
)
})
)
const firstAbort = new AbortController()
const secondAbort = new AbortController()
const first = registry.watch('/repo', {
...context(1),
signal: firstAbort.signal
})
const second = registry.watch('/repo', {
...context(2),
signal: secondAbort.signal
})
firstAbort.abort()
await first
expect(sharedSignal?.aborted).toBe(false)
secondAbort.abort()
await second
expect(sharedSignal?.aborted).toBe(true)
expect(rejectSubscribe).toBeDefined()
})
})
describe('createRelayWatcherProcessPool', () => {
it('fails closed instead of loading the native watcher in the relay process', async () => {
const previousVitest = process.env.VITEST
process.env.VITEST = 'true'
const pool = createRelayWatcherProcessPool(
join(tmpdir(), `missing-relay-watcher-${process.pid}.js`)
)
try {
await expect(pool.subscribe('/repo', vi.fn(), {}, {})).rejects.toMatchObject({
code: 'entry_missing'
})
} finally {
pool.dispose()
if (previousVitest === undefined) {
delete process.env.VITEST
} else {
process.env.VITEST = previousVitest
}
}
})
})
@@ -0,0 +1,312 @@
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch'
import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
import type {
WatcherProcessEvent,
WatcherProcessSubscription
} from '../main/ipc/parcel-watcher-process'
import {
WATCHER_IGNORE_DIRS,
buildParcelWatcherIgnoreOptions
} from '../main/ipc/filesystem-watcher-ignore'
import {
createRelayWatcherProcessPool,
type RelayWatcherProcessPool
} from './relay-watcher-process-pool'
const MAX_RELAY_WATCH_ROOTS = 20
const RELAY_WATCH_CRAWL_TIMEOUT_MS = 60_000
const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
type RelayWatchState = {
rootPath: string
clients: Map<number, () => boolean>
setupPromise: Promise<void>
subscription: WatcherProcessSubscription | null
abortController: AbortController
generation: number
closed: boolean
}
function overflowEvent(rootPath: string): Record<string, unknown> {
return { events: [{ kind: 'overflow', absolutePath: rootPath }] }
}
function createWatchAbortError(): Error {
const error = new Error('Request "fs.watch" was cancelled')
error.name = 'AbortError'
return error
}
function shouldRetryInitialWatch(error: unknown): boolean {
return (
isWatcherProcessFailure(error) &&
error.code !== 'entry_missing' &&
error.code !== 'subscribe_aborted' &&
error.code !== 'supervisor_disposed' &&
(error.scope === 'supervisor' || error.code === 'subscribe_timeout')
)
}
export class RelayFilesystemWatchRegistry {
private readonly watches = new Map<string, RelayWatchState>()
constructor(
private readonly dispatcher: RelayDispatcher,
private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool()
) {
this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
}
async watch(rootPath: string, context?: RequestContext): Promise<void> {
this.releaseStaleWatches()
const clientId = context?.clientId ?? 0
const isStale = context?.isStale ?? (() => false)
const existing = this.watches.get(rootPath)
if (existing) {
existing.clients.set(clientId, isStale)
await this.awaitSetupForClient(existing, clientId, context)
return
}
if (this.watches.size >= MAX_RELAY_WATCH_ROOTS) {
throw new Error('Maximum number of file watchers reached')
}
const state: RelayWatchState = {
rootPath,
clients: new Map([[clientId, isStale]]),
setupPromise: Promise.resolve(),
subscription: null,
abortController: new AbortController(),
generation: 0,
closed: false
}
this.watches.set(rootPath, state)
state.setupPromise = this.startInitialWatch(state)
await this.awaitSetupForClient(state, clientId, context)
}
unwatch(rootPath: string, context?: RequestContext): void {
const state = this.watches.get(rootPath)
if (state) {
this.releaseWatchClient(state, context?.clientId ?? 0)
}
}
dispose(): void {
for (const state of Array.from(this.watches.values())) {
this.closeWatch(state)
}
this.watcherPool.dispose()
}
private async startInitialWatch(state: RelayWatchState): Promise<void> {
try {
await this.subscribeState(state)
} catch (firstError) {
if (!state.closed && shouldRetryInitialWatch(firstError)) {
try {
await this.subscribeState(state)
this.emitOverflow(state)
return
} catch (quarantineError) {
this.closeWatch(state)
throw quarantineError
}
}
this.closeWatch(state)
throw firstError
}
}
private subscribeState(state: RelayWatchState): Promise<void> {
const generation = ++state.generation
const emitOverflow = (): void => {
if (state.generation === generation) {
this.emitOverflow(state)
}
}
return this.watcherPool
.subscribe(
state.rootPath,
(error, events) => {
if (state.closed || state.generation !== generation) {
return
}
if (error) {
process.stderr.write(
`[relay] File watcher error for ${state.rootPath}: ${error.message}\n`
)
emitOverflow()
return
}
this.emitEvents(state, events)
},
RELAY_WATCH_OPTIONS,
{
delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
onInterruption: emitOverflow,
onOverflow: emitOverflow,
onTerminalError: (error) => this.recoverWatch(state, generation, error),
signal: state.abortController.signal,
subscribeTimeoutMs: RELAY_WATCH_CRAWL_TIMEOUT_MS
}
)
.then(async (subscription) => {
if (
state.closed ||
state.generation !== generation ||
this.watches.get(state.rootPath) !== state
) {
await subscription.unsubscribe()
return
}
state.subscription = subscription
})
}
private recoverWatch(state: RelayWatchState, failedGeneration: number, error: Error): void {
if (state.closed || state.generation !== failedGeneration) {
return
}
state.subscription = null
this.emitOverflow(state)
const recovery = this.subscribeState(state)
state.setupPromise = recovery
void recovery.catch((recoveryError: unknown) => {
if (!state.closed) {
const message = recoveryError instanceof Error ? recoveryError.message : error.message
process.stderr.write(
`[relay] File watcher disabled after bounded recovery for ${state.rootPath}: ${message}\n`
)
this.closeWatch(state)
}
})
}
private emitEvents(state: RelayWatchState, events: readonly WatcherProcessEvent[]): void {
if (state.closed || events.length === 0) {
return
}
this.dispatcher.notify('fs.changed', {
events: events.map((event) => ({
kind: event.type,
absolutePath: event.path,
...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory })
}))
})
}
private emitOverflow(state: RelayWatchState): void {
if (!state.closed) {
this.dispatcher.notify('fs.changed', overflowEvent(state.rootPath))
}
}
private async awaitSetupForClient(
state: RelayWatchState,
clientId: number,
context?: RequestContext
): Promise<void> {
try {
await this.awaitSetupWithAbort(state.setupPromise, context?.signal)
} catch (error) {
this.releaseWatchClient(state, clientId)
const expectedAbort =
(error instanceof Error && error.name === 'AbortError') ||
(isWatcherProcessFailure(error) && error.code === 'subscribe_aborted')
if (!expectedAbort && error instanceof Error) {
process.stderr.write(
`[relay] File watcher not available for ${state.rootPath}: ${error.message}\n`
)
throw error
}
return
}
if (context?.isStale()) {
this.releaseWatchClient(state, clientId)
}
}
private awaitSetupWithAbort(setupPromise: Promise<void>, signal?: AbortSignal): Promise<void> {
if (!signal) {
return setupPromise
}
if (signal.aborted) {
return Promise.reject(createWatchAbortError())
}
return new Promise((resolve, reject) => {
const onAbort = (): void => {
cleanup()
reject(createWatchAbortError())
}
const cleanup = (): void => signal.removeEventListener('abort', onAbort)
signal.addEventListener('abort', onAbort, { once: true })
setupPromise.then(
() => {
cleanup()
resolve()
},
(error) => {
cleanup()
reject(error)
}
)
})
}
private releaseClientWatches(clientId: number): void {
for (const state of Array.from(this.watches.values())) {
this.releaseWatchClient(state, clientId)
}
}
private releaseStaleWatches(): void {
for (const state of Array.from(this.watches.values())) {
for (const [clientId, isStale] of state.clients) {
if (isStale()) {
state.clients.delete(clientId)
}
}
if (state.clients.size === 0) {
this.closeWatch(state)
}
}
}
private releaseWatchClient(state: RelayWatchState, clientId: number): void {
state.clients.delete(clientId)
if (!state.closed && state.clients.size === 0) {
this.closeWatch(state)
}
}
private closeWatch(state: RelayWatchState): void {
if (state.closed) {
return
}
state.closed = true
state.generation++
state.abortController.abort()
state.clients.clear()
if (this.watches.get(state.rootPath) === state) {
this.watches.delete(state.rootPath)
}
const subscription = state.subscription
state.subscription = null
if (subscription) {
// Why: a child can die during unwatch; release quarantine history even
// when physical teardown reports that already-contained failure.
void subscription.unsubscribe().then(
() => this.watcherPool.forgetRoot(state.rootPath),
() => this.watcherPool.forgetRoot(state.rootPath)
)
return
}
void state.setupPromise.then(
() => this.watcherPool.forgetRoot(state.rootPath),
() => this.watcherPool.forgetRoot(state.rootPath)
)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { join } from 'node:path'
import { RuntimeWatcherProcessPool } from '../main/ipc/runtime-watcher-process-pool'
import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor'
export type RelayWatcherProcessPool = Pick<
RuntimeWatcherProcessPool,
'dispose' | 'forgetRoot' | 'subscribe'
>
export function getRelayWatcherProcessEntryPath(): string {
return join(__dirname, 'relay-watcher.js')
}
export function createRelayWatcherProcessPool(
entryPath = getRelayWatcherProcessEntryPath()
): RelayWatcherProcessPool {
return new RuntimeWatcherProcessPool({
createSupervisor: () =>
new WatcherProcessSupervisor({
entryPath,
// Why: a leaked VITEST environment must never move the native addon
// back into the relay when its crash-isolation child is missing.
useInProcessVitestFallback: false
})
})
}
+12 -1
View File
@@ -10,6 +10,7 @@ import { getEndpointFileName } from '../shared/agent-hook-listener'
import { relayTestSocketPath } from './relay-test-socket-path'
const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts')
const WATCHER_TS_ENTRY = path.resolve(__dirname, '../main/ipc/parcel-watcher-process-entry.ts')
let bundleDir: string
let relayEntry: string
const spawnedSocketDirs: string[] = []
@@ -24,7 +25,17 @@ beforeAll(async () => {
target: 'node18',
format: 'cjs',
outfile: relayEntry,
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false
})
await build({
entryPoints: [WATCHER_TS_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: path.join(bundleDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false
})
}, 30_000)