mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +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:
@@ -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'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('; ')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user