fix(fs): harden managed WSL watcher lifecycle

This commit is contained in:
Jinwoo Hong
2026-07-10 00:59:11 -04:00
parent bb7272971b
commit c541110f86
47 changed files with 6655 additions and 643 deletions
@@ -0,0 +1,124 @@
import type { NormalizedOutputOptions, OutputBundle, OutputChunk } from 'rollup'
import { describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from './plain-node-entry-guard'
function entryChunk(name: string, code: string, imports: string[] = []): OutputChunk {
return {
type: 'chunk',
name,
fileName: `${name}.js`,
code,
isEntry: true,
imports,
dynamicImports: []
} as unknown as OutputChunk
}
describe('plain Node entry guard', () => {
it('rejects electron imports reachable from the managed WSL watcher host', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const hook = plugin.writeBundle
const chunk = entryChunk('wsl-watcher-host', 'require("electron")')
const bundle = { [chunk.fileName]: chunk } as OutputBundle
expect(() => {
if (typeof hook === 'function') {
hook.call(
{
meta: { watchMode: false, rollupVersion: 'test', viteVersion: 'test' }
} as never,
{ dir: 'out/main' } as NormalizedOutputOptions,
bundle
)
}
}).toThrow(/wsl-watcher-host.*requires electron/)
})
it('rejects shared chunks that are not copied into the managed runtime', () => {
const host = entryChunk('wsl-watcher-host', 'require("node:fs")', ['chunks/shared.js'])
const shared = entryChunk('shared', 'module.exports = {}')
shared.isEntry = false
shared.fileName = 'chunks/shared.js'
expect(() => runGuard([host, shared])).toThrow(/reaches shared chunk "chunks\/shared\.js"/)
})
it('rejects bare dependencies that are absent from the managed runtime', () => {
const host = entryChunk('wsl-watcher-host', 'require("external-package")')
expect(() => runGuard([host])).toThrow(/loads unavailable dependency "external-package"/)
})
it('allows only Node built-ins and the staged native watcher binding', () => {
const host = entryChunk(
'wsl-watcher-host',
'require("node:fs"); require("path"); require("./watcher.node"); require.main === module'
)
expect(() => runGuard([host])).not.toThrow()
})
it('rejects a bare native watcher loader that Node would resolve as a package', () => {
const host = entryChunk('wsl-watcher-host', 'require("watcher.node")')
expect(() => runGuard([host])).toThrow(/loads unavailable dependency "watcher\.node"/)
})
it('rejects Rollup assets that are absent from the staged dependency set', () => {
const host = entryChunk('wsl-watcher-host', 'require("node:fs")')
host.referencedFiles = ['assets/runtime-data.bin']
expect(() => runGuard([host])).toThrow(/references unstaged asset/)
})
it('rejects implicit chunks that would not be copied beside host.js', () => {
const host = entryChunk('wsl-watcher-host', 'require("node:fs")')
host.implicitlyLoadedBefore = ['chunks/implicit.js']
const implicit = entryChunk('implicit', 'module.exports = {}')
implicit.isEntry = false
implicit.fileName = 'chunks/implicit.js'
expect(() => runGuard([host, implicit])).toThrow(/reaches shared chunk "chunks\/implicit\.js"/)
})
it.each([
'require.resolve("external-package")',
'module.require("external-package")',
'import(loaderTarget)',
'createRequire(import.meta.url)'
])('rejects indirect or dynamic loaders: %s', (code) => {
const host = entryChunk('wsl-watcher-host', code)
expect(() => runGuard([host])).toThrow(
/dependencies cannot be proven|loads unavailable dependency/
)
})
it.each([
'const load = require; load("external-package")',
'const load = module.require; load("external-package")',
'const load = require.resolve; load("external-package")',
'const { require: load } = module; load("external-package")',
'module["req" + "uire"]("external-package")',
'const property = "require"; module[property]("external-package")',
'require["res" + "olve"]("external-package")'
])('rejects aliased CommonJS loaders: %s', (code) => {
const host = entryChunk('wsl-watcher-host', code)
expect(() => runGuard([host])).toThrow(/dependencies cannot be proven/)
})
})
function runGuard(chunks: OutputChunk[]): void {
const hook = createPlainNodeEntryGuardPlugin().writeBundle
const bundle = Object.fromEntries(chunks.map((chunk) => [chunk.fileName, chunk])) as OutputBundle
if (typeof hook === 'function') {
hook.call(
{
meta: { watchMode: false, rollupVersion: 'test', viteVersion: 'test' }
} as never,
{ dir: 'out/main' } as NormalizedOutputOptions,
bundle
)
}
}
+194 -1
View File
@@ -1,6 +1,8 @@
import { spawnSync } from 'node:child_process'
import { builtinModules } from 'node:module'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
import { parseAst } from 'rollup/parseAst'
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
@@ -12,16 +14,204 @@ import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
// forked daemon, parcel-watcher hosts, computer sidecar, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'wsl-watcher-host',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls'
] as const
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
const NODE_BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))
type AstNode = { type: string; [key: string]: unknown }
function isAstNode(value: unknown): value is AstNode {
return Boolean(value && typeof value === 'object' && typeof (value as AstNode).type === 'string')
}
function walkAst(
node: AstNode,
visit: (node: AstNode, parent: AstNode | null, grandparent: AstNode | null) => void,
parent: AstNode | null = null,
grandparent: AstNode | null = null
): void {
visit(node, parent, grandparent)
for (const value of Object.values(node)) {
if (isAstNode(value)) {
walkAst(value, visit, node, parent)
} else if (Array.isArray(value)) {
for (const item of value) {
if (isAstNode(item)) {
walkAst(item, visit, node, parent)
}
}
}
}
}
function isAllowedWslHostDependency(specifier: string): boolean {
return specifier === './watcher.node' || NODE_BUILTINS.has(specifier)
}
function literalSpecifier(argument: unknown): string | undefined {
if (!isAstNode(argument) || argument.type !== 'Literal') {
return undefined
}
return typeof argument.value === 'string' ? argument.value : undefined
}
function memberName(node: AstNode): string | undefined {
if (node.type !== 'MemberExpression' || !isAstNode(node.property)) {
return undefined
}
if (node.property.type === 'Identifier' && node.computed !== true) {
return typeof node.property.name === 'string' ? node.property.name : undefined
}
return node.property.type === 'Literal' && typeof node.property.value === 'string'
? node.property.value
: undefined
}
function identifierName(node: unknown): string | undefined {
return isAstNode(node) && node.type === 'Identifier' && typeof node.name === 'string'
? node.name
: undefined
}
function assertAllowedLoaderArgument(argument: unknown): void {
const specifier = literalSpecifier(argument)
if (!specifier || !isAllowedWslHostDependency(specifier)) {
throw new Error(
`[plain-node-entry-guard] "wsl-watcher-host" loads unavailable dependency ` +
`"${specifier ?? 'dynamic expression'}". Only Node built-ins and ./watcher.node are staged in WSL.`
)
}
}
function loaderProofError(kind: string): Error {
return new Error(
`[plain-node-entry-guard] "wsl-watcher-host" ${kind}, so its staged dependencies cannot be proven.`
)
}
function assertDirectWslHostLoaders(code: string): void {
const ast = parseAst(code) as unknown as AstNode
walkAst(ast, (node, parent, grandparent) => {
if (node.type === 'ImportExpression') {
assertAllowedLoaderArgument(node.source)
return
}
if (node.type === 'CallExpression') {
const callee = node.callee
const firstArgument = Array.isArray(node.arguments) ? node.arguments[0] : undefined
if (identifierName(callee) === 'require') {
assertAllowedLoaderArgument(firstArgument)
return
}
if (isAstNode(callee) && callee.type === 'MemberExpression') {
const owner = identifierName(callee.object)
const property = memberName(callee)
if (
(owner === 'require' && property === 'resolve') ||
(owner === 'module' && property === 'require') ||
(owner === 'process' && property === 'getBuiltinModule')
) {
assertAllowedLoaderArgument(firstArgument)
return
}
if (property === 'createRequire') {
throw loaderProofError('uses createRequire')
}
}
}
if (node.type === 'Identifier' && node.name === 'createRequire') {
throw loaderProofError('uses createRequire')
}
if (node.type === 'Identifier' && node.name === 'require') {
const directCall = parent?.type === 'CallExpression' && parent.callee === node
const directResolve =
parent?.type === 'MemberExpression' &&
parent.object === node &&
memberName(parent) === 'resolve' &&
grandparent?.type === 'CallExpression' &&
grandparent.callee === parent
const propertyNameOnly =
parent?.type === 'MemberExpression' && parent.property === node && parent.computed !== true
const nonLoaderMetadata =
parent?.type === 'MemberExpression' &&
parent.object === node &&
['cache', 'extensions', 'main'].includes(memberName(parent) ?? '')
if (!directCall && !directResolve && !propertyNameOnly && !nonLoaderMetadata) {
throw loaderProofError('aliases require')
}
}
if (
node.type === 'MemberExpression' &&
node.computed === true &&
['Module', 'module', 'process', 'require'].includes(identifierName(node.object) ?? '')
) {
throw loaderProofError('uses a computed module loader property')
}
if (node.type === 'MemberExpression' && memberName(node) === 'require') {
const directModuleCall =
identifierName(node.object) === 'module' &&
parent?.type === 'CallExpression' &&
parent.callee === node
if (!directModuleCall) {
throw loaderProofError('uses an indirect require loader')
}
}
if (
node.type === 'Property' &&
parent?.type === 'ObjectPattern' &&
(identifierName(node.key) === 'require' || literalSpecifier(node.key) === 'require')
) {
throw loaderProofError('aliases require')
}
})
}
function assertWslWatcherHostSelfContained(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): void {
const linkedFiles = [
...entry.imports,
...entry.dynamicImports,
...(entry.implicitlyLoadedBefore ?? [])
]
for (const imported of linkedFiles) {
if (byFileName.has(imported)) {
throw new Error(
`[plain-node-entry-guard] "wsl-watcher-host" reaches shared chunk "${imported}". ` +
`The staged Linux host only ships host.js and watcher.node, so its JavaScript ` +
`module graph must be bundled into the entry.`
)
}
if (!isAllowedWslHostDependency(imported)) {
throw new Error(
`[plain-node-entry-guard] "wsl-watcher-host" imports external dependency ` +
`"${imported}". Only Node built-ins and ./watcher.node are staged in WSL.`
)
}
}
for (const referenced of entry.referencedFiles ?? []) {
const normalized = referenced.replaceAll('\\', '/')
if (normalized !== 'watcher.node' && normalized !== './watcher.node') {
throw new Error(
`[plain-node-entry-guard] "wsl-watcher-host" references unstaged asset ` +
`"${referenced}".`
)
}
}
assertDirectWslHostLoaders(entry.code)
}
function collectReachableChunks(
entry: OutputChunk,
@@ -119,6 +309,9 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
if (entryName === 'wsl-watcher-host') {
assertWslWatcherHostSelfContained(entry, byFileName)
}
}
}
+11 -2
View File
@@ -67,9 +67,10 @@ module.exports = {
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
// Why: these Linux-host artifacts are staged as a Windows-only external
// resource; including either copy in app.asar would duplicate ~62 MB.
// resource; app.asar must exclude compiled, prepared, and failed temp copies.
'!out/main/wsl-watcher-host.js',
'!out/wsl-watcher{,/**/*}',
'!out/wsl-watcher.*{,/**/*}',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
@@ -124,6 +125,7 @@ module.exports = {
'node_modules/sherpa-onnx*/**'
],
afterPack: async (context) => {
try {
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
@@ -174,6 +176,11 @@ module.exports = {
context.packager
)
}
} finally {
// Why: immutable WSL runtime sources are retained while Electron Builder
// reads them, then made eligible for bounded pruning after packaging.
electronBuilderBeforeBuild.releaseWslRuntimeBuildLease()
}
},
win: {
executableName: 'Orca',
@@ -198,7 +205,9 @@ module.exports = {
to: 'computer-use-windows/runtime.ps1'
},
{
from: 'out/wsl-watcher',
// Why: beforeBuild binds this process-local macro to an immutable
// content-addressed source, so concurrent publishers cannot race readers.
from: '${env.ORCA_WSL_WATCHER_BUILD_SOURCE}',
to: 'wsl-watcher'
},
featureWallResources
@@ -1,5 +1,6 @@
const { execFileSync } = require('node:child_process')
const { resolve } = require('node:path')
const { readFileSync, rmSync } = require('node:fs')
const { basename, resolve, sep } = require('node:path')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const projectDir = resolve(__dirname, '../..')
@@ -8,10 +9,14 @@ function electronBuilderBeforeBuild(context, runner = execFileSync) {
const shouldContinue = electronBuilderNativeRebuild(context)
const preparationArgs = buildWslRuntimePreparationArgs(context)
if (preparationArgs) {
runner(process.execPath, preparationArgs, {
const output = runner(process.execPath, preparationArgs, {
cwd: projectDir,
stdio: 'inherit'
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'inherit']
})
const prepared = parseWslRuntimePreparationOutput(output)
process.env.ORCA_WSL_WATCHER_BUILD_SOURCE = prepared.source
process.env.ORCA_WSL_WATCHER_BUILD_LEASE = prepared.lease
}
return shouldContinue
}
@@ -20,13 +25,95 @@ function buildWslRuntimePreparationArgs(context) {
const platform =
typeof context?.platform === 'string' ? context.platform : context?.platform?.nodeName
if (platform === 'win32') {
const processStartToken = currentProcessStartToken()
if (!processStartToken) {
throw new Error(`Could not determine Electron Builder process identity for ${process.pid}`)
}
// Why: Windows cannot execute Linux native addons directly, so package a
// verified Linux runtime that can be installed inside each WSL distro.
return ['config/scripts/prepare-wsl-watcher-runtime.mjs']
return [
'config/scripts/prepare-wsl-watcher-runtime.mjs',
'--print-package-source',
'--lease-owner-pid',
String(process.pid),
'--lease-owner-start-token',
processStartToken
]
}
return null
}
let cachedProcessStartToken
function currentProcessStartToken() {
if (cachedProcessStartToken !== undefined) {
return cachedProcessStartToken
}
try {
if (process.platform === 'linux') {
const contents = readFileSync(`/proc/${process.pid}/stat`, 'utf8')
cachedProcessStartToken = contents.slice(contents.lastIndexOf(') ') + 2).split(' ')[19]
} else if (process.platform === 'win32') {
cachedProcessStartToken = execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Process -Id ${process.pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`
],
{ encoding: 'utf8', timeout: 2_000, windowsHide: true }
).trim()
} else {
cachedProcessStartToken = execFileSync(
'ps',
['-o', 'lstart=', '-p', String(process.pid)],
{ encoding: 'utf8', timeout: 2_000 }
).trim()
}
} catch {
cachedProcessStartToken = null
}
return cachedProcessStartToken || null
}
function parseWslRuntimeBuildSource(output) {
return parseWslRuntimePreparationOutput(output).source
}
function parseWslRuntimePreparationOutput(output) {
const text = Buffer.isBuffer(output) ? output.toString('utf8') : String(output ?? '')
const lines = text.split(/\r?\n/)
const value = (name) =>
lines.find((line) => line.startsWith(`${name}=`))?.slice(name.length + 1)
const source = value('ORCA_WSL_WATCHER_BUILD_SOURCE')
const lease = value('ORCA_WSL_WATCHER_BUILD_LEASE')
if (!source || !lease) {
throw new Error('WSL watcher runtime preparation did not return an immutable leased source')
}
return { source, lease }
}
function releaseWslRuntimeBuildLease() {
const configuredLease = process.env.ORCA_WSL_WATCHER_BUILD_LEASE
delete process.env.ORCA_WSL_WATCHER_BUILD_LEASE
if (!configuredLease) {
return
}
const leasePath = resolve(projectDir, configuredLease)
const buildsRoot = resolve(projectDir, 'out', 'wsl-watcher.builds')
if (
!leasePath.startsWith(`${buildsRoot}${sep}`) ||
!/^[a-f0-9]{20}\.lease-/.test(basename(leasePath))
) {
throw new Error(`Refusing to release invalid WSL runtime build lease: ${configuredLease}`)
}
rmSync(leasePath, { recursive: true, force: true })
}
module.exports = electronBuilderBeforeBuild
module.exports.default = electronBuilderBeforeBuild
module.exports.buildWslRuntimePreparationArgs = buildWslRuntimePreparationArgs
module.exports.parseWslRuntimeBuildSource = parseWslRuntimeBuildSource
module.exports.parseWslRuntimePreparationOutput = parseWslRuntimePreparationOutput
module.exports.releaseWslRuntimeBuildLease = releaseWslRuntimeBuildLease
module.exports.currentProcessStartToken = currentProcessStartToken
@@ -7,7 +7,12 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const electronBuilderBeforeBuild = require('./electron-builder-before-build.cjs')
const { buildWslRuntimePreparationArgs } = electronBuilderBeforeBuild
const {
buildWslRuntimePreparationArgs,
currentProcessStartToken,
parseWslRuntimeBuildSource,
parseWslRuntimePreparationOutput
} = electronBuilderBeforeBuild
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
@@ -34,7 +39,8 @@ describe('electron-builder config', () => {
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
'!out/main/wsl-watcher-host.js',
'!out/wsl-watcher{,/**/*}'
'!out/wsl-watcher{,/**/*}',
'!out/wsl-watcher.*{,/**/*}'
])
)
})
@@ -63,7 +69,7 @@ describe('electron-builder config', () => {
to: 'computer-use-windows/runtime.ps1'
}),
expect.objectContaining({
from: 'out/wsl-watcher',
from: '${env.ORCA_WSL_WATCHER_BUILD_SOURCE}',
to: 'wsl-watcher'
})
])
@@ -145,12 +151,38 @@ describe('electron-builder config', () => {
it('prepares the managed WSL runtime only for Windows artifacts', () => {
expect(buildWslRuntimePreparationArgs({ platform: { nodeName: 'win32' } })).toEqual([
'config/scripts/prepare-wsl-watcher-runtime.mjs'
'config/scripts/prepare-wsl-watcher-runtime.mjs',
'--print-package-source',
'--lease-owner-pid',
String(process.pid),
'--lease-owner-start-token',
currentProcessStartToken()
])
expect(buildWslRuntimePreparationArgs({ platform: { nodeName: 'darwin' } })).toBeNull()
expect(buildWslRuntimePreparationArgs({ platform: { nodeName: 'linux' } })).toBeNull()
})
it('reads the immutable WSL source returned by runtime preparation', () => {
expect(
parseWslRuntimeBuildSource(
'[prepare] complete\nORCA_WSL_WATCHER_BUILD_SOURCE=out/wsl-watcher.builds/abc\n' +
'ORCA_WSL_WATCHER_BUILD_LEASE=out/wsl-watcher.builds/abc.lease-token\n'
)
).toBe('out/wsl-watcher.builds/abc')
expect(
parseWslRuntimePreparationOutput(
'ORCA_WSL_WATCHER_BUILD_SOURCE=out/wsl-watcher.builds/abc\n' +
'ORCA_WSL_WATCHER_BUILD_LEASE=out/wsl-watcher.builds/abc.lease-token\n'
)
).toEqual({
source: 'out/wsl-watcher.builds/abc',
lease: 'out/wsl-watcher.builds/abc.lease-token'
})
expect(() => parseWslRuntimeBuildSource('[prepare] missing source\n')).toThrow(
'did not return an immutable leased source'
)
})
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
try {
@@ -0,0 +1,140 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, readdir, rename, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { downloadRuntime, WSL_WATCHER_NODE_VERSION } from './prepare-wsl-watcher-runtime.mjs'
function sha256(contents) {
return createHash('sha256').update(contents).digest('hex')
}
function successfulFetch(contents) {
return vi.fn(async () => ({
ok: true,
status: 200,
arrayBuffer: async () => Uint8Array.from(contents).buffer
}))
}
function cachedArchive(cacheDir) {
return join(cacheDir, `node-v${WSL_WATCHER_NODE_VERSION}-linux-x64.tar.xz`)
}
async function cacheArtifacts(cacheDir) {
return (await readdir(cacheDir)).sort()
}
describe('managed WSL runtime archive cache', () => {
it('cleans a partial unique temp and preserves cache when writing fails', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'orca-wsl-cache-write-'))
const cached = cachedArchive(cacheDir)
const next = Buffer.from('verified next archive')
try {
await writeFile(cached, 'previous cache')
await expect(
downloadRuntime('x64', sha256(next), {
runtimeCacheDir: cacheDir,
fetchRuntime: successfulFetch(next),
createPublicationId: () => 'write-failure',
runtimeFileOperations: {
writeFile: async (filename, contents) => {
await writeFile(filename, contents)
if (filename.endsWith('.stage-write-failure')) {
throw new Error('cache write failed')
}
}
}
})
).rejects.toThrow('cache write failed')
await expect(readFile(cached, 'utf8')).resolves.toBe('previous cache')
await expect(cacheArtifacts(cacheDir)).resolves.toEqual([cached.split(/[\\/]/).at(-1)])
} finally {
await rm(cacheDir, { recursive: true, force: true })
}
})
it('rolls cache publication back and cleans temp files when rename fails', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'orca-wsl-cache-rename-'))
const cached = cachedArchive(cacheDir)
const next = Buffer.from('verified next archive')
const temporary = `${cached}.stage-rename-failure`
try {
await writeFile(cached, 'previous cache')
await expect(
downloadRuntime('x64', sha256(next), {
runtimeCacheDir: cacheDir,
fetchRuntime: successfulFetch(next),
createPublicationId: () => 'rename-failure',
runtimeFileOperations: {
rename: async (from, to) => {
if (from === temporary && to === cached) {
throw new Error('cache rename failed')
}
return rename(from, to)
}
}
})
).rejects.toThrow('cache rename failed')
await expect(readFile(cached, 'utf8')).resolves.toBe('previous cache')
await expect(cacheArtifacts(cacheDir)).resolves.toEqual([cached.split(/[\\/]/).at(-1)])
} finally {
await rm(cacheDir, { recursive: true, force: true })
}
})
it('keeps a concurrently published verified archive instead of replacing it', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'orca-wsl-cache-race-'))
const cached = cachedArchive(cacheDir)
const next = Buffer.from('verified next archive')
const renameRuntimePath = vi.fn(rename)
try {
await writeFile(cached, 'stale cache')
await downloadRuntime('x64', sha256(next), {
runtimeCacheDir: cacheDir,
fetchRuntime: successfulFetch(next),
createPublicationId: () => 'racing',
beforeCachePublish: async () => writeFile(cached, next),
runtimeFileOperations: { rename: renameRuntimePath }
})
expect(
renameRuntimePath.mock.calls.some(
([from, to]) => String(from).includes('.stage-') && to === cached
)
).toBe(false)
await expect(readFile(cached)).resolves.toEqual(next)
await expect(cacheArtifacts(cacheDir)).resolves.toEqual([cached.split(/[\\/]/).at(-1)])
} finally {
await rm(cacheDir, { recursive: true, force: true })
}
})
it('recovers a verified crash backup before downloading and scavenges aged stages', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'orca-wsl-cache-recover-'))
const cached = cachedArchive(cacheDir)
const backup = `${cached}.backup-crash`
const staleStage = `${cached}.stage-abandoned`
const verified = Buffer.from('verified crash backup')
const fetchRuntime = successfulFetch(Buffer.from('should not download'))
try {
await writeFile(backup, verified)
await writeFile(staleStage, 'partial')
await utimes(staleStage, new Date(0), new Date(0))
await downloadRuntime('x64', sha256(verified), {
runtimeCacheDir: cacheDir,
fetchRuntime,
createPublicationId: () => 'recovered'
})
expect(fetchRuntime).not.toHaveBeenCalled()
await expect(readFile(cached)).resolves.toEqual(verified)
await expect(cacheArtifacts(cacheDir)).resolves.toEqual([cached.split(/[\\/]/).at(-1)])
} finally {
await rm(cacheDir, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,165 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { prepareWslWatcherRuntime } from './prepare-wsl-watcher-runtime.mjs'
function deferred() {
let resolve
const promise = new Promise((createdResolve) => {
resolve = createdResolve
})
return { promise, resolve }
}
async function pathExists(filename) {
try {
await stat(filename)
return true
} catch {
return false
}
}
async function makeFixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-runtime-recovery-'))
const output = join(root, 'out', 'wsl-watcher')
const hostSource = join(root, 'out', 'main', 'wsl-watcher-host.js')
const archive = join(root, 'node.tar.xz')
await mkdir(join(root, 'out', 'main'), { recursive: true })
await mkdir(join(root, 'node_modules', '@parcel', 'watcher'), { recursive: true })
await mkdir(join(root, 'node_modules', '@parcel', 'watcher-linux-x64-glibc'), {
recursive: true
})
await writeFile(hostSource, 'host version one')
await writeFile(join(root, 'node_modules', '@parcel', 'watcher', 'LICENSE'), 'license')
await writeFile(
join(root, 'node_modules', '@parcel', 'watcher-linux-x64-glibc', 'watcher.node'),
'native payload'
)
const archivePayload = 'archive payload'
await writeFile(archive, archivePayload)
return {
root,
output,
hostSource,
archive,
archiveHash: createHash('sha256').update(archivePayload).digest('hex')
}
}
function options(fixture, publicationId, overrides = {}) {
return {
runtimeRootDir: fixture.root,
runtimeOutputDir: fixture.output,
fetchRuntimeArchive: vi.fn(async () => fixture.archive),
archives: {
x64: {
sha256: fixture.archiveHash,
watcherPackage: '@parcel/watcher-linux-x64-glibc'
}
},
createPublicationId: () => publicationId,
publicationLockOptions: { retryDelayMs: 1, timeoutMs: 2_000 },
...overrides
}
}
describe('WSL runtime crash recovery and immutable package source', () => {
it('keeps the atomic development pointer readable while public publication is paused', async () => {
const fixture = await makeFixture()
const secondReady = deferred()
const releaseSecond = deferred()
try {
const first = await prepareWslWatcherRuntime(options(fixture, 'first'))
await writeFile(fixture.hostSource, 'host version two')
const secondPending = prepareWslWatcherRuntime(
options(fixture, 'second', {
runtimeFileOperations: {
rename: async (from, to) => {
await rename(from, to)
if (from === fixture.output && to === `${fixture.output}.backup-second`) {
secondReady.resolve()
await releaseSecond.promise
}
}
}
})
)
await secondReady.promise
expect(await pathExists(fixture.output)).toBe(false)
const pointerPath = `${fixture.output}.current.json`
const firstPointer = JSON.parse(await readFile(pointerPath, 'utf8'))
const pointedSource = resolve(dirname(pointerPath), ...firstPointer.relativePath.split('/'))
expect(pointedSource).toBe(first.packageSourceDir)
await expect(readFile(join(first.packageSourceDir, 'host.js'), 'utf8')).resolves.toBe(
'host version one'
)
releaseSecond.resolve()
const second = await secondPending
expect(second.packageSourceDir).not.toBe(first.packageSourceDir)
await expect(readFile(join(second.packageSourceDir, 'host.js'), 'utf8')).resolves.toBe(
'host version two'
)
const secondPointer = JSON.parse(await readFile(pointerPath, 'utf8'))
expect(secondPointer.bundleVersion).toBe(second.bundleVersion)
} finally {
releaseSecond.resolve()
await rm(fixture.root, { recursive: true, force: true })
}
})
it('rehashes and repairs a corrupted immutable build instead of reusing it', async () => {
const fixture = await makeFixture()
try {
const first = await prepareWslWatcherRuntime(options(fixture, 'first-corruption'))
await writeFile(join(first.packageSourceDir, 'host.js'), 'corrupted host')
const repaired = await prepareWslWatcherRuntime(options(fixture, 'repair-corruption'))
expect(repaired.packageSourceDir).toBe(first.packageSourceDir)
await expect(readFile(join(repaired.packageSourceDir, 'host.js'), 'utf8')).resolves.toBe(
'host version one'
)
} finally {
await rm(fixture.root, { recursive: true, force: true })
}
})
it('recovers a valid crash backup and scavenges an aged abandoned stage', async () => {
const fixture = await makeFixture()
try {
await prepareWslWatcherRuntime(options(fixture, 'initial'))
const backup = `${fixture.output}.backup-crash`
const abandoned = `${fixture.output}.stage-abandoned`
await rename(fixture.output, backup)
await mkdir(abandoned)
await writeFile(join(abandoned, 'partial'), 'partial')
await utimes(abandoned, new Date(0), new Date(0))
await writeFile(fixture.hostSource, 'host version two')
await expect(
prepareWslWatcherRuntime(
options(fixture, 'recovery', {
beforePublish: async () => {
expect(await readFile(join(fixture.output, 'host.js'), 'utf8')).toBe(
'host version one'
)
throw new Error('stop after recovery')
}
})
)
).rejects.toThrow('stop after recovery')
await expect(readFile(join(fixture.output, 'host.js'), 'utf8')).resolves.toBe(
'host version one'
)
await expect(pathExists(abandoned)).resolves.toBe(false)
await expect(pathExists(backup)).resolves.toBe(false)
} finally {
await rm(fixture.root, { recursive: true, force: true })
}
})
})
+152 -82
View File
@@ -1,6 +1,18 @@
import { createHash } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import {
recoverAndScavengeRuntimePaths,
replacePublishedRuntimePath,
withRuntimePublicationLock
} from './wsl-watcher-runtime-publication.mjs'
import {
acquireRuntimeBuildLease,
createImmutableRuntimeBuildSource,
publishRuntimeBuildPointer,
validatePreparedRuntimeBundle
} from './wsl-watcher-runtime-build-source.mjs'
import { downloadRuntimeArchive } from './wsl-watcher-runtime-cache.mjs'
export const WSL_WATCHER_NODE_VERSION = '24.15.0'
const WSL_WATCHER_INSTALL_LAYOUT_VERSION = 1
@@ -23,45 +35,15 @@ function sha256(contents) {
return createHash('sha256').update(contents).digest('hex')
}
async function matchesHash(filename, expected) {
try {
return sha256(await readFile(filename)) === expected
} catch {
return false
}
const defaultRuntimeFileOperations = { copyFile, mkdir, readFile, rename, rm, stat, writeFile }
export function downloadRuntime(arch, expectedHash, options = {}) {
return downloadRuntimeArchive(arch, expectedHash, WSL_WATCHER_NODE_VERSION, cacheDir, options)
}
async function downloadRuntime(arch, expectedHash) {
await mkdir(cacheDir, { recursive: true })
const filename = `node-v${WSL_WATCHER_NODE_VERSION}-linux-${arch}.tar.xz`
const cached = path.join(cacheDir, filename)
if (await matchesHash(cached, expectedHash)) {
return cached
}
const response = await fetch(`https://nodejs.org/dist/v${WSL_WATCHER_NODE_VERSION}/${filename}`, {
signal: AbortSignal.timeout(60_000)
})
if (!response.ok) {
throw new Error(`Could not download ${filename}: HTTP ${response.status}`)
}
const contents = Buffer.from(await response.arrayBuffer())
const actualHash = sha256(contents)
if (actualHash !== expectedHash) {
throw new Error(
`Checksum mismatch for ${filename}: expected ${expectedHash}, got ${actualHash}`
)
}
const temporary = `${cached}.${process.pid}.tmp`
await writeFile(temporary, contents)
await rm(cached, { force: true })
await rename(temporary, cached)
return cached
}
async function assertBuildInput(filename, description) {
async function assertBuildInput(filename, description, statRuntimePath = stat) {
try {
const info = await stat(filename)
const info = await statRuntimePath(filename)
if (info.isFile()) {
return
}
@@ -69,57 +51,145 @@ async function assertBuildInput(filename, description) {
throw new Error(`Missing ${description} at ${filename}. Run pnpm build:electron-vite first.`)
}
export async function prepareWslWatcherRuntime() {
const hostSource = path.join(rootDir, 'out', 'main', 'wsl-watcher-host.js')
await assertBuildInput(hostSource, 'compiled WSL watcher host')
export async function prepareWslWatcherRuntime({
runtimeRootDir = rootDir,
runtimeOutputDir = outputDir,
fetchRuntimeArchive = downloadRuntime,
archives = runtimeArchives,
createPublicationId = randomUUID,
beforePublish,
afterPublicationLockAcquired,
runtimeFileOperations: operationOverrides = {},
publicationLockOptions = {},
buildRetentionOptions = {}
} = {}) {
const operations = { ...defaultRuntimeFileOperations, ...operationOverrides }
const hostSource = path.join(runtimeRootDir, 'out', 'main', 'wsl-watcher-host.js')
await assertBuildInput(hostSource, 'compiled WSL watcher host', operations.stat)
const stageDir = `${outputDir}.${process.pid}.tmp`
await rm(stageDir, { recursive: true, force: true })
await mkdir(stageDir, { recursive: true })
const hostContents = await readFile(hostSource)
await writeFile(path.join(stageDir, 'host.js'), hostContents)
await copyFile(
path.join(rootDir, 'node_modules', '@parcel', 'watcher', 'LICENSE'),
path.join(stageDir, 'parcel-watcher-LICENSE')
const publicationId = createPublicationId()
const architectures = Object.keys(archives)
const stageDir = `${runtimeOutputDir}.stage-${publicationId}`
const backupDir = `${runtimeOutputDir}.backup-${publicationId}`
const lockPath = `${runtimeOutputDir}.publish.lock`
const validatePublished = (candidate) =>
validatePreparedRuntimeBundle(candidate, architectures, undefined, { operations })
await operations.mkdir(path.dirname(runtimeOutputDir), { recursive: true })
await withRuntimePublicationLock(
lockPath,
() => recoverAndScavengeRuntimePaths(runtimeOutputDir, validatePublished, { operations }),
{ ...publicationLockOptions, operations }
)
const versionParts = [
String(WSL_WATCHER_INSTALL_LAYOUT_VERSION),
WSL_WATCHER_NODE_VERSION,
sha256(hostContents)
]
for (const [arch, runtime] of Object.entries(runtimeArchives)) {
const archDir = path.join(stageDir, arch)
await mkdir(archDir, { recursive: true })
const archive = await downloadRuntime(arch, runtime.sha256)
const watcherSource = path.join(
rootDir,
'node_modules',
'@parcel',
runtime.watcherPackage.replace('@parcel/', ''),
'watcher.node'
try {
await operations.rm(stageDir, { recursive: true, force: true })
await operations.mkdir(stageDir, { recursive: true })
const hostContents = await operations.readFile(hostSource)
await operations.writeFile(path.join(stageDir, 'host.js'), hostContents)
const watcherLicense = await operations.readFile(
path.join(runtimeRootDir, 'node_modules', '@parcel', 'watcher', 'LICENSE')
)
const watcherContents = await readFile(watcherSource)
versionParts.push(arch, runtime.sha256, sha256(watcherContents))
await copyFile(archive, path.join(archDir, 'node.tar.xz'))
await writeFile(path.join(archDir, 'watcher.node'), watcherContents)
}
await operations.writeFile(path.join(stageDir, 'parcel-watcher-LICENSE'), watcherLicense)
const manifest = {
protocol: 1,
installLayout: WSL_WATCHER_INSTALL_LAYOUT_VERSION,
nodeVersion: WSL_WATCHER_NODE_VERSION,
bundleVersion: sha256(versionParts.join('\n')).slice(0, 20)
const versionParts = [
String(WSL_WATCHER_INSTALL_LAYOUT_VERSION),
WSL_WATCHER_NODE_VERSION,
sha256(hostContents),
sha256(watcherLicense)
]
for (const [arch, runtime] of Object.entries(archives)) {
const archDir = path.join(stageDir, arch)
await operations.mkdir(archDir, { recursive: true })
const archive = await fetchRuntimeArchive(arch, runtime.sha256)
const watcherSource = path.join(
runtimeRootDir,
'node_modules',
'@parcel',
runtime.watcherPackage.replace('@parcel/', ''),
'watcher.node'
)
const watcherContents = await operations.readFile(watcherSource)
versionParts.push(arch, runtime.sha256, sha256(watcherContents))
await operations.copyFile(archive, path.join(archDir, 'node.tar.xz'))
await operations.writeFile(path.join(archDir, 'watcher.node'), watcherContents)
}
const manifest = {
protocol: 1,
installLayout: WSL_WATCHER_INSTALL_LAYOUT_VERSION,
nodeVersion: WSL_WATCHER_NODE_VERSION,
bundleVersion: sha256(versionParts.join('\n')).slice(0, 20)
}
await operations.writeFile(
path.join(stageDir, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
)
const packageSourceDir = await createImmutableRuntimeBuildSource(
stageDir,
runtimeOutputDir,
manifest,
architectures,
publicationId,
{
operations,
lockOptions: publicationLockOptions,
retentionOptions: buildRetentionOptions
}
)
await beforePublish?.({ publicationId, stageDir })
await withRuntimePublicationLock(
lockPath,
async () => {
await recoverAndScavengeRuntimePaths(runtimeOutputDir, validatePublished, { operations })
await afterPublicationLockAcquired?.({ publicationId, stageDir })
await replacePublishedRuntimePath(stageDir, runtimeOutputDir, backupDir, {
operations
})
await publishRuntimeBuildPointer(
runtimeOutputDir,
packageSourceDir,
manifest,
publicationId,
{ operations }
)
},
{ ...publicationLockOptions, operations }
)
process.stdout.write(
`[prepare-wsl-watcher-runtime] prepared ${manifest.bundleVersion} in ${runtimeOutputDir}\n`
)
return { ...manifest, packageSourceDir }
} finally {
// Why: interrupted preparation must not leave large archives eligible for
// app.asar discovery or consume disk indefinitely across package retries.
await operations.rm(stageDir, { recursive: true, force: true })
}
await writeFile(path.join(stageDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
await rm(outputDir, { recursive: true, force: true })
await rename(stageDir, outputDir)
process.stdout.write(
`[prepare-wsl-watcher-runtime] prepared ${manifest.bundleVersion} in ${outputDir}\n`
)
return manifest
}
function commandLineValue(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
}
if (process.argv[1] === import.meta.filename) {
await prepareWslWatcherRuntime()
const prepared = await prepareWslWatcherRuntime()
if (process.argv.includes('--print-package-source')) {
const ownerProcessId = Number(commandLineValue('--lease-owner-pid'))
const ownerProcessStartToken = commandLineValue('--lease-owner-start-token')
if (
!Number.isSafeInteger(ownerProcessId) ||
ownerProcessId <= 0 ||
ownerProcessId !== process.ppid ||
!ownerProcessStartToken
) {
throw new Error('Invalid Electron Builder parent identity for WSL runtime lease')
}
const packageSource = path.relative(rootDir, prepared.packageSourceDir).replaceAll('\\', '/')
const leasePath = await acquireRuntimeBuildLease(prepared.packageSourceDir, {
ownerProcessId,
ownerProcessStartToken
})
const packageLease = path.relative(rootDir, leasePath).replaceAll('\\', '/')
process.stdout.write(`ORCA_WSL_WATCHER_BUILD_SOURCE=${packageSource}\n`)
process.stdout.write(`ORCA_WSL_WATCHER_BUILD_LEASE=${packageLease}\n`)
}
}
@@ -0,0 +1,180 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { prepareWslWatcherRuntime } from './prepare-wsl-watcher-runtime.mjs'
function deferred() {
let resolve
const promise = new Promise((createdResolve) => {
resolve = createdResolve
})
return { promise, resolve }
}
async function pathExists(filename) {
try {
await stat(filename)
return true
} catch {
return false
}
}
async function makeFixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-runtime-stage-'))
const output = join(root, 'out', 'wsl-watcher')
const archive = join(root, 'node.tar.xz')
await mkdir(join(root, 'out', 'main'), { recursive: true })
await mkdir(join(root, 'node_modules', '@parcel', 'watcher'), { recursive: true })
await mkdir(join(root, 'node_modules', '@parcel', 'watcher-linux-x64-glibc'), {
recursive: true
})
await writeFile(join(root, 'out', 'main', 'wsl-watcher-host.js'), 'host payload', 'utf8')
await writeFile(join(root, 'node_modules', '@parcel', 'watcher', 'LICENSE'), 'license', 'utf8')
await writeFile(
join(root, 'node_modules', '@parcel', 'watcher-linux-x64-glibc', 'watcher.node'),
'native payload',
'utf8'
)
const archivePayload = 'archive payload'
await writeFile(archive, archivePayload, 'utf8')
return {
root,
output,
archive,
archiveHash: createHash('sha256').update(archivePayload).digest('hex')
}
}
function preparationOptions(fixture, overrides = {}) {
return {
runtimeRootDir: fixture.root,
runtimeOutputDir: fixture.output,
fetchRuntimeArchive: vi.fn(async () => fixture.archive),
archives: {
x64: {
sha256: fixture.archiveHash,
watcherPackage: '@parcel/watcher-linux-x64-glibc'
}
},
publicationLockOptions: { retryDelayMs: 1, timeoutMs: 2_000 },
...overrides
}
}
describe('prepareWslWatcherRuntime', () => {
it('removes failed unique staging without replacing the last complete runtime', async () => {
const fixture = await makeFixture()
try {
await mkdir(fixture.output, { recursive: true })
await writeFile(join(fixture.output, 'complete.txt'), 'last complete runtime', 'utf8')
const options = preparationOptions(fixture, {
createPublicationId: () => 'failed',
fetchRuntimeArchive: vi.fn().mockRejectedValue(new Error('download interrupted'))
})
await expect(prepareWslWatcherRuntime(options)).rejects.toThrow('download interrupted')
await expect(pathExists(`${fixture.output}.stage-failed`)).resolves.toBe(false)
await expect(readFile(join(fixture.output, 'complete.txt'), 'utf8')).resolves.toBe(
'last complete runtime'
)
} finally {
await rm(fixture.root, { recursive: true, force: true })
}
})
it('serializes concurrent publication after both unique stages are complete', async () => {
const fixture = await makeFixture()
const firstLocked = deferred()
const releaseFirst = deferred()
const secondBlocked = deferred()
let secondAcquired = false
try {
const first = prepareWslWatcherRuntime(
preparationOptions(fixture, {
createPublicationId: () => 'first',
afterPublicationLockAcquired: async () => {
firstLocked.resolve()
await releaseFirst.promise
}
})
)
await firstLocked.promise
const second = prepareWslWatcherRuntime(
preparationOptions(fixture, {
createPublicationId: () => 'second',
runtimeFileOperations: {
mkdir: async (filename, options) => {
try {
return await mkdir(filename, options)
} catch (error) {
if (filename === `${fixture.output}.publish.lock` && error?.code === 'EEXIST') {
secondBlocked.resolve()
}
throw error
}
}
},
afterPublicationLockAcquired: () => {
secondAcquired = true
}
})
)
await secondBlocked.promise
expect(secondAcquired).toBe(false)
releaseFirst.resolve()
await Promise.all([first, second])
expect(secondAcquired).toBe(true)
await expect(readFile(join(fixture.output, 'host.js'), 'utf8')).resolves.toBe('host payload')
await expect(readFile(join(fixture.output, 'manifest.json'), 'utf8')).resolves.toContain(
'"bundleVersion"'
)
expect(
(await readdir(join(fixture.root, 'out'))).filter((name) =>
/\.(?:stage|backup)-|\.publish\.lock$/.test(name)
)
).toEqual([])
} finally {
releaseFirst.resolve()
await rm(fixture.root, { recursive: true, force: true })
}
})
it('rolls back the previous complete output when final publication rename fails', async () => {
const fixture = await makeFixture()
try {
await mkdir(fixture.output, { recursive: true })
await writeFile(join(fixture.output, 'complete.txt'), 'known good', 'utf8')
const stage = `${fixture.output}.stage-rollback`
const options = preparationOptions(fixture, {
createPublicationId: () => 'rollback',
runtimeFileOperations: {
rename: async (from, to) => {
if (from === stage && to === fixture.output) {
throw new Error('final rename failed')
}
return rename(from, to)
}
}
})
await expect(prepareWslWatcherRuntime(options)).rejects.toThrow('final rename failed')
await expect(readFile(join(fixture.output, 'complete.txt'), 'utf8')).resolves.toBe(
'known good'
)
expect((await readdir(join(fixture.root, 'out'))).sort()).toEqual([
'main',
'wsl-watcher',
'wsl-watcher.builds'
])
} finally {
await rm(fixture.root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,361 @@
import { execFile } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { promisify } from 'node:util'
import {
recoverAndScavengeRuntimePaths,
withRuntimePublicationLock
} from './wsl-watcher-runtime-publication.mjs'
const DEFAULT_MAX_COMPLETED_BUILDS = 5
const DEFAULT_MIN_RECENT_BUILDS = 2
const DEFAULT_MAX_BUILD_AGE_MS = 7 * 24 * 60 * 60 * 1_000
const DEFAULT_UNKNOWN_LEASE_AGE_MS = 24 * 60 * 60 * 1_000
const BUILD_VERSION_RE = /^[a-f0-9]{20}$/
const defaultOperations = { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile }
const execFileAsync = promisify(execFile)
function sha256(contents) {
return createHash('sha256').update(contents).digest('hex')
}
async function isFile(filename, operations) {
try {
return (await operations.stat(filename)).isFile()
} catch {
return false
}
}
async function computedBundleVersion(bundlePath, architectures, manifest, operations) {
const versionParts = [
String(manifest.installLayout),
manifest.nodeVersion,
sha256(await operations.readFile(join(bundlePath, 'host.js'))),
sha256(await operations.readFile(join(bundlePath, 'parcel-watcher-LICENSE')))
]
for (const arch of architectures) {
versionParts.push(
arch,
sha256(await operations.readFile(join(bundlePath, arch, 'node.tar.xz'))),
sha256(await operations.readFile(join(bundlePath, arch, 'watcher.node')))
)
}
return sha256(versionParts.join('\n')).slice(0, 20)
}
export async function validatePreparedRuntimeBundle(
bundlePath,
architectures,
expectedManifest,
{ operations: operationOverrides = {} } = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
let manifest
try {
manifest = JSON.parse(await operations.readFile(join(bundlePath, 'manifest.json'), 'utf8'))
} catch {
return false
}
if (
manifest?.protocol !== 1 ||
manifest.installLayout !== 1 ||
typeof manifest.nodeVersion !== 'string' ||
!BUILD_VERSION_RE.test(manifest.bundleVersion) ||
(expectedManifest && JSON.stringify(manifest) !== JSON.stringify(expectedManifest))
) {
return false
}
const required = ['host.js', 'parcel-watcher-LICENSE']
for (const arch of architectures) {
required.push(join(arch, 'node.tar.xz'), join(arch, 'watcher.node'))
}
if (
!(await Promise.all(required.map((name) => isFile(join(bundlePath, name), operations)))).every(
Boolean
)
) {
return false
}
try {
// Why: the manifest names the content address; existence checks alone let
// a corrupted prior build be silently reused by a later packager.
return (
(await computedBundleVersion(bundlePath, architectures, manifest, operations)) ===
manifest.bundleVersion
)
} catch {
return false
}
}
export function runtimeBuildPointerPath(runtimeOutputDir) {
return `${runtimeOutputDir}.current.json`
}
function pointerContents(runtimeOutputDir, manifest) {
return {
protocol: 1,
bundleVersion: manifest.bundleVersion,
relativePath: `${basename(runtimeOutputDir)}.builds/${manifest.bundleVersion}`
}
}
export async function publishRuntimeBuildPointer(
runtimeOutputDir,
immutablePath,
manifest,
publicationId,
{ operations: operationOverrides = {} } = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
const expectedPath = join(`${runtimeOutputDir}.builds`, manifest.bundleVersion)
if (immutablePath !== expectedPath) {
throw new Error(`Invalid immutable WSL runtime source: ${immutablePath}`)
}
const pointerPath = runtimeBuildPointerPath(runtimeOutputDir)
const pointerStage = `${pointerPath}.stage-${publicationId}`
try {
await operations.writeFile(
pointerStage,
`${JSON.stringify(pointerContents(runtimeOutputDir, manifest), null, 2)}\n`
)
// Why: readers see either the complete old pointer or complete new pointer;
// both reference immutable directories that retention keeps available.
await operations.rename(pointerStage, pointerPath)
} finally {
await operations.rm(pointerStage, { force: true })
}
return pointerPath
}
async function readCurrentBuildVersion(runtimeOutputDir, operations) {
try {
const pointer = JSON.parse(
await operations.readFile(runtimeBuildPointerPath(runtimeOutputDir), 'utf8')
)
return pointer?.protocol === 1 && BUILD_VERSION_RE.test(pointer.bundleVersion)
? pointer.bundleVersion
: null
} catch {
return null
}
}
export async function runtimeProcessStartToken(pid) {
try {
if (process.platform === 'linux') {
const contents = await readFile(`/proc/${pid}/stat`, 'utf8')
return contents.slice(contents.lastIndexOf(') ') + 2).split(' ')[19] ?? null
}
if (process.platform === 'win32') {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`
],
{ encoding: 'utf8', timeout: 2_000, windowsHide: true }
)
return stdout.trim() || null
}
const { stdout } = await execFileAsync('ps', ['-o', 'lstart=', '-p', String(pid)], {
encoding: 'utf8',
timeout: 2_000
})
return stdout.trim() || null
} catch {
return null
}
}
async function defaultLeaseOwnerIsAlive(owner) {
try {
process.kill(owner.pid, 0)
} catch (error) {
if (error?.code !== 'EPERM') {
return false
}
}
const actualStartToken = await runtimeProcessStartToken(owner.pid)
if (!actualStartToken || typeof owner.processStartToken !== 'string') {
return null
}
return actualStartToken === owner.processStartToken
}
async function activeLeaseVersions(buildsDir, names, operations, options) {
const active = new Set()
const leasePattern = /^([a-f0-9]{20})\.lease-/
for (const name of names) {
const version = name.match(leasePattern)?.[1]
if (!version) {
continue
}
const leasePath = join(buildsDir, name)
let owner
let modifiedAt = 0
try {
owner = JSON.parse(await operations.readFile(join(leasePath, 'owner.json'), 'utf8'))
modifiedAt = (await operations.stat(leasePath)).mtimeMs
} catch {}
const alive = Number.isSafeInteger(owner?.pid) ? await options.ownerIsAlive(owner) : null
if (
alive === true ||
(alive === null && options.now() - modifiedAt < options.unknownLeaseAgeMs)
) {
active.add(version)
} else {
await operations.rm(leasePath, { recursive: true, force: true })
}
}
return active
}
export async function pruneImmutableRuntimeBuilds(
runtimeOutputDir,
{
operations: operationOverrides = {},
maxCompletedBuilds = DEFAULT_MAX_COMPLETED_BUILDS,
minRecentBuilds = DEFAULT_MIN_RECENT_BUILDS,
maxBuildAgeMs = DEFAULT_MAX_BUILD_AGE_MS,
unknownLeaseAgeMs = DEFAULT_UNKNOWN_LEASE_AGE_MS,
ownerIsAlive = defaultLeaseOwnerIsAlive,
now = Date.now
} = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
const buildsDir = `${runtimeOutputDir}.builds`
let names
try {
names = await operations.readdir(buildsDir)
} catch {
return
}
const activeVersions = await activeLeaseVersions(buildsDir, names, operations, {
now,
ownerIsAlive,
unknownLeaseAgeMs
})
const currentVersion = await readCurrentBuildVersion(runtimeOutputDir, operations)
const builds = (
await Promise.all(
names
.filter((name) => BUILD_VERSION_RE.test(name))
.map(async (name) => {
try {
return { name, modifiedAt: (await operations.stat(join(buildsDir, name))).mtimeMs }
} catch {
return null
}
})
)
)
.filter(Boolean)
.sort((left, right) => right.modifiedAt - left.modifiedAt)
let retainedCompleted = 0
for (const [index, build] of builds.entries()) {
const protectedBuild =
build.name === currentVersion || activeVersions.has(build.name) || index < minRecentBuilds
const withinAge = now() - build.modifiedAt < maxBuildAgeMs
if (protectedBuild || (retainedCompleted < maxCompletedBuilds && withinAge)) {
if (!activeVersions.has(build.name)) {
retainedCompleted += 1
}
continue
}
await operations.rm(join(buildsDir, build.name), { recursive: true, force: true })
}
}
export async function acquireRuntimeBuildLease(
immutablePath,
{
operations: operationOverrides = {},
createToken = randomUUID,
ownerProcessId = process.pid,
ownerProcessStartToken,
readProcessStartToken = runtimeProcessStartToken,
now = Date.now
} = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
const version = basename(immutablePath)
if (!BUILD_VERSION_RE.test(version)) {
throw new Error(`Cannot lease invalid WSL runtime build source: ${immutablePath}`)
}
const claimedStartToken = ownerProcessStartToken ?? (await readProcessStartToken(ownerProcessId))
const actualStartToken = await readProcessStartToken(ownerProcessId)
if (!claimedStartToken || actualStartToken !== claimedStartToken) {
throw new Error(`Cannot verify WSL runtime build lease owner: ${ownerProcessId}`)
}
const leasePath = join(dirname(immutablePath), `${version}.lease-${createToken()}`)
try {
await operations.mkdir(leasePath)
await operations.writeFile(
join(leasePath, 'owner.json'),
`${JSON.stringify({
protocol: 1,
pid: ownerProcessId,
processStartToken: claimedStartToken,
createdAt: now()
})}\n`
)
return leasePath
} catch (error) {
await operations.rm(leasePath, { recursive: true, force: true })
throw error
}
}
export async function createImmutableRuntimeBuildSource(
sourceStage,
runtimeOutputDir,
manifest,
architectures,
publicationId,
{
operations: operationOverrides = {},
lockOptions = {},
retentionOptions = {},
afterBuildSourceLockAcquired
} = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
const buildsDir = `${runtimeOutputDir}.builds`
const immutablePath = join(buildsDir, manifest.bundleVersion)
const immutableStage = `${immutablePath}.stage-${publicationId}`
const lockPath = `${runtimeOutputDir}.builds.publish.lock`
const validate = (candidate) =>
validatePreparedRuntimeBundle(candidate, architectures, manifest, { operations })
await operations.mkdir(buildsDir, { recursive: true })
try {
await operations.rm(immutableStage, { recursive: true, force: true })
await operations.cp(sourceStage, immutableStage, { recursive: true, force: false })
await withRuntimePublicationLock(
lockPath,
async () => {
await afterBuildSourceLockAcquired?.({ immutablePath, immutableStage })
await recoverAndScavengeRuntimePaths(immutablePath, validate, { operations })
if (!(await validate(immutableStage))) {
throw new Error(`Prepared WSL runtime stage failed content validation: ${immutableStage}`)
}
if (!(await validate(immutablePath))) {
await operations.rm(immutablePath, { recursive: true, force: true })
await operations.rename(immutableStage, immutablePath)
}
await pruneImmutableRuntimeBuilds(runtimeOutputDir, {
...retentionOptions,
operations
})
},
{ ...lockOptions, operations }
)
return immutablePath
} finally {
await operations.rm(immutableStage, { recursive: true, force: true })
}
}
@@ -0,0 +1,116 @@
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readdir, rm, utimes } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
import {
acquireRuntimeBuildLease,
pruneImmutableRuntimeBuilds,
publishRuntimeBuildPointer,
runtimeProcessStartToken
} from './wsl-watcher-runtime-build-source.mjs'
const execFileAsync = promisify(execFile)
describe('immutable WSL runtime build retention', () => {
it('preserves current, recent, and actively leased builds while bounding completed versions', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-build-retention-'))
const output = join(root, 'out', 'wsl-watcher')
const buildsDir = `${output}.builds`
const versions = Array.from({ length: 6 }, (_, index) =>
(index + 1).toString(16).padStart(20, '0')
)
try {
await mkdir(buildsDir, { recursive: true })
for (const [index, version] of versions.entries()) {
const build = join(buildsDir, version)
await mkdir(build)
await utimes(build, new Date(index * 1_000), new Date(index * 1_000))
}
const currentVersion = versions[1]
await publishRuntimeBuildPointer(
output,
join(buildsDir, currentVersion),
{ bundleVersion: currentVersion },
'pointer'
)
const leasedVersion = versions[0]
const lease = await acquireRuntimeBuildLease(join(buildsDir, leasedVersion), {
createToken: () => 'active'
})
await pruneImmutableRuntimeBuilds(output, {
maxCompletedBuilds: 2,
minRecentBuilds: 1,
maxBuildAgeMs: 0,
now: () => 100_000,
ownerIsAlive: () => true
})
expect(
(await readdir(buildsDir)).filter((name) => /^[a-f0-9]{20}$/.test(name)).sort()
).toEqual([currentVersion, leasedVersion, versions.at(-1)].sort())
await rm(lease, { recursive: true, force: true })
await pruneImmutableRuntimeBuilds(output, {
maxCompletedBuilds: 2,
minRecentBuilds: 1,
maxBuildAgeMs: 0,
now: () => 100_000
})
expect(await readdir(buildsDir)).not.toContain(leasedVersion)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('keeps a parent-owned lease active after the preparation child exits', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-build-parent-lease-'))
const output = join(root, 'out', 'wsl-watcher')
const version = 'a'.repeat(20)
const build = join(`${output}.builds`, version)
const parentStartToken = await runtimeProcessStartToken(process.pid)
const moduleUrl = new URL('./wsl-watcher-runtime-build-source.mjs', import.meta.url).href
try {
await mkdir(build, { recursive: true })
const childScript = `
import { acquireRuntimeBuildLease } from ${JSON.stringify(moduleUrl)};
await acquireRuntimeBuildLease(${JSON.stringify(build)}, {
createToken: () => 'child-created',
ownerProcessId: ${process.pid},
ownerProcessStartToken: ${JSON.stringify(parentStartToken)}
});
`
await execFileAsync(process.execPath, ['--input-type=module', '--eval', childScript], {
timeout: 10_000,
windowsHide: true
})
await pruneImmutableRuntimeBuilds(output, {
maxCompletedBuilds: 0,
minRecentBuilds: 0,
maxBuildAgeMs: 0
})
expect(await readdir(`${output}.builds`)).toContain(version)
await expect(
acquireRuntimeBuildLease(build, {
ownerProcessId: process.pid,
ownerProcessStartToken: 'wrong-process-start-token'
})
).rejects.toThrow('Cannot verify WSL runtime build lease owner')
await rm(join(`${output}.builds`, `${version}.lease-child-created`), {
recursive: true,
force: true
})
await pruneImmutableRuntimeBuilds(output, {
maxCompletedBuilds: 0,
minRecentBuilds: 0,
maxBuildAgeMs: 0
})
expect(await readdir(`${output}.builds`)).not.toContain(version)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,90 @@
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
recoverAndScavengeRuntimePaths,
replacePublishedRuntimePath,
withRuntimePublicationLock
} from './wsl-watcher-runtime-publication.mjs'
const defaultOperations = { mkdir, readFile, rename, rm, stat, writeFile }
function sha256(contents) {
return createHash('sha256').update(contents).digest('hex')
}
async function matchesHash(filename, expected, operations) {
try {
return sha256(await operations.readFile(filename)) === expected
} catch {
return false
}
}
export async function downloadRuntimeArchive(
arch,
expectedHash,
nodeVersion,
defaultCacheDir,
{
runtimeCacheDir = defaultCacheDir,
fetchRuntime = fetch,
createPublicationId = randomUUID,
beforeCachePublish,
runtimeFileOperations: operationOverrides = {},
cacheLockOptions = {}
} = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
await operations.mkdir(runtimeCacheDir, { recursive: true })
const filename = `node-v${nodeVersion}-linux-${arch}.tar.xz`
const cached = join(runtimeCacheDir, filename)
const lockPath = `${cached}.publish.lock`
const validate = (candidate) => matchesHash(candidate, expectedHash, operations)
await withRuntimePublicationLock(
lockPath,
() => recoverAndScavengeRuntimePaths(cached, validate, { operations }),
{ ...cacheLockOptions, operations }
)
if (await validate(cached)) {
return cached
}
const response = await fetchRuntime(`https://nodejs.org/dist/v${nodeVersion}/${filename}`, {
signal: AbortSignal.timeout(60_000)
})
if (!response.ok) {
throw new Error(`Could not download ${filename}: HTTP ${response.status}`)
}
const contents = Buffer.from(await response.arrayBuffer())
const actualHash = sha256(contents)
if (actualHash !== expectedHash) {
throw new Error(
`Checksum mismatch for ${filename}: expected ${expectedHash}, got ${actualHash}`
)
}
const publicationId = createPublicationId()
const temporary = `${cached}.stage-${publicationId}`
const backup = `${cached}.backup-${publicationId}`
try {
await operations.writeFile(temporary, contents)
await beforeCachePublish?.({ cached, temporary })
await withRuntimePublicationLock(
lockPath,
async () => {
await recoverAndScavengeRuntimePaths(cached, validate, { operations })
// Why: another packager may publish the same verified archive while
// this download is in flight; never replace that known-good result.
if (await validate(cached)) {
return
}
await replacePublishedRuntimePath(temporary, cached, backup, { operations })
},
{ ...cacheLockOptions, operations }
)
return cached
} finally {
await operations.rm(temporary, { force: true })
}
}
+215
View File
@@ -0,0 +1,215 @@
import { randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { promisify } from 'node:util'
const DEFAULT_HEARTBEAT_INTERVAL_MS = 1_000
const DEFAULT_HEARTBEAT_STALE_MS = 10_000
const DEFAULT_RETRY_DELAY_MS = 25
const DEFAULT_TIMEOUT_MS = 30_000
const execFileAsync = promisify(execFile)
const defaultOperations = { mkdir, readFile, rename, rm, stat, writeFile }
function errorCode(error) {
return error?.code
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
async function readOwner(lockPath, operations) {
try {
const parsed = JSON.parse(await operations.readFile(`${lockPath}/owner.json`, 'utf8'))
return typeof parsed?.token === 'string' && Number.isSafeInteger(parsed.pid) ? parsed : null
} catch {
return null
}
}
async function processStartToken(pid) {
try {
if (process.platform === 'linux') {
const contents = await readFile(`/proc/${pid}/stat`, 'utf8')
return contents.slice(contents.lastIndexOf(') ') + 2).split(' ')[19] ?? null
}
if (process.platform === 'win32') {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`
],
{ encoding: 'utf8', timeout: 2_000, windowsHide: true }
)
return stdout.trim() || null
}
const { stdout } = await execFileAsync('ps', ['-o', 'lstart=', '-p', String(pid)], {
encoding: 'utf8',
timeout: 2_000
})
return stdout.trim() || null
} catch {
return null
}
}
let currentProcessStartToken
function getProcessStartToken(pid) {
if (pid !== process.pid) {
return processStartToken(pid)
}
currentProcessStartToken ??= processStartToken(pid)
return currentProcessStartToken
}
async function defaultOwnerIsAlive(owner) {
try {
process.kill(owner.pid, 0)
} catch (error) {
return errorCode(error) === 'EPERM'
}
const actualStart = await getProcessStartToken(owner.pid)
return !actualStart || !owner.processStartToken || actualStart === owner.processStartToken
}
async function heartbeatIsStale(lockPath, operations, now, staleMs) {
try {
const heartbeat = await operations.stat(`${lockPath}/heartbeat`)
return now() - heartbeat.mtimeMs >= staleMs
} catch {
try {
const lock = await operations.stat(lockPath)
return now() - lock.mtimeMs >= staleMs
} catch {
return true
}
}
}
async function quarantineOwnedLock(lockPath, expectedToken, operations, createToken, purpose) {
const quarantine = `${lockPath}.${purpose}-${createToken()}`
try {
await operations.rename(lockPath, quarantine)
} catch (error) {
if (errorCode(error) === 'ENOENT') {
return true
}
return false
}
const movedOwner = await readOwner(quarantine, operations)
if ((movedOwner?.token ?? null) !== expectedToken) {
try {
await operations.rename(quarantine, lockPath)
} catch {
// Preserve the displaced owner for diagnosis rather than deleting it.
}
return false
}
await operations.rm(quarantine, { recursive: true, force: true })
return true
}
async function releaseOwnedLock(lockPath, token, operations, createToken) {
const owner = await readOwner(lockPath, operations)
if (owner?.token !== token) {
return
}
await quarantineOwnedLock(lockPath, token, operations, createToken, 'release')
}
function startHeartbeat(lockPath, owner, operations, intervalMs) {
const tick = async () => {
const current = await readOwner(lockPath, operations)
if (current?.token === owner.token) {
await operations.writeFile(`${lockPath}/heartbeat`, owner.token).catch(() => undefined)
}
}
const timer = setInterval(() => void tick(), intervalMs)
timer.unref?.()
return timer
}
export async function withRuntimePublicationLock(
lockPath,
action,
{
operations: operationOverrides = {},
createToken = randomUUID,
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
heartbeatStaleMs = DEFAULT_HEARTBEAT_STALE_MS,
ownerIsAlive = defaultOwnerIsAlive,
processId = process.pid,
processStartToken: ownerStartToken,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
timeoutMs = DEFAULT_TIMEOUT_MS,
now = Date.now
} = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
const token = createToken()
const owner = {
token,
pid: processId,
processStartToken: ownerStartToken ?? (await getProcessStartToken(processId))
}
const startedAt = now()
let checkedOwnerToken = null
let checkedOwnerAlive = null
while (true) {
try {
await operations.mkdir(lockPath)
try {
await operations.writeFile(`${lockPath}/owner.json`, JSON.stringify(owner))
await operations.writeFile(`${lockPath}/heartbeat`, token)
} catch (error) {
await operations.rm(lockPath, { recursive: true, force: true })
throw error
}
break
} catch (error) {
if (errorCode(error) !== 'EEXIST') {
throw error
}
const incumbent = await readOwner(lockPath, operations)
const stale = await heartbeatIsStale(lockPath, operations, now, heartbeatStaleMs)
let alive = null
if (incumbent) {
if (incumbent.token === checkedOwnerToken && checkedOwnerAlive === true && !stale) {
alive = true
} else {
alive = await ownerIsAlive(incumbent)
checkedOwnerToken = incumbent.token
checkedOwnerAlive = alive
}
}
if (
(alive === false || (alive === null && stale)) &&
(await quarantineOwnedLock(
lockPath,
incumbent?.token ?? null,
operations,
createToken,
'reclaim'
))
) {
continue
}
if (now() - startedAt >= timeoutMs) {
throw new Error(`Timed out waiting for WSL watcher runtime publication lock: ${lockPath}`)
}
await delay(retryDelayMs)
}
}
const heartbeat = startHeartbeat(lockPath, owner, operations, heartbeatIntervalMs)
try {
return await action()
} finally {
clearInterval(heartbeat)
await releaseOwnedLock(lockPath, token, operations, createToken)
}
}
@@ -0,0 +1,87 @@
import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { withRuntimePublicationLock } from './wsl-watcher-runtime-lock.mjs'
async function writeOwner(lockPath, owner) {
await mkdir(lockPath, { recursive: true })
await writeFile(join(lockPath, 'owner.json'), JSON.stringify(owner))
await writeFile(join(lockPath, 'heartbeat'), owner.token)
}
function tokenSequence(...tokens) {
return vi.fn(() => tokens.shift() ?? `token-${tokens.length}`)
}
describe('WSL runtime publication lock', () => {
it('promptly reclaims a dead PID identity even with a fresh heartbeat', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-lock-dead-'))
const lockPath = join(root, 'publish.lock')
try {
await writeOwner(lockPath, {
token: 'dead-owner',
pid: process.pid,
processStartToken: 'reused-pid'
})
const action = vi.fn()
await withRuntimePublicationLock(lockPath, action, {
createToken: tokenSequence('new-owner', 'reclaim', 'release')
})
expect(action).toHaveBeenCalledOnce()
await expect(readFile(lockPath)).rejects.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('never steals a long-held live owner even when its heartbeat is stale', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-lock-live-'))
const lockPath = join(root, 'publish.lock')
try {
const liveOwner = { token: 'live-owner', pid: 42, processStartToken: 'live-start' }
await writeOwner(lockPath, liveOwner)
await utimes(join(lockPath, 'heartbeat'), new Date(0), new Date(0))
await expect(
withRuntimePublicationLock(lockPath, vi.fn(), {
createToken: tokenSequence('waiter'),
heartbeatStaleMs: 1,
ownerIsAlive: async () => true,
timeoutMs: 0
})
).rejects.toThrow('Timed out waiting')
await expect(readFile(join(lockPath, 'owner.json'), 'utf8')).resolves.toBe(
JSON.stringify(liveOwner)
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('does not release a replacement lock created with a new token', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-lock-aba-'))
const lockPath = join(root, 'publish.lock')
const replacement = { token: 'replacement', pid: 77, processStartToken: 'replacement-start' }
try {
await withRuntimePublicationLock(
lockPath,
async () => {
await rm(lockPath, { recursive: true, force: true })
await writeOwner(lockPath, replacement)
},
{
createToken: tokenSequence('original'),
heartbeatIntervalMs: 60_000
}
)
await expect(readFile(join(lockPath, 'owner.json'), 'utf8')).resolves.toBe(
JSON.stringify(replacement)
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,111 @@
import { readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
export { withRuntimePublicationLock } from './wsl-watcher-runtime-lock.mjs'
const DEFAULT_ARTIFACT_MAX_AGE_MS = 24 * 60 * 60 * 1_000
const defaultOperations = { readdir, rename, rm, stat }
function errorCode(error) {
return error?.code
}
async function pathExists(filename, operations) {
try {
await operations.stat(filename)
return true
} catch {
return false
}
}
async function artifactDetails(parent, name, operations) {
const filename = join(parent, name)
try {
return { filename, name, modifiedAt: (await operations.stat(filename)).mtimeMs }
} catch {
return null
}
}
export async function recoverAndScavengeRuntimePaths(
publishedPath,
validate,
{
operations: operationOverrides = {},
maxArtifactAgeMs = DEFAULT_ARTIFACT_MAX_AGE_MS,
now = Date.now
} = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
const parent = dirname(publishedPath)
const base = basename(publishedPath)
let names
try {
names = await operations.readdir(parent)
} catch {
return false
}
const details = (
await Promise.all(
names
.filter((name) => name.startsWith(`${base}.backup-`) || name.startsWith(`${base}.stage-`))
.map((name) => artifactDetails(parent, name, operations))
)
).filter(Boolean)
let recovered = false
if (!(await pathExists(publishedPath, operations))) {
const backups = details
.filter((entry) => entry.name.startsWith(`${base}.backup-`))
.sort((left, right) => right.modifiedAt - left.modifiedAt)
for (const backup of backups) {
if (await validate(backup.filename)) {
await operations.rename(backup.filename, publishedPath)
recovered = true
break
}
}
}
for (const artifact of details) {
if (artifact.filename !== publishedPath && now() - artifact.modifiedAt >= maxArtifactAgeMs) {
await operations.rm(artifact.filename, { recursive: true, force: true })
}
}
return recovered
}
export async function replacePublishedRuntimePath(
stagedPath,
publishedPath,
backupPath,
{ operations: operationOverrides = {} } = {}
) {
const operations = { ...defaultOperations, ...operationOverrides }
let previousMoved = false
try {
try {
await operations.rename(publishedPath, backupPath)
previousMoved = true
} catch (error) {
if (errorCode(error) !== 'ENOENT') {
throw error
}
}
await operations.rename(stagedPath, publishedPath)
} catch (publicationError) {
if (previousMoved) {
try {
await operations.rename(backupPath, publishedPath)
} catch (rollbackError) {
throw new AggregateError(
[publicationError, rollbackError],
`Failed to publish and roll back WSL watcher runtime at ${publishedPath}`
)
}
}
throw publicationError
}
if (previousMoved) {
await operations.rm(backupPath, { recursive: true, force: true })
}
}
@@ -7,6 +7,10 @@ export type WatcherEventBatchState = {
overflowed: boolean
}
export function hasQueuedWatcherChanges(batch: WatcherEventBatchState): boolean {
return batch.overflowed || batch.events.length > 0
}
export function appendWatcherEvents(
batchEvents: WatcherEvent[],
incomingEvents: WatcherEvent[]
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createWslWatcherMock, handleMock } = vi.hoisted(() => ({
createWslWatcherMock: vi.fn(),
handleMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
vi.mock('fs/promises', () => ({ stat: vi.fn() }))
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: createWslWatcherMock }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
import {
closeAllWatchers,
closeLocalWatcherForWorktreePath,
registerFilesystemWatcherHandlers
} from './filesystem-watcher'
import { stat } from 'node:fs/promises'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
const WORKTREE = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
function sender(id: number) {
return { id, isDestroyed: () => false, once: vi.fn(), send: vi.fn() }
}
function root(unsubscribe: () => Promise<void> = async () => undefined) {
return {
subscription: { unsubscribe },
listeners: new Map(),
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }
}
}
describe('pending WSL watcher lifecycle', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
createWslWatcherMock.mockReset()
vi.mocked(stat)
.mockReset()
.mockResolvedValue({ isDirectory: () => true } as never)
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
afterEach(async () => {
vi.useRealTimers()
vi.restoreAllMocks()
await closeAllWatchers()
})
it('aborts the final pending listener and lets a later listener retry', async () => {
let firstSignal!: AbortSignal
createWslWatcherMock
.mockImplementationOnce((_root, _path, deps) => {
firstSignal = deps.signal
return new Promise((_resolve, reject) => {
deps.signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true
})
})
})
.mockResolvedValueOnce(root())
const firstSender = sender(1)
const firstWatch = handlers['fs:watchWorktree'](
{ sender: firstSender },
{ worktreePath: WORKTREE }
) as Promise<unknown>
await vi.waitFor(() => expect(createWslWatcherMock).toHaveBeenCalledOnce())
handlers['fs:unwatchWorktree']({ sender: firstSender }, { worktreePath: WORKTREE })
expect(firstSignal.aborted).toBe(true)
const secondSender = sender(2)
const secondWatch = handlers['fs:watchWorktree'](
{ sender: secondSender },
{ worktreePath: WORKTREE }
) as Promise<unknown>
await Promise.all([firstWatch, secondWatch])
expect(createWslWatcherMock).toHaveBeenCalledTimes(2)
})
it('releases a final-unwatched install after its deferred stat settles', async () => {
let resolveStat!: () => void
vi.mocked(stat)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStat = () => resolve({ isDirectory: () => true } as never)
})
)
.mockResolvedValue({ isDirectory: () => true } as never)
let retrySignal!: AbortSignal
createWslWatcherMock.mockImplementation(async (_root, _path, deps) => {
retrySignal = deps.signal
return root()
})
const abortSpy = vi.spyOn(AbortController.prototype, 'abort')
const firstSender = sender(1)
const firstWatch = handlers['fs:watchWorktree'](
{ sender: firstSender },
{ worktreePath: WORKTREE }
) as Promise<unknown>
await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce())
handlers['fs:unwatchWorktree']({ sender: firstSender }, { worktreePath: WORKTREE })
expect(abortSpy).toHaveBeenCalledOnce()
resolveStat()
await firstWatch
handlers['fs:unwatchWorktree']({ sender: firstSender }, { worktreePath: WORKTREE })
expect(abortSpy).toHaveBeenCalledOnce()
await handlers['fs:watchWorktree']({ sender: sender(2) }, { worktreePath: WORKTREE })
expect(createWslWatcherMock).toHaveBeenCalledOnce()
expect(retrySignal.aborted).toBe(false)
})
it('releases a deferred-stat install across closeAll and allows a clean retry', async () => {
let resolveStat!: () => void
vi.mocked(stat)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStat = () => resolve({ isDirectory: () => true } as never)
})
)
.mockResolvedValue({ isDirectory: () => true } as never)
let retrySignal!: AbortSignal
createWslWatcherMock.mockImplementation(async (_root, _path, deps) => {
retrySignal = deps.signal
return root()
})
const abortSpy = vi.spyOn(AbortController.prototype, 'abort')
const retrySender = sender(1)
const firstWatch = handlers['fs:watchWorktree'](
{ sender: retrySender },
{ worktreePath: WORKTREE }
) as Promise<unknown>
await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce())
const shutdown = closeAllWatchers()
expect(abortSpy).toHaveBeenCalledOnce()
resolveStat()
await Promise.all([firstWatch, shutdown])
await closeAllWatchers()
expect(abortSpy).toHaveBeenCalledOnce()
await handlers['fs:watchWorktree']({ sender: retrySender }, { worktreePath: WORKTREE })
expect(retrySender.once).toHaveBeenCalledTimes(2)
expect(createWslWatcherMock).toHaveBeenCalledOnce()
expect(retrySignal.aborted).toBe(false)
})
it('aborts and awaits an explicitly closed pending watcher', async () => {
let signal!: AbortSignal
createWslWatcherMock.mockImplementation((_root, _path, deps) => {
signal = deps.signal
return new Promise((_resolve, reject) => {
deps.signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })
})
})
const watch = handlers['fs:watchWorktree'](
{ sender: sender(1) },
{ worktreePath: WORKTREE }
) as Promise<unknown>
await vi.waitFor(() => expect(createWslWatcherMock).toHaveBeenCalledOnce())
await closeLocalWatcherForWorktreePath(WORKTREE)
expect(signal.aborted).toBe(true)
await watch
})
it('awaits shutdown cancellation and the resulting unsubscribe drain', async () => {
let signal!: AbortSignal
let resolveInstall!: (value: ReturnType<typeof root>) => void
let resolveUnsubscribe!: () => void
const unsubscribe = vi.fn(() => new Promise<void>((resolve) => (resolveUnsubscribe = resolve)))
createWslWatcherMock.mockImplementation((_root, _path, deps) => {
signal = deps.signal
return new Promise((resolve) => {
resolveInstall = resolve
})
})
const watch = handlers['fs:watchWorktree'](
{ sender: sender(1) },
{ worktreePath: WORKTREE }
) as Promise<unknown>
await vi.waitFor(() => expect(createWslWatcherMock).toHaveBeenCalledOnce())
let closed = false
const shutdown = closeAllWatchers().then(() => (closed = true))
expect(signal.aborted).toBe(true)
expect(closed).toBe(false)
resolveInstall(root(unsubscribe))
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
expect(closed).toBe(false)
resolveUnsubscribe()
await Promise.all([watch, shutdown])
expect(closed).toBe(true)
})
it('pauses sustained listenerless events and flushes them once after reattach', async () => {
vi.useFakeTimers()
const watchedRoot = root()
let schedule!: (rootKey: string, watched: typeof watchedRoot) => void
createWslWatcherMock.mockImplementation(async (_root, _path, deps) => {
schedule = deps.scheduleBatchFlush
return watchedRoot
})
const firstSender = sender(1)
await handlers['fs:watchWorktree']({ sender: firstSender }, { worktreePath: WORKTREE })
handlers['fs:unwatchWorktree']({ sender: firstSender }, { worktreePath: WORKTREE })
for (let index = 0; index < 100; index += 1) {
watchedRoot.batch.events.push({ type: 'update', path: `${WORKTREE}\\README.md` } as never)
schedule(WORKTREE, watchedRoot)
await vi.advanceTimersByTimeAsync(10)
}
expect(firstSender.send).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(1)
const secondSender = sender(2)
await handlers['fs:watchWorktree']({ sender: secondSender }, { worktreePath: WORKTREE })
await vi.advanceTimersByTimeAsync(150)
expect(secondSender.send).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,22 @@
import type { WslWatchEngine } from './filesystem-watcher-wsl-engine'
export function clearWslWatcherTimers(
timers: readonly (ReturnType<typeof setTimeout> | null)[]
): void {
for (const timer of timers) {
if (timer) {
clearTimeout(timer)
}
}
}
export function stopWslWatcherEngines(
startingEngines: Set<WslWatchEngine>,
activeEngine: WslWatchEngine | null
): void {
for (const engine of startingEngines) {
engine.stop()
}
startingEngines.clear()
activeEngine?.stop()
}
+40 -33
View File
@@ -3,20 +3,20 @@ import { StringDecoder } from 'node:string_decoder'
import type { Event as WatcherEvent } from '@parcel/watcher'
import {
subscribeViaWslWatcherHost,
type WslHostStopReason,
type WslHostSubscription
} from './filesystem-watcher-wsl-host-client'
import {
buildSnapshotScript,
diffSnapshots,
parseSnapshotFrame,
SNAPSHOT_END,
SNAPSHOT_START,
MAX_SNAPSHOT_RECORD_CHARS,
parseSnapshotRecord,
type WslSnapshot
} from './filesystem-watcher-wsl-snapshot'
export type WslWatchEngine = {
ready: Promise<void>
stopped: Promise<void>
stopped: Promise<WslHostStopReason>
stop(): void
}
@@ -30,7 +30,7 @@ export type WslEngineContext = {
}
const SNAPSHOT_STARTUP_TIMEOUT_MS = 30_000
const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024
const MAX_SNAPSHOT_ENTRIES = 1_000_000
function createChildEngine(
args: string[],
@@ -50,12 +50,12 @@ function createChildEngine(
const stderrDecoder = new StringDecoder('utf8')
let resolveReady!: () => void
let rejectReady!: (error: Error) => void
let resolveStopped!: () => void
let resolveStopped!: (reason: WslHostStopReason) => void
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const stopped = new Promise<void>((resolve) => {
const stopped = new Promise<WslHostStopReason>((resolve) => {
resolveStopped = resolve
})
const settleReady = (error?: Error): void => {
@@ -75,7 +75,7 @@ function createChildEngine(
return
}
stoppedSettled = true
resolveStopped()
resolveStopped('failure')
}
const stop = (): void => {
if (disposed) {
@@ -134,14 +134,14 @@ export function createWslNativeEngine(context: WslEngineContext): WslWatchEngine
const abortController = new AbortController()
let disposed = false
let stoppedSettled = false
let resolveStopped!: () => void
const stopped = new Promise<void>((resolve) => {
let resolveStopped!: (reason: WslHostStopReason) => void
const stopped = new Promise<WslHostStopReason>((resolve) => {
resolveStopped = resolve
})
const settleStopped = (): void => {
const settleStopped = (reason: WslHostStopReason = 'failure'): void => {
if (!stoppedSettled) {
stoppedSettled = true
resolveStopped()
resolveStopped(reason)
}
}
const ready = subscribeViaWslWatcherHost(
@@ -178,44 +178,51 @@ export function createWslNativeEngine(context: WslEngineContext): WslWatchEngine
}
export function createWslSnapshotEngine(context: WslEngineContext): WslWatchEngine {
let streamBuffer = ''
let recordBuffer = ''
let current: WslSnapshot = new Map()
let previous: WslSnapshot | null = null
const decoder = new StringDecoder('utf8')
const finishSnapshot = (settleReady: (error?: Error) => void): void => {
if (!previous) {
previous = current
settleReady()
} else {
const events = diffSnapshots(previous, current)
previous = current
if (events.length > 0) {
context.onEvents(events)
}
}
current = new Map()
}
return createChildEngine(
['-d', context.distro, '--', 'sh', '-s', '--', context.linuxPath],
buildSnapshotScript(context.ignoreDirs),
SNAPSHOT_STARTUP_TIMEOUT_MS,
context.worktreePath,
(chunk, settleReady, stop) => {
streamBuffer += decoder.write(chunk)
recordBuffer += decoder.write(chunk)
while (true) {
const start = streamBuffer.indexOf(SNAPSHOT_START)
if (start === -1) {
streamBuffer = streamBuffer.slice(-1)
return
}
if (start > 0) {
streamBuffer = streamBuffer.slice(start)
}
const end = streamBuffer.indexOf(SNAPSHOT_END, 1)
const end = recordBuffer.indexOf('\0')
if (end === -1) {
if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) {
if (recordBuffer.length > MAX_SNAPSHOT_RECORD_CHARS) {
context.onOverflow()
stop()
}
return
}
const next = parseSnapshotFrame(streamBuffer.slice(1, end), context.distro)
streamBuffer = streamBuffer.slice(end + 1)
if (!previous) {
previous = next
settleReady()
const rawRecord = recordBuffer.slice(0, end)
recordBuffer = recordBuffer.slice(end + 1)
if (rawRecord.length === 0) {
finishSnapshot(settleReady)
} else {
const events = diffSnapshots(previous, next)
previous = next
if (events.length > 0) {
context.onEvents(events)
const parsed = parseSnapshotRecord(rawRecord, context.distro)
if (!parsed || current.size >= MAX_SNAPSHOT_ENTRIES) {
context.onOverflow()
stop()
return
}
current.set(...parsed)
}
}
}
@@ -0,0 +1,9 @@
import { WslWatcherCompatibilityError } from './filesystem-watcher-wsl-runtime'
export function isPermanentWslNativeFailure(error: unknown): boolean {
return (
error instanceof WslWatcherCompatibilityError ||
(error instanceof Error &&
error.message.startsWith('Packaged managed WSL watcher resource is missing'))
)
}
+151 -154
View File
@@ -1,88 +1,65 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { StringDecoder } from 'node:string_decoder'
import type { Event as WatcherEvent } from '@parcel/watcher'
import { spawn } from 'node:child_process'
import {
acquireWslDistroHost,
canonicalizeWslLinuxPath,
handleWslHostGone,
releaseHostOwnership,
releaseWslDistroHost,
resetWslDistroHostsForTest,
stopHostIfUnused,
type WslDistroHost,
type WslHostStopReason,
type WslHostSubscriptionContext,
type WslHostSubscriptionRecord
} from './filesystem-watcher-wsl-host-lifecycle'
import { wslHostMessageEvents, type WslHostMessage } from './filesystem-watcher-wsl-host-protocol'
import { ensureWslWatcherRuntime } from './filesystem-watcher-wsl-runtime'
export type WslHostSubscriptionContext = {
distro: string
linuxPath: string
ignoreDirs: readonly string[]
onEvents: (events: WatcherEvent[]) => void
onOverflow: () => void
onStopped: () => void
}
export type {
WslHostStopReason,
WslHostSubscriptionContext
} from './filesystem-watcher-wsl-host-lifecycle'
export type WslHostSubscription = {
unsubscribe(): void
}
type PendingResult = {
resolve: () => void
reject: (error: Error) => void
}
export class WslWatcherTopologyError extends Error {}
type SubscriptionRecord = {
id: number
context: WslHostSubscriptionContext
pending: PendingResult | null
pendingTimer: ReturnType<typeof setTimeout> | null
}
type DistroHost = {
distro: string
process: ChildProcessWithoutNullStreams | null
starting: Promise<void> | null
subscriptions: Map<number, SubscriptionRecord>
nextId: number
streamBuffer: string
stdoutDecoder: StringDecoder
stderrDecoder: StringDecoder
stderrTail: string
}
const hosts = new Map<string, DistroHost>()
const STARTUP_TIMEOUT_MS = 30_000
const SUBSCRIBE_TIMEOUT_MS = 30_000
const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024
function createHost(distro: string): DistroHost {
return {
distro,
process: null,
starting: null,
subscriptions: new Map(),
nextId: 1,
streamBuffer: '',
stdoutDecoder: new StringDecoder('utf8'),
stderrDecoder: new StringDecoder('utf8'),
stderrTail: ''
}
}
function settleRecordFailure(host: DistroHost, record: SubscriptionRecord, error: Error): void {
function settleRecordFailure(
host: WslDistroHost,
record: WslHostSubscriptionRecord,
error: Error,
reason: WslHostStopReason = 'failure'
): void {
host.subscriptions.delete(record.id)
if (record.pendingTimer) {
clearTimeout(record.pendingTimer)
record.pendingTimer = null
}
if (reason === 'topology') {
record.context.onOverflow()
}
if (record.pending) {
record.pending.reject(error)
record.pending = null
} else {
record.context.onStopped()
record.context.onStopped(reason)
}
if (host.subscriptions.size === 0) {
hosts.delete(host.distro)
const child = host.process
host.process = null
child?.kill()
} else {
if (!stopHostIfUnused(host)) {
send(host, { op: 'unsubscribe', id: record.id })
}
}
function handleMessage(host: DistroHost, message: WslHostMessage, settleReady: () => void): void {
function handleMessage(
host: WslDistroHost,
message: WslHostMessage,
settleReady: () => void
): void {
if (message.op === 'ready') {
if (message.protocol === 1) {
settleReady()
@@ -112,7 +89,10 @@ function handleMessage(host: DistroHost, message: WslHostMessage, settleReady: (
} else if (message.op === 'subscribe-failed') {
settleRecordFailure(host, record, new Error(String(message.message ?? 'subscribe failed')))
} else if (message.op === 'watch-error') {
settleRecordFailure(host, record, new Error(String(message.message ?? 'watch failed')))
const topology = message.reason === 'topology'
const ErrorType = topology ? WslWatcherTopologyError : Error
const error = new ErrorType(String(message.message ?? 'watch failed'))
settleRecordFailure(host, record, error, topology ? 'topology' : 'failure')
} else if (message.op === 'events') {
const events = wslHostMessageEvents(message, record.context)
if (events.length > 0) {
@@ -121,31 +101,7 @@ function handleMessage(host: DistroHost, message: WslHostMessage, settleReady: (
}
}
function handleHostGone(host: DistroHost, child: ChildProcessWithoutNullStreams): void {
if (host.process !== child) {
return
}
host.process = null
hosts.delete(host.distro)
const error = new Error(
`Managed WSL watcher exited${host.stderrTail.trim() ? `: ${host.stderrTail.trim()}` : ''}`
)
for (const record of host.subscriptions.values()) {
if (record.pendingTimer) {
clearTimeout(record.pendingTimer)
record.pendingTimer = null
}
if (record.pending) {
record.pending.reject(error)
record.pending = null
} else {
record.context.onStopped()
}
}
host.subscriptions.clear()
}
function send(host: DistroHost, message: object): boolean {
function send(host: WslDistroHost, message: object): boolean {
try {
if (!host.process?.stdin.writable) {
return false
@@ -157,8 +113,11 @@ function send(host: DistroHost, message: object): boolean {
}
}
async function startHost(host: DistroHost): Promise<void> {
const runtime = await ensureWslWatcherRuntime(host.distro)
async function startHost(host: WslDistroHost, signal: AbortSignal): Promise<void> {
const runtime = await ensureWslWatcherRuntime(host.distro, signal)
if (host.retired) {
throw new Error('Managed WSL watcher startup was cancelled')
}
const child = spawn(
'wsl.exe',
['-d', host.distro, '--exec', runtime.nodePath, runtime.hostPath],
@@ -209,39 +168,72 @@ async function startHost(host: DistroHost): Promise<void> {
child.stderr.on('data', (chunk: Buffer) => {
host.stderrTail = (host.stderrTail + host.stderrDecoder.write(chunk)).slice(-4096)
})
child.stdin.on('error', (error) => {
settle(error)
child.kill()
handleWslHostGone(host, child)
})
child.stdout.on('error', (error) => {
settle(error)
child.kill()
handleWslHostGone(host, child)
})
// Diagnostics are optional; an unreadable stderr pipe must not crash main.
child.stderr.on('error', () => undefined)
child.once('error', (error) => {
settle(error)
handleHostGone(host, child)
handleWslHostGone(host, child)
})
child.once('close', (code, signal) => {
settle(new Error(`Managed WSL watcher exited before ready (${code ?? signal})`))
handleHostGone(host, child)
handleWslHostGone(host, child)
})
})
}
function ensureHost(distro: string): Promise<DistroHost> {
let host = hosts.get(distro)
if (!host) {
host = createHost(distro)
hosts.set(distro, host)
}
if (!host.starting) {
host.starting = startHost(host)
function ensureHost(host: WslDistroHost): Promise<WslDistroHost> {
if (!host.starting && !host.process) {
const bootstrapAbortController = new AbortController()
host.bootstrapAbortController = bootstrapAbortController
const starting = startHost(host, bootstrapAbortController.signal)
.catch((error) => {
if (host?.process) {
host.process.kill()
}
hosts.delete(distro)
const child = host.process
host.process = null
releaseHostOwnership(host)
child?.kill()
throw error
})
.finally(() => {
if (host) {
if (host.starting === starting) {
host.starting = null
}
if (host.bootstrapAbortController === bootstrapAbortController) {
host.bootstrapAbortController = null
}
})
host.starting = starting
}
return host.starting ? host.starting.then(() => host) : Promise.resolve(host)
}
async function ensureHostAvailable(
host: WslDistroHost,
signal: AbortSignal | undefined
): Promise<WslDistroHost> {
if (!signal) {
return ensureHost(host)
}
let rejectAbort!: (error: Error) => void
const aborted = new Promise<never>((_resolve, reject) => {
rejectAbort = reject
})
const onAbort = (): void => rejectAbort(new Error('Managed WSL watcher subscription cancelled'))
signal.addEventListener('abort', onAbort, { once: true })
try {
return await Promise.race([ensureHost(host), aborted])
} finally {
signal.removeEventListener('abort', onAbort)
}
return host.starting.then(() => host!)
}
export async function subscribeViaWslWatcherHost(
@@ -251,65 +243,70 @@ export async function subscribeViaWslWatcherHost(
if (signal?.aborted) {
throw new Error('Managed WSL watcher subscription cancelled')
}
const host = await ensureHost(context.distro)
if (signal?.aborted) {
if (host.subscriptions.size === 0) {
hosts.delete(host.distro)
const child = host.process
host.process = null
child?.kill()
const normalizedContext = {
...context,
linuxPath: canonicalizeWslLinuxPath(context.linuxPath)
}
let host: WslDistroHost | null = null
try {
// Why: reservations prevent one cancelled caller from killing a host that
// another same-distro caller is still waiting to use.
host = acquireWslDistroHost(normalizedContext.distro)
await ensureHostAvailable(host, signal)
if (signal?.aborted) {
throw new Error('Managed WSL watcher subscription cancelled')
}
throw new Error('Managed WSL watcher subscription cancelled')
}
const id = host.nextId++
const record: SubscriptionRecord = { id, context, pending: null, pendingTimer: null }
const ready = new Promise<void>((resolve, reject) => {
record.pending = { resolve, reject }
})
host.subscriptions.set(id, record)
record.pendingTimer = setTimeout(() => {
settleRecordFailure(host, record, new Error('Timed out subscribing managed WSL watcher'))
}, SUBSCRIBE_TIMEOUT_MS)
signal?.addEventListener(
'abort',
() => {
if (host.subscriptions.has(id)) {
settleRecordFailure(host, record, new Error('Managed WSL watcher subscription cancelled'))
}
},
{ once: true }
)
if (
!send(host, {
op: 'subscribe',
const id = host.nextId++
const record: WslHostSubscriptionRecord = {
id,
dir: context.linuxPath,
ignoreDirs: context.ignoreDirs
context: normalizedContext,
pending: null,
pendingTimer: null
}
const ready = new Promise<void>((resolve, reject) => {
record.pending = { resolve, reject }
})
) {
settleRecordFailure(host, record, new Error('Managed WSL watcher is unavailable'))
}
await ready
return {
unsubscribe: () => {
if (!host.subscriptions.delete(id)) {
return
}
if (host.subscriptions.size === 0) {
hosts.delete(host.distro)
const child = host.process
host.process = null
child?.kill()
} else {
send(host, { op: 'unsubscribe', id })
host.subscriptions.set(id, record)
record.pendingTimer = setTimeout(() => {
settleRecordFailure(host!, record, new Error('Timed out subscribing managed WSL watcher'))
}, SUBSCRIBE_TIMEOUT_MS)
signal?.addEventListener(
'abort',
() => {
if (host?.subscriptions.has(id)) {
settleRecordFailure(host, record, new Error('Managed WSL watcher subscription cancelled'))
}
},
{ once: true }
)
if (
!send(host, {
op: 'subscribe',
id,
dir: normalizedContext.linuxPath,
ignoreDirs: normalizedContext.ignoreDirs
})
) {
settleRecordFailure(host, record, new Error('Managed WSL watcher is unavailable'))
}
await ready
return {
unsubscribe: () => {
if (!host?.subscriptions.delete(id)) {
return
}
if (!stopHostIfUnused(host)) {
send(host, { op: 'unsubscribe', id })
}
}
}
} finally {
if (host) {
releaseWslDistroHost(host)
}
}
}
export function resetWslWatcherHostsForTest(): void {
for (const host of hosts.values()) {
host.process?.kill()
}
hosts.clear()
resetWslDistroHostsForTest()
}
@@ -0,0 +1,296 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { ensureRuntimeMock, spawnMock } = vi.hoisted(() => ({
ensureRuntimeMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
vi.mock('./filesystem-watcher-wsl-runtime', () => ({
ensureWslWatcherRuntime: ensureRuntimeMock
}))
import {
resetWslWatcherHostsForTest,
subscribeViaWslWatcherHost,
WslWatcherTopologyError,
type WslHostSubscriptionContext
} from './filesystem-watcher-wsl-host-client'
class FakeChildProcess extends EventEmitter {
stdin = new PassThrough()
stdout = new PassThrough()
stderr = new PassThrough()
stdinData = ''
kill = vi.fn(() => true)
constructor() {
super()
this.stdin.on('data', (chunk: Buffer) => {
this.stdinData += chunk.toString('utf8')
})
}
}
function context(
linuxPath: string,
onStopped = vi.fn()
): WslHostSubscriptionContext & { onStopped: ReturnType<typeof vi.fn> } {
return {
distro: 'Ubuntu',
linuxPath,
ignoreDirs: ['node_modules'],
onEvents: vi.fn(),
onOverflow: vi.fn(),
onStopped
}
}
function writeMessage(child: FakeChildProcess, message: object): void {
child.stdout.write(`${JSON.stringify(message)}\n`)
}
function subscribeMessages(child: FakeChildProcess): { id: number; dir: string }[] {
return child.stdinData
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as { op: string; id: number; dir: string })
.filter((message) => message.op === 'subscribe')
}
async function readySubscription(
child: FakeChildProcess,
linuxPath: string,
onStopped = vi.fn()
): Promise<Awaited<ReturnType<typeof subscribeViaWslWatcherHost>>> {
const pending = subscribeViaWslWatcherHost(context(linuxPath, onStopped))
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
writeMessage(child, { op: 'ready', protocol: 1 })
await vi.waitFor(() => expect(subscribeMessages(child)).toHaveLength(1))
writeMessage(child, { op: 'subscribed', id: subscribeMessages(child)[0]!.id })
return pending
}
describe('managed WSL host lifecycle', () => {
beforeEach(() => {
spawnMock.mockReset()
ensureRuntimeMock.mockReset().mockResolvedValue({
nodePath: '/home/me/.local/share/orca/wsl-watcher/version/x64/node',
hostPath: '/home/me/.local/share/orca/wsl-watcher/version/x64/host.js'
})
})
afterEach(() => {
resetWslWatcherHostsForTest()
vi.restoreAllMocks()
})
it.each(['stdin', 'stdout'] as const)(
'contains asynchronous child %s errors instead of crashing main',
async (streamName) => {
const child = new FakeChildProcess()
const onStopped = vi.fn()
spawnMock.mockReturnValueOnce(child)
const subscription = await readySubscription(child, '/home/me/repo', onStopped)
expect(() => child[streamName].emit('error', new Error(`${streamName} failed`))).not.toThrow()
expect(child.kill).toHaveBeenCalled()
expect(onStopped).toHaveBeenCalledOnce()
subscription.unsubscribe()
}
)
it('contains diagnostic stderr errors without stopping a healthy host', async () => {
const child = new FakeChildProcess()
const onStopped = vi.fn()
spawnMock.mockReturnValueOnce(child)
const subscription = await readySubscription(child, '/home/me/repo', onStopped)
expect(() => child.stderr.emit('error', new Error('stderr failed'))).not.toThrow()
expect(onStopped).not.toHaveBeenCalled()
subscription.unsubscribe()
})
it('reuses one live host for mixed-case distro subscriptions', async () => {
const child = new FakeChildProcess()
spawnMock.mockReturnValueOnce(child)
const first = await readySubscription(child, '/home/me/repo')
const secondPending = subscribeViaWslWatcherHost({
...context('/home/me/other'),
distro: 'ubuntu'
})
await vi.waitFor(() => expect(subscribeMessages(child)).toHaveLength(2))
writeMessage(child, { op: 'subscribed', id: subscribeMessages(child)[1]!.id })
const second = await secondPending
expect(spawnMock).toHaveBeenCalledOnce()
expect(spawnMock.mock.calls[0]?.[1]).toEqual(expect.arrayContaining(['-d', 'Ubuntu']))
first.unsubscribe()
expect(child.kill).not.toHaveBeenCalled()
second.unsubscribe()
expect(child.kill).toHaveBeenCalledOnce()
})
it('canonicalizes roots without changing Linux path case', async () => {
const child = new FakeChildProcess()
const subscriptionContext = context('/home//me/Repo/./nested/../')
const onEvents = vi.fn()
subscriptionContext.onEvents = onEvents
spawnMock.mockReturnValueOnce(child)
const pending = subscribeViaWslWatcherHost(subscriptionContext)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
writeMessage(child, { op: 'ready', protocol: 1 })
await vi.waitFor(() => expect(subscribeMessages(child)).toHaveLength(1))
const [{ id, dir }] = subscribeMessages(child)
expect(dir).toBe('/home/me/Repo')
writeMessage(child, { op: 'subscribed', id })
const subscription = await pending
writeMessage(child, {
op: 'events',
id,
events: [
{ type: 'update', path: '/home/me/Repo/file.md' },
{ type: 'update', path: '/home/me/repo/wrong-case.md' }
]
})
await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce())
expect(onEvents).toHaveBeenCalledWith([
{ type: 'update', path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\Repo\\file.md' }
])
subscription.unsubscribe()
})
it('does not let a cancelled acquisition kill another startup waiter', async () => {
const child = new FakeChildProcess()
const abortController = new AbortController()
let resolveRuntime!: (runtime: { nodePath: string; hostPath: string }) => void
let bootstrapSignal!: AbortSignal
ensureRuntimeMock.mockReset().mockImplementation((_distro, signal: AbortSignal) => {
bootstrapSignal = signal
return new Promise((resolve) => {
resolveRuntime = resolve
})
})
spawnMock.mockReturnValueOnce(child)
const cancelled = subscribeViaWslWatcherHost(
context('/home/me/cancelled'),
abortController.signal
)
const cancelledResult = cancelled.then(
() => null,
(error: unknown) => error
)
const retained = subscribeViaWslWatcherHost(context('/home/me/retained'))
await vi.waitFor(() => expect(ensureRuntimeMock).toHaveBeenCalledOnce())
abortController.abort()
expect(bootstrapSignal.aborted).toBe(false)
resolveRuntime({ nodePath: '/managed/node', hostPath: '/managed/host.js' })
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
writeMessage(child, { op: 'ready', protocol: 1 })
await vi.waitFor(() => expect(subscribeMessages(child)).toHaveLength(1))
writeMessage(child, { op: 'subscribed', id: subscribeMessages(child)[0]!.id })
await expect(cancelledResult).resolves.toEqual(
expect.objectContaining({ message: expect.stringContaining('cancelled') })
)
const subscription = await retained
expect(child.kill).not.toHaveBeenCalled()
subscription.unsubscribe()
expect(child.kill).toHaveBeenCalledOnce()
})
it('does not spawn an orphan host when every waiter cancels during runtime setup', async () => {
let bootstrapSignal!: AbortSignal
ensureRuntimeMock.mockReset().mockImplementation((_distro, signal: AbortSignal) => {
bootstrapSignal = signal
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('runtime cancelled')), {
once: true
})
})
})
const abortController = new AbortController()
const pending = subscribeViaWslWatcherHost(context('/home/me/repo'), abortController.signal)
const result = pending.catch((error: unknown) => error)
await vi.waitFor(() => expect(ensureRuntimeMock).toHaveBeenCalledOnce())
abortController.abort()
await expect(result).resolves.toEqual(
expect.objectContaining({ message: expect.stringContaining('cancelled') })
)
expect(bootstrapSignal.aborted).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(spawnMock).not.toHaveBeenCalled()
})
it('propagates topology stops without classifying them as host failures', async () => {
const child = new FakeChildProcess()
const subscriptionContext = context('/home/me/repo')
spawnMock.mockReturnValueOnce(child)
const pending = subscribeViaWslWatcherHost(subscriptionContext)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
writeMessage(child, { op: 'ready', protocol: 1 })
await vi.waitFor(() => expect(subscribeMessages(child)).toHaveLength(1))
const [{ id }] = subscribeMessages(child)
writeMessage(child, { op: 'subscribed', id })
await pending
writeMessage(child, {
op: 'watch-error',
id,
reason: 'topology',
message: 'recursive topology changed'
})
await vi.waitFor(() => expect(subscriptionContext.onStopped).toHaveBeenCalledWith('topology'))
expect(subscriptionContext.onOverflow).toHaveBeenCalledOnce()
})
it('marks overflow when topology changes before subscription readiness', async () => {
const child = new FakeChildProcess()
const subscriptionContext = context('/home/me/repo')
spawnMock.mockReturnValueOnce(child)
const pending = subscribeViaWslWatcherHost(subscriptionContext)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
writeMessage(child, { op: 'ready', protocol: 1 })
await vi.waitFor(() => expect(subscribeMessages(child)).toHaveLength(1))
const [{ id }] = subscribeMessages(child)
writeMessage(child, {
op: 'watch-error',
id,
reason: 'topology',
message: 'topology changed while attaching'
})
await expect(pending).rejects.toBeInstanceOf(WslWatcherTopologyError)
expect(subscriptionContext.onOverflow).toHaveBeenCalledOnce()
expect(subscriptionContext.onStopped).not.toHaveBeenCalled()
})
it('ignores a failed old child closing after its replacement is live', async () => {
const oldChild = new FakeChildProcess()
const liveChild = new FakeChildProcess()
spawnMock.mockReturnValueOnce(oldChild).mockReturnValueOnce(liveChild)
const failed = subscribeViaWslWatcherHost(context('/home/me/failed'))
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
oldChild.stdout.write('{invalid-json}\n')
await expect(failed).rejects.toThrow()
const second = await readySubscription(liveChild, '/home/me/second')
oldChild.emit('close', 1, null)
const thirdPending = subscribeViaWslWatcherHost(context('/home/me/third'))
await vi.waitFor(() => expect(subscribeMessages(liveChild)).toHaveLength(2))
writeMessage(liveChild, { op: 'subscribed', id: subscribeMessages(liveChild)[1]!.id })
const third = await thirdPending
expect(spawnMock).toHaveBeenCalledTimes(2)
second.unsubscribe()
third.unsubscribe()
})
})
@@ -0,0 +1,139 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { posix } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import type { Event as WatcherEvent } from '@parcel/watcher'
export type WslHostStopReason = 'failure' | 'topology'
export type WslHostSubscriptionContext = {
distro: string
linuxPath: string
ignoreDirs: readonly string[]
onEvents: (events: WatcherEvent[]) => void
onOverflow: () => void
onStopped: (reason: WslHostStopReason) => void
}
export type WslHostSubscriptionRecord = {
id: number
context: WslHostSubscriptionContext
pending: { resolve: () => void; reject: (error: Error) => void } | null
pendingTimer: ReturnType<typeof setTimeout> | null
}
export type WslDistroHost = {
distro: string
process: ChildProcessWithoutNullStreams | null
starting: Promise<void> | null
bootstrapAbortController: AbortController | null
retired: boolean
pendingAcquisitions: number
subscriptions: Map<number, WslHostSubscriptionRecord>
nextId: number
streamBuffer: string
stdoutDecoder: StringDecoder
stderrDecoder: StringDecoder
stderrTail: string
}
const hosts = new Map<string, WslDistroHost>()
function distroKey(distro: string): string {
return distro.toLowerCase()
}
export function canonicalizeWslLinuxPath(linuxPath: string): string {
const absolute = linuxPath.startsWith('/') ? linuxPath : `/${linuxPath}`
const normalized = posix.normalize(absolute)
return normalized === '/' ? normalized : normalized.replace(/\/+$/, '')
}
function createHost(distro: string): WslDistroHost {
return {
distro,
process: null,
starting: null,
bootstrapAbortController: null,
retired: false,
pendingAcquisitions: 0,
subscriptions: new Map(),
nextId: 1,
streamBuffer: '',
stdoutDecoder: new StringDecoder('utf8'),
stderrDecoder: new StringDecoder('utf8'),
stderrTail: ''
}
}
export function acquireWslDistroHost(distro: string): WslDistroHost {
const key = distroKey(distro)
let host = hosts.get(key)
if (!host) {
host = createHost(distro)
hosts.set(key, host)
}
host.pendingAcquisitions += 1
return host
}
export function releaseHostOwnership(host: WslDistroHost): void {
// Why: a delayed close from a replaced child must not delete its successor.
const key = distroKey(host.distro)
if (hosts.get(key) === host) {
hosts.delete(key)
}
}
export function stopHostIfUnused(host: WslDistroHost): boolean {
if (host.pendingAcquisitions > 0 || host.subscriptions.size > 0) {
return false
}
releaseHostOwnership(host)
host.retired = true
host.bootstrapAbortController?.abort()
const child = host.process
host.process = null
child?.kill()
return true
}
export function releaseWslDistroHost(host: WslDistroHost): void {
host.pendingAcquisitions -= 1
stopHostIfUnused(host)
}
export function handleWslHostGone(
host: WslDistroHost,
child: ChildProcessWithoutNullStreams
): void {
if (host.process !== child) {
return
}
host.process = null
releaseHostOwnership(host)
const error = new Error(
`Managed WSL watcher exited${host.stderrTail.trim() ? `: ${host.stderrTail.trim()}` : ''}`
)
for (const record of host.subscriptions.values()) {
if (record.pendingTimer) {
clearTimeout(record.pendingTimer)
record.pendingTimer = null
}
if (record.pending) {
record.pending.reject(error)
record.pending = null
} else {
record.context.onStopped('failure')
}
}
host.subscriptions.clear()
}
export function resetWslDistroHostsForTest(): void {
for (const host of hosts.values()) {
host.retired = true
host.bootstrapAbortController?.abort()
host.process?.kill()
}
hosts.clear()
}
@@ -6,7 +6,7 @@ export type WslHostMessage =
| { op: 'subscribed'; id?: unknown }
| { op: 'subscribe-failed'; id?: unknown; message?: unknown }
| { op: 'events'; id?: unknown; events?: unknown }
| { op: 'watch-error'; id?: unknown; message?: unknown }
| { op: 'watch-error'; id?: unknown; message?: unknown; reason?: unknown }
| { op: 'unsubscribed'; id?: unknown }
| { op: 'protocol-error'; message?: unknown }
@@ -66,5 +66,5 @@ describe.skipIf(!runLive)('WSL watcher live integration', () => {
} finally {
await root.subscription.unsubscribe()
}
})
}, 15_000)
})
@@ -0,0 +1,361 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { CompatibilityError, ensureRuntimeMock, isDistroRunningMock, spawnMock } = vi.hoisted(
() => ({
CompatibilityError: class WslWatcherCompatibilityError extends Error {},
ensureRuntimeMock: vi.fn(),
isDistroRunningMock: vi.fn(),
spawnMock: vi.fn()
})
)
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
vi.mock('./filesystem-watcher-wsl-runtime', () => ({
ensureWslWatcherRuntime: ensureRuntimeMock,
isWslDistroRunning: isDistroRunningMock,
WslWatcherCompatibilityError: CompatibilityError
}))
import { resetWslWatcherHostsForTest } from './filesystem-watcher-wsl-host-client'
import { SNAPSHOT_END, SNAPSHOT_START } from './filesystem-watcher-wsl-snapshot'
import { createWslWatcher, type WatchedRoot } from './filesystem-watcher-wsl'
const ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
class FakeChildProcess extends EventEmitter {
stdin = new PassThrough()
stdout = new PassThrough()
stderr = new PassThrough()
stdinData = ''
killed = false
kill = vi.fn(() => {
if (!this.killed) {
this.killed = true
this.emit('close', null, 'SIGTERM')
}
return true
})
constructor() {
super()
this.stdin.on('data', (chunk: Buffer) => {
this.stdinData += chunk.toString('utf8')
})
}
}
function writeMessage(child: FakeChildProcess, message: object): void {
child.stdout.write(`${JSON.stringify(message)}\n`)
}
function subscriptionId(child: FakeChildProcess): number | undefined {
for (const line of child.stdinData.split('\n')) {
if (!line) {
continue
}
const message = JSON.parse(line) as { op: string; id?: number }
if (message.op === 'subscribe') {
return message.id
}
}
return undefined
}
async function makeNativeReady(child: FakeChildProcess): Promise<void> {
writeMessage(child, { op: 'ready', protocol: 1 })
await vi.waitFor(() => expect(subscriptionId(child)).toBeTypeOf('number'))
writeMessage(child, { op: 'subscribed', id: subscriptionId(child) })
}
function createRoot(scheduleBatchFlush = vi.fn(), rootPath = ROOT): Promise<WatchedRoot> {
return createWslWatcher(rootPath, rootPath, {
ignoreDirs: ['node_modules', '.git'],
scheduleBatchFlush
})
}
async function waitForSpawnCount(count: number): Promise<void> {
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(count))
}
describe('WSL watcher recovery policy', () => {
beforeEach(() => {
vi.useFakeTimers()
spawnMock.mockReset()
ensureRuntimeMock.mockReset().mockResolvedValue({
nodePath: '/home/me/.local/share/orca/wsl-watcher/version/x64/node',
hostPath: '/home/me/.local/share/orca/wsl-watcher/version/x64/host.js'
})
isDistroRunningMock.mockReset().mockResolvedValue(true)
})
afterEach(() => {
resetWslWatcherHostsForTest()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('reprobes native watching after a transient initial failure', async () => {
ensureRuntimeMock.mockRejectedValueOnce(new Error('temporary install timeout'))
const snapshot = new FakeChildProcess()
const native = new FakeChildProcess()
spawnMock.mockReturnValueOnce(snapshot).mockReturnValueOnce(native)
const rootPending = createRoot()
await waitForSpawnCount(1)
snapshot.stdout.write(`${SNAPSHOT_START}${SNAPSHOT_END}`)
const root = await rootPending
await vi.advanceTimersByTimeAsync(30_000)
await waitForSpawnCount(2)
expect(snapshot.kill).not.toHaveBeenCalled()
await makeNativeReady(native)
await vi.waitFor(() => expect(snapshot.kill).toHaveBeenCalledOnce())
expect(ensureRuntimeMock).toHaveBeenCalledTimes(2)
await root.subscription.unsubscribe()
})
it('uses an overflow barrier for snapshot mutations during native handoff', async () => {
ensureRuntimeMock.mockRejectedValueOnce(new Error('temporary install timeout'))
const snapshot = new FakeChildProcess()
const native = new FakeChildProcess()
const scheduleBatchFlush = vi.fn()
spawnMock.mockReturnValueOnce(snapshot).mockReturnValueOnce(native)
const rootPending = createRoot(scheduleBatchFlush)
await waitForSpawnCount(1)
snapshot.stdout.write(
`${SNAPSHOT_START}regular file\t1\t/home/me/repo/file.md\0${SNAPSHOT_END}`
)
const root = await rootPending
await vi.advanceTimersByTimeAsync(30_000)
await waitForSpawnCount(2)
snapshot.stdout.write(
`${SNAPSHOT_START}regular file\t2\t/home/me/repo/file.md\0${SNAPSHOT_END}`
)
await vi.waitFor(() => expect(root.batch.events).toHaveLength(1))
await makeNativeReady(native)
await vi.waitFor(() => expect(root.batch.overflowed).toBe(true))
expect(root.batch.events).toHaveLength(0)
expect(scheduleBatchFlush).toHaveBeenCalledTimes(2)
await root.subscription.unsubscribe()
})
it('cancels overlapping recovery starts when unsubscribed', async () => {
ensureRuntimeMock.mockRejectedValueOnce(new Error('temporary install timeout'))
const snapshot = new FakeChildProcess()
const pendingNative = new FakeChildProcess()
const replacementSnapshot = new FakeChildProcess()
spawnMock
.mockReturnValueOnce(snapshot)
.mockReturnValueOnce(pendingNative)
.mockReturnValueOnce(replacementSnapshot)
const rootPending = createRoot()
await waitForSpawnCount(1)
snapshot.stdout.write(`${SNAPSHOT_START}${SNAPSHOT_END}`)
const root = await rootPending
await vi.advanceTimersByTimeAsync(30_000)
await waitForSpawnCount(2)
snapshot.emit('close', 1, null)
await vi.advanceTimersByTimeAsync(500)
await waitForSpawnCount(3)
await root.subscription.unsubscribe()
await vi.waitFor(() => expect(pendingNative.kill).toHaveBeenCalledOnce())
expect(replacementSnapshot.kill).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(60_000)
expect(spawnMock).toHaveBeenCalledTimes(3)
})
it('cancels native startup through the owning install signal', async () => {
const pendingNative = new FakeChildProcess()
spawnMock.mockReturnValueOnce(pendingNative)
const abortController = new AbortController()
const pending = createWslWatcher(ROOT, ROOT, {
ignoreDirs: ['node_modules', '.git'],
scheduleBatchFlush: vi.fn(),
signal: abortController.signal
})
await waitForSpawnCount(1)
abortController.abort()
await expect(pending).rejects.toThrow('cancelled')
expect(pendingNative.kill).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(60_000)
expect(spawnMock).toHaveBeenCalledOnce()
})
it('retries native after transient native and snapshot startup failures', async () => {
const initialNative = new FakeChildProcess()
const failedSnapshot = new FakeChildProcess()
const retriedNative = new FakeChildProcess()
ensureRuntimeMock
.mockResolvedValueOnce({ nodePath: '/managed/node', hostPath: '/managed/host.js' })
.mockRejectedValueOnce(new Error('temporary native bootstrap failure'))
.mockResolvedValue({ nodePath: '/managed/node', hostPath: '/managed/host.js' })
spawnMock
.mockReturnValueOnce(initialNative)
.mockReturnValueOnce(failedSnapshot)
.mockReturnValueOnce(retriedNative)
const rootPending = createRoot()
await waitForSpawnCount(1)
await makeNativeReady(initialNative)
const root = await rootPending
initialNative.emit('close', 1, null)
await vi.advanceTimersByTimeAsync(500)
await waitForSpawnCount(2)
expect(spawnMock.mock.calls[1]?.[1]).toContain('sh')
failedSnapshot.emit('close', 1, null)
await vi.advanceTimersByTimeAsync(10_000)
await waitForSpawnCount(3)
expect(spawnMock.mock.calls[2]?.[1]).toContain('--exec')
await makeNativeReady(retriedNative)
await root.subscription.unsubscribe()
})
it('canonicalizes snapshot roots while preserving path case', async () => {
ensureRuntimeMock.mockRejectedValueOnce(
new CompatibilityError('managed WSL watcher requires a glibc distro')
)
const snapshot = new FakeChildProcess()
spawnMock.mockReturnValueOnce(snapshot)
const noncanonical = '\\\\wsl.localhost\\Ubuntu\\home\\me\\Repo\\.\\nested\\..\\'
const rootPending = createRoot(vi.fn(), noncanonical)
await waitForSpawnCount(1)
expect(spawnMock.mock.calls[0]?.[1]).toEqual([
'-d',
'Ubuntu',
'--',
'sh',
'-s',
'--',
'/home/me/Repo'
])
snapshot.stdout.write(`${SNAPSHOT_START}${SNAPSHOT_END}`)
const root = await rootPending
await root.subscription.unsubscribe()
})
it('keeps permanently unsupported distros on the snapshot engine', async () => {
ensureRuntimeMock.mockRejectedValue(
new CompatibilityError('managed WSL watcher requires glibc >= 2.28')
)
const snapshot = new FakeChildProcess()
spawnMock.mockReturnValueOnce(snapshot)
const rootPending = createRoot()
await waitForSpawnCount(1)
snapshot.stdout.write(`${SNAPSHOT_START}${SNAPSHOT_END}`)
const root = await rootPending
await vi.advanceTimersByTimeAsync(120_000)
expect(spawnMock).toHaveBeenCalledOnce()
expect(ensureRuntimeMock).toHaveBeenCalledOnce()
await root.subscription.unsubscribe()
})
it('does not re-probe a packaged build with a missing managed resource', async () => {
ensureRuntimeMock.mockRejectedValue(
new Error('Packaged managed WSL watcher resource is missing at C:\\Orca\\resources')
)
const snapshot = new FakeChildProcess()
spawnMock.mockReturnValueOnce(snapshot)
const rootPending = createRoot()
await waitForSpawnCount(1)
snapshot.stdout.write(`${SNAPSHOT_START}${SNAPSHOT_END}`)
const root = await rootPending
await vi.advanceTimersByTimeAsync(120_000)
expect(spawnMock).toHaveBeenCalledOnce()
expect(ensureRuntimeMock).toHaveBeenCalledOnce()
await root.subscription.unsubscribe()
})
it('circuit-breaks unstable native hosts to snapshots and later reprobes', async () => {
const first = new FakeChildProcess()
const second = new FakeChildProcess()
const third = new FakeChildProcess()
const snapshot = new FakeChildProcess()
const reprobe = new FakeChildProcess()
spawnMock
.mockReturnValueOnce(first)
.mockReturnValueOnce(second)
.mockReturnValueOnce(third)
.mockReturnValueOnce(snapshot)
.mockReturnValueOnce(reprobe)
const rootPending = createRoot()
await waitForSpawnCount(1)
await makeNativeReady(first)
const root = await rootPending
first.emit('close', 1, null)
await vi.advanceTimersByTimeAsync(500)
await waitForSpawnCount(2)
await makeNativeReady(second)
second.emit('close', 1, null)
await vi.advanceTimersByTimeAsync(1_000)
await waitForSpawnCount(3)
await makeNativeReady(third)
third.emit('close', 1, null)
await vi.advanceTimersByTimeAsync(2_000)
await waitForSpawnCount(4)
expect(spawnMock.mock.calls[3]?.[1]).toEqual([
'-d',
'Ubuntu',
'--',
'sh',
'-s',
'--',
'/home/me/repo'
])
snapshot.stdout.write(`${SNAPSHOT_START}${SNAPSHOT_END}`)
await vi.advanceTimersByTimeAsync(30_000)
await waitForSpawnCount(5)
expect(snapshot.kill).not.toHaveBeenCalled()
await makeNativeReady(reprobe)
await vi.waitFor(() => expect(snapshot.kill).toHaveBeenCalledOnce())
await root.subscription.unsubscribe()
})
it('restarts topology changes natively without tripping the circuit breaker', async () => {
const children = Array.from({ length: 4 }, () => new FakeChildProcess())
for (const child of children) {
spawnMock.mockReturnValueOnce(child)
}
const scheduleBatchFlush = vi.fn()
const rootPending = createRoot(scheduleBatchFlush)
await waitForSpawnCount(1)
await makeNativeReady(children[0]!)
const root = await rootPending
for (let index = 0; index < 3; index += 1) {
const child = children[index]!
writeMessage(child, {
op: 'watch-error',
id: subscriptionId(child),
reason: 'topology',
message: 'recursive topology changed'
})
await vi.advanceTimersByTimeAsync(500)
await waitForSpawnCount(index + 2)
expect(spawnMock.mock.calls[index + 1]?.[1]).toContain('--exec')
await makeNativeReady(children[index + 1]!)
}
expect(spawnMock).toHaveBeenCalledTimes(4)
expect(root.batch.overflowed).toBe(true)
expect(scheduleBatchFlush).toHaveBeenCalled()
await root.subscription.unsubscribe()
})
})
@@ -0,0 +1,232 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { copyFileMock, mkdirMock, readFileMock, rmMock, spawnMock } = vi.hoisted(() => ({
copyFileMock: vi.fn(),
mkdirMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ execFile: vi.fn(), spawn: spawnMock }))
vi.mock('node:fs/promises', () => ({
copyFile: copyFileMock,
mkdir: mkdirMock,
readFile: readFileMock,
rm: rmMock
}))
import {
ensureWslWatcherRuntime,
resetWslWatcherRuntimeForTest,
WslWatcherCompatibilityError
} from './filesystem-watcher-wsl-runtime'
class FakeInstaller extends EventEmitter {
stdin = new PassThrough()
stdout = new PassThrough()
stderr = new PassThrough()
kill = vi.fn()
respond(chunks: Buffer[], code = 0, stderr = ''): void {
this.stdin.once('finish', () => {
for (const chunk of chunks) {
this.stdout.write(chunk)
}
if (stderr) {
this.stderr.write(stderr)
}
this.emit('close', code, null)
})
}
}
function createDeferredCopy(): { promise: Promise<void>; resolve: () => void } {
let resolvePromise!: () => void
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
afterEach(() => {
resetWslWatcherRuntimeForTest()
spawnMock.mockReset()
})
beforeEach(() => {
copyFileMock.mockReset().mockResolvedValue(undefined)
mkdirMock.mockReset().mockResolvedValue(undefined)
rmMock.mockReset().mockResolvedValue(undefined)
readFileMock.mockReset().mockResolvedValue(
JSON.stringify({
protocol: 1,
installLayout: 1,
nodeVersion: '24.15.0',
bundleVersion: 'a'.repeat(20)
})
)
})
describe('managed WSL runtime integrity probes', () => {
it('re-probes after every resolved call while sharing only in-flight work', async () => {
const first = new FakeInstaller()
const second = new FakeInstaller()
spawnMock.mockReturnValueOnce(first).mockReturnValueOnce(second)
const output = Buffer.from('ready\n/home/me/node\n/home/me/host.js\n')
first.respond([output])
await expect(ensureWslWatcherRuntime('Ubuntu')).resolves.toMatchObject({
nodePath: '/home/me/node'
})
second.respond([output])
await expect(ensureWslWatcherRuntime('Ubuntu')).resolves.toMatchObject({
hostPath: '/home/me/host.js'
})
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('decodes installer UTF-8 split inside a Unicode path', async () => {
const child = new FakeInstaller()
spawnMock.mockReturnValueOnce(child)
const output = Buffer.from('ready\n/home/测试/node\n/home/测试/host.js\n')
const split = output.indexOf(Buffer.from('测')) + 1
child.respond([output.subarray(0, split), output.subarray(split)])
await expect(ensureWslWatcherRuntime('Ubuntu')).resolves.toEqual({
nodePath: '/home/测试/node',
hostPath: '/home/测试/host.js'
})
})
it('surfaces compatibility exit codes as structured permanent errors', async () => {
const child = new FakeInstaller()
spawnMock.mockReturnValueOnce(child)
child.respond([], 73, 'managed WSL watcher requires glibc >= 2.28')
await expect(ensureWslWatcherRuntime('Legacy')).rejects.toBeInstanceOf(
WslWatcherCompatibilityError
)
})
it.each(['stdout', 'stderr'] as const)(
'contains installer %s stream errors and rejects only once',
async (stream) => {
const child = new FakeInstaller()
spawnMock.mockReturnValueOnce(child)
const pending = ensureWslWatcherRuntime('Broken')
const failure = new Error(`${stream} failed`)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
expect(() => child[stream].emit('error', failure)).not.toThrow()
child.emit('close', 0, null)
expect(() => child[stream].emit('error', new Error('late stream error'))).not.toThrow()
await expect(pending).rejects.toBe(failure)
expect(child.kill).toHaveBeenCalledOnce()
}
)
it('cancels shared installation only after its last owner cancels', async () => {
const child = new FakeInstaller()
spawnMock.mockReturnValueOnce(child)
const firstAbort = new AbortController()
const secondAbort = new AbortController()
const first = ensureWslWatcherRuntime('Shared', firstAbort.signal)
const second = ensureWslWatcherRuntime('Shared', secondAbort.signal)
firstAbort.abort()
await expect(first).rejects.toThrow('cancelled')
expect(child.kill).not.toHaveBeenCalled()
secondAbort.abort()
await expect(second).rejects.toThrow('cancelled')
expect(child.kill).toHaveBeenCalledOnce()
expect(spawnMock).toHaveBeenCalledOnce()
})
it('falls back to the legacy WSL UNC provider and cleans both provider paths', async () => {
const probe = new FakeInstaller()
const install = new FakeInstaller()
spawnMock.mockReturnValueOnce(probe).mockReturnValueOnce(install)
mkdirMock.mockImplementation(async (target: string) => {
if (target.startsWith('\\\\wsl.localhost\\')) {
throw Object.assign(new Error('provider unavailable'), { code: 'ENOENT' })
}
})
probe.respond([Buffer.from('install\nx64\n/home/me\n')])
install.respond([Buffer.from('ready\n/home/me/node\n/home/me/host.js\n')])
await expect(ensureWslWatcherRuntime('Ubuntu')).resolves.toEqual({
nodePath: '/home/me/node',
hostPath: '/home/me/host.js'
})
expect(mkdirMock.mock.calls[0]?.[0]).toMatch(/^\\\\wsl\.localhost\\Ubuntu\\/)
expect(mkdirMock.mock.calls[1]?.[0]).toMatch(/^\\\\wsl\$\\Ubuntu\\/)
expect(rmMock.mock.calls.some(([target]) => String(target).startsWith('\\\\wsl$\\'))).toBe(true)
})
it('settles started UNC copies before cleanup and legacy provider fallback', async () => {
const probe = new FakeInstaller()
const install = new FakeInstaller()
const pendingCopies = [createDeferredCopy(), createDeferredCopy(), createDeferredCopy()]
const providerFailure = Object.assign(new Error('modern provider copy failed'), {
code: 'ENOENT'
})
spawnMock.mockReturnValueOnce(probe).mockReturnValueOnce(install)
copyFileMock.mockImplementation((_source: string, target: string) => {
if (!target.startsWith('\\\\wsl.localhost\\')) {
return Promise.resolve()
}
const modernCopyIndex = copyFileMock.mock.calls.length - 1
return modernCopyIndex === 0
? Promise.reject(providerFailure)
: pendingCopies[modernCopyIndex - 1]?.promise
})
probe.respond([Buffer.from('install\nx64\n/home/me\n')])
install.respond([Buffer.from('ready\n/home/me/node\n/home/me/host.js\n')])
const pendingInstall = ensureWslWatcherRuntime('Ubuntu')
await vi.waitFor(() => expect(copyFileMock.mock.calls.length).toBeGreaterThanOrEqual(4))
await Promise.resolve()
expect(copyFileMock).toHaveBeenCalledTimes(4)
expect(rmMock).not.toHaveBeenCalled()
expect(mkdirMock).toHaveBeenCalledTimes(1)
for (const pendingCopy of pendingCopies) {
pendingCopy.resolve()
}
await expect(pendingInstall).resolves.toEqual({
nodePath: '/home/me/node',
hostPath: '/home/me/host.js'
})
expect(mkdirMock.mock.calls[1]?.[0]).toMatch(/^\\\\wsl\$\\Ubuntu\\/)
expect(
rmMock.mock.calls.some(([target]) => String(target).startsWith('\\\\wsl.localhost\\'))
).toBe(true)
expect(rmMock.mock.calls.some(([target]) => String(target).startsWith('\\\\wsl$\\'))).toBe(true)
})
it('keeps staged transfer data for a retained owner and cleans it after final cancellation', async () => {
const probe = new FakeInstaller()
const install = new FakeInstaller()
spawnMock.mockReturnValueOnce(probe).mockReturnValueOnce(install)
probe.respond([Buffer.from('install\nx64\n/home/me\n')])
const firstAbort = new AbortController()
const secondAbort = new AbortController()
const first = ensureWslWatcherRuntime('Ubuntu', firstAbort.signal)
const second = ensureWslWatcherRuntime('Ubuntu', secondAbort.signal)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
firstAbort.abort()
await expect(first).rejects.toThrow('cancelled')
expect(install.kill).not.toHaveBeenCalled()
expect(rmMock).not.toHaveBeenCalled()
secondAbort.abort()
await expect(second).rejects.toThrow('cancelled')
await vi.waitFor(() => expect(install.kill).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(rmMock).toHaveBeenCalled())
})
})
@@ -0,0 +1,295 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { copyFile, mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
export type WslWatcherManifest = {
protocol: 1
installLayout: 1
nodeVersion: string
bundleVersion: string
}
export type InstalledWslWatcherRuntime = { nodePath: string; hostPath: string }
type InstallerResponse =
| ({ kind: 'ready' } & InstalledWslWatcherRuntime)
| { kind: 'install'; arch: 'x64' | 'arm64'; home: string }
export class WslWatcherCompatibilityError extends Error {}
export const PROBE_BUNDLE_SENTINEL = '-'
export const INSTALL_SCRIPT = String.raw`
set -eu; mode=$1; version=$2; node_version=$3; bundle_linux=$4
case "$(uname -m)" in x86_64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) echo "unsupported WSL architecture: $(uname -m)" >&2; exit 70 ;; esac
glibc_info=$(getconf GNU_LIBC_VERSION 2>/dev/null) || { echo "managed WSL watcher requires a glibc distro" >&2; exit 71; }
set -- $glibc_info; glibc_version=$2
case "$glibc_version" in 0.*|1.*|2.[0-9]|2.1[0-9]|2.2[0-7]) echo "managed WSL watcher requires glibc >= 2.28 (found $glibc_version)" >&2; exit 73 ;; esac
base="$HOME/.local/share/orca/wsl-watcher"; install="$base/$version/$arch"; complete="$install/.complete"
transfer_base="$HOME/.local/share/orca/wsl-watcher-transfer"; mkdir -p "$transfer_base"; find "$transfer_base" -mindepth 1 -maxdepth 1 -type d -mmin +180 -exec rm -rf -- {} \; 2>/dev/null || true
runtime_is_valid() {
test -x "$install/node" && test -f "$install/host.js" && test -f "$install/watcher.node" && test -f "$install/LICENSE" && test -f "$install/parcel-watcher-LICENSE" &&
test "$(cat "$complete" 2>/dev/null || true)" = "$version" && "$install/node" "$install/host.js" --check >/dev/null 2>&1
}
prune_old_versions() {
retained=0; ls -1dt "$base"/[0-9a-f]* 2>/dev/null | while IFS= read -r candidate; do
test -d "$candidate" || continue; name=$(basename "$candidate")
case "$name" in ????????????????????) ;; *) continue ;; esac; case "$name" in *[!0-9a-f]*) continue ;; esac
locked=false; for candidate_lock in "$candidate"/.install-*.lock; do test -d "$candidate_lock" && locked=true; done
if "$locked" || test "$retained" -lt 3; then retained=$((retained + 1)); else rm -rf -- "$candidate"; fi
done
}
print_ready() { touch "$base/$version"; prune_old_versions; printf 'ready\n%s\n%s\n' "$install/node" "$install/host.js"; }
if runtime_is_valid; then print_ready; exit 0; fi
if test "$mode" = probe; then printf 'install\n%s\n%s\n' "$arch" "$HOME"; exit 0; fi
test "$mode" = install
archive="$bundle_linux/$arch/node.tar.xz"; watcher="$bundle_linux/$arch/watcher.node"; host="$bundle_linux/host.js"; watcher_license="$bundle_linux/parcel-watcher-LICENSE"
test -f "$archive" && test -f "$watcher" && test -f "$host" && test -f "$watcher_license"
mkdir -p "$base/$version"
lock="$base/$version/.install-$arch.lock"
lock_owner_live() {
test -r "$lock/owner" && read -r owner owner_start <"$lock/owner" && test -r "/proc/$owner/stat" || return 1
actual_start=$(awk '{print $22}' "/proc/$owner/stat" 2>/dev/null) && test "$actual_start" = "$owner_start"
}
attempt=0; while ! mkdir "$lock" 2>/dev/null; do
if ! lock_owner_live && { test -r "$lock/owner" || find "$lock" -maxdepth 0 -mmin +1 -print -quit | grep -q .; }; then rm -rf -- "$lock"; continue; fi
if runtime_is_valid; then print_ready; exit 0; fi
attempt=$((attempt + 1)); test "$attempt" -lt 1200 || { echo "timed out waiting for WSL watcher install" >&2; exit 72; }
sleep 0.1
done
owner_start=$(awk '{print $22}' "/proc/$$/stat"); printf '%s %s\n' "$$" "$owner_start" >"$lock/owner"
if runtime_is_valid; then rm -rf -- "$lock"; print_ready; exit 0; fi
stage="$base/version-$version-$arch-$$.tmp"; heartbeat_pid=
cleanup() { test -z "$heartbeat_pid" || { kill "$heartbeat_pid" 2>/dev/null || true; wait "$heartbeat_pid" 2>/dev/null || true; }; rm -rf -- "$stage" "$lock"; }
trap cleanup EXIT HUP INT TERM
while touch "$lock/heartbeat" 2>/dev/null; do sleep 2; done & heartbeat_pid=$!
rm -rf -- "$stage" "$install"; mkdir -p "$stage"
tar -xJf "$archive" -C "$stage" --strip-components=2 "node-v$node_version-linux-$arch/bin/node"; tar -xJf "$archive" -C "$stage" --strip-components=1 "node-v$node_version-linux-$arch/LICENSE"
cp "$host" "$stage/host.js"; cp "$watcher" "$stage/watcher.node"; cp "$watcher_license" "$stage/parcel-watcher-LICENSE"
chmod 700 "$stage/node" "$stage/host.js"; "$stage/node" "$stage/host.js" --check >/dev/null; printf '%s\n' "$version" >"$stage/.complete"
mv "$stage" "$install"; runtime_is_valid; print_ready
`
export function createWslRuntimeTransferPaths(
distro: string,
home: string,
version: string,
nonce = `${process.pid}-${randomUUID()}`,
provider: 'wsl.localhost' | 'wsl$' = 'wsl.localhost'
): { linuxPath: string; windowsPath: string } {
const linuxPath = `${home.replace(/\/+$/, '')}/.local/share/orca/wsl-watcher-transfer/${version}-${nonce}`
return {
linuxPath,
windowsPath: `\\\\${provider}\\${distro}${linuxPath.replaceAll('/', '\\')}`
}
}
export function createWslRuntimeCancellationError(): Error {
return new Error('Managed WSL watcher installation cancelled')
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw createWslRuntimeCancellationError()
}
}
export function parseInstallerResponse(output: string): InstallerResponse {
const lines = output
.replaceAll('\0', '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (lines[0] === 'ready' && lines[1]?.startsWith('/') && lines[2]?.startsWith('/')) {
return { kind: 'ready', nodePath: lines[1], hostPath: lines[2] }
}
if (
lines[0] === 'install' &&
(lines[1] === 'x64' || lines[1] === 'arm64') &&
lines[2]?.startsWith('/')
) {
return { kind: 'install', arch: lines[1], home: lines[2] }
}
throw new Error('Managed WSL watcher installer returned an invalid response')
}
async function stageRuntimeBundle(
bundlePath: string,
transferPath: string,
arch: 'x64' | 'arm64',
signal?: AbortSignal
): Promise<void> {
throwIfAborted(signal)
const archTarget = join(transferPath, arch)
await mkdir(archTarget, { recursive: true })
let copyFailure: { error: unknown } | undefined
const copies = [
copyFile(join(bundlePath, 'host.js'), join(transferPath, 'host.js')),
copyFile(
join(bundlePath, 'parcel-watcher-LICENSE'),
join(transferPath, 'parcel-watcher-LICENSE')
),
copyFile(join(bundlePath, arch, 'node.tar.xz'), join(archTarget, 'node.tar.xz')),
copyFile(join(bundlePath, arch, 'watcher.node'), join(archTarget, 'watcher.node'))
]
// Why: Promise.all rejects early, but cleanup or provider fallback must not
// race sibling UNC copies that are still writing into the transfer path.
await Promise.all(
copies.map((copy) =>
copy.catch((error: unknown) => {
copyFailure ??= { error }
})
)
)
if (copyFailure) {
throw copyFailure.error
}
throwIfAborted(signal)
}
async function stageRuntimeBundleWithProviders(
distro: string,
home: string,
version: string,
bundlePath: string,
arch: 'x64' | 'arm64',
signal?: AbortSignal
): Promise<{ linuxPath: string; transferPaths: string[] }> {
const nonce = `${process.pid}-${randomUUID()}`
// Why: older WSL releases may expose only the legacy UNC provider even when
// the modern provider is unavailable for the same running distribution.
const transfers = (['wsl.localhost', 'wsl$'] as const).map((provider) =>
createWslRuntimeTransferPaths(distro, home, version, nonce, provider)
)
let lastError: unknown
for (const transfer of transfers) {
try {
await stageRuntimeBundle(bundlePath, transfer.windowsPath, arch, signal)
return {
linuxPath: transfer.linuxPath,
transferPaths: transfers.map((candidate) => candidate.windowsPath)
}
} catch (error) {
lastError = error
await rm(transfer.windowsPath, { recursive: true, force: true }).catch(() => undefined)
throwIfAborted(signal)
}
}
throw lastError
}
function runInstaller(distro: string, args: string[], signal?: AbortSignal): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => {
if (signal?.aborted) {
rejectPromise(createWslRuntimeCancellationError())
return
}
const child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
let stdout = ''
let stderr = ''
const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')
let settled = false
const settle = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
if (error) {
rejectPromise(error)
} else {
resolvePromise(stdout)
}
}
const fail = (error: Error): void => {
if (settled) {
return
}
settle(error)
child.kill()
}
const onAbort = (): void => fail(createWslRuntimeCancellationError())
const timer = setTimeout(() => {
fail(new Error(`Timed out installing managed WSL watcher for ${distro}`))
}, 120_000)
child.stdout.on('data', (chunk: Buffer) => {
stdout += stdoutDecoder.write(chunk)
if (stdout.length > 64 * 1024) {
fail(new Error('Managed WSL watcher installer produced too much output'))
}
})
child.stderr.on('data', (chunk: Buffer) => {
stderr = (stderr + stderrDecoder.write(chunk)).slice(-4096)
})
child.stdout.on('error', fail)
child.stderr.on('error', fail)
child.on('error', fail)
child.once('close', (code, signal) => {
stdout += stdoutDecoder.end()
stderr = (stderr + stderrDecoder.end()).slice(-4096)
if (code === 0) {
settle()
} else {
const ErrorType =
code === 70 || code === 71 || code === 73 ? WslWatcherCompatibilityError : Error
settle(
new ErrorType(
`Managed WSL watcher install failed (${code ?? signal})${stderr.trim() ? `: ${stderr.trim()}` : ''}`
)
)
}
})
child.stdin.on('error', fail)
signal?.addEventListener('abort', onAbort, { once: true })
child.stdin.end(INSTALL_SCRIPT)
})
}
export async function installWslWatcherRuntime(
distro: string,
bundlePath: string,
manifest: WslWatcherManifest,
signal?: AbortSignal
): Promise<InstalledWslWatcherRuntime> {
throwIfAborted(signal)
// Why: wsl.exe drops trailing empty arguments, so probe uses a non-path sentinel.
const probeArgs = ['probe', manifest.bundleVersion, manifest.nodeVersion, PROBE_BUNDLE_SENTINEL]
const probe = parseInstallerResponse(await runInstaller(distro, probeArgs, signal))
if (probe.kind === 'ready') {
return { nodePath: probe.nodePath, hostPath: probe.hostPath }
}
const transfer = await stageRuntimeBundleWithProviders(
distro,
probe.home,
manifest.bundleVersion,
bundlePath,
probe.arch,
signal
)
try {
const installed = parseInstallerResponse(
await runInstaller(
distro,
['install', manifest.bundleVersion, manifest.nodeVersion, transfer.linuxPath],
signal
)
)
if (installed.kind !== 'ready') {
throw new Error('Managed WSL watcher installation did not finish')
}
return { nodePath: installed.nodePath, hostPath: installed.hostPath }
} finally {
await Promise.all(
transfer.transferPaths.map((transferPath) =>
rm(transferPath, { recursive: true, force: true }).catch(() => undefined)
)
)
}
}
@@ -1,21 +1,120 @@
import { describe, expect, it } from 'vitest'
import { INSTALL_SCRIPT, parseRunningWslDistros } from './filesystem-watcher-wsl-runtime'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
createWslRuntimeTransferPaths,
decodeWslListOutput,
INSTALL_SCRIPT,
PROBE_BUNDLE_SENTINEL,
parseInstallerResponse,
parseRunningWslDistros,
resolveWslWatcherBundlePath
} from './filesystem-watcher-wsl-runtime'
const temporaryRoots: string[] = []
afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
)
})
describe('managed WSL watcher runtime', () => {
it('parses running distro output across WSL encodings', () => {
it('decodes the real UTF-16LE WSL list format without corrupting Unicode distro names', () => {
const names = `Ubuntu\r\n${String.fromCodePoint(0x6d4b, 0x8bd5, 0x53d1, 0x884c, 0x7248)}\r\n`
const encoded = Buffer.from(names, 'utf16le')
expect(decodeWslListOutput(encoded)).toBe(names)
expect(parseRunningWslDistros(encoded)).toEqual(['Ubuntu', '测试发行版'])
expect(parseRunningWslDistros(Buffer.from('* Debian\r\n', 'utf8'))).toEqual(['Debian'])
})
it('retains compatibility with already-decoded ASCII/NUL output', () => {
expect(parseRunningWslDistros('U\0b\0u\0n\0t\0u\0\r\0\n\0* Debian\r\n')).toEqual([
'Ubuntu',
'Debian'
])
})
it('installs a verified architecture-specific host without a user runtime', () => {
expect(INSTALL_SCRIPT).toContain('case "$(uname -m)"')
expect(INSTALL_SCRIPT).toContain('getconf GNU_LIBC_VERSION')
expect(INSTALL_SCRIPT).toContain('node-v$node_version-linux-$arch/bin/node')
expect(INSTALL_SCRIPT).toContain('node-v$node_version-linux-$arch/LICENSE')
expect(INSTALL_SCRIPT).toContain('parcel-watcher-LICENSE')
expect(INSTALL_SCRIPT).toContain('"$stage/node" "$stage/host.js" --check')
expect(INSTALL_SCRIPT).not.toContain('python')
it('requires a complete self-checking install and bounds lock/version retention', () => {
expect(PROBE_BUNDLE_SENTINEL).toBe('-')
expect(INSTALL_SCRIPT).toContain('runtime_is_valid()')
expect(INSTALL_SCRIPT).toContain('test "$(cat "$complete"')
expect(INSTALL_SCRIPT).toContain('= "$version"')
expect(INSTALL_SCRIPT).toContain('"$install/node" "$install/host.js" --check')
expect(INSTALL_SCRIPT).toContain('/proc/$owner/stat')
expect(INSTALL_SCRIPT).toContain('lock/heartbeat')
expect(INSTALL_SCRIPT).toContain('glibc >= 2.28')
expect(INSTALL_SCRIPT).toContain('-mmin +180')
expect(INSTALL_SCRIPT.indexOf('glibc >= 2.28')).toBeLessThan(
INSTALL_SCRIPT.indexOf("printf 'install")
)
expect(INSTALL_SCRIPT).toContain('test "$retained" -lt 3')
expect(INSTALL_SCRIPT).toContain('touch "$base/$version"')
expect(INSTALL_SCRIPT).toContain('set -eu;')
expect(INSTALL_SCRIPT).not.toContain('wslpath')
})
it('parses probe and completed installation responses strictly', () => {
expect(parseInstallerResponse('install\nx64\n/home/me\n')).toEqual({
kind: 'install',
arch: 'x64',
home: '/home/me'
})
expect(parseInstallerResponse('ready\n/home/me/node\n/home/me/host.js\n')).toEqual({
kind: 'ready',
nodePath: '/home/me/node',
hostPath: '/home/me/host.js'
})
expect(() => parseInstallerResponse('ready\nrelative/node\nrelative/host\n')).toThrow()
})
it('stages through the WSL UNC boundary without a mounted Windows drive', () => {
expect(createWslRuntimeTransferPaths('Ubuntu', '/home/me', 'a'.repeat(20), '42')).toEqual({
linuxPath: `/home/me/.local/share/orca/wsl-watcher-transfer/${'a'.repeat(20)}-42`,
windowsPath: `\\\\wsl.localhost\\Ubuntu\\home\\me\\.local\\share\\orca\\wsl-watcher-transfer\\${'a'.repeat(20)}-42`
})
})
it('fails closed for a missing packaged resource but resolves explicit development output', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-runtime-path-'))
temporaryRoots.push(root)
const developmentBundle = join(root, 'out', 'wsl-watcher')
await mkdir(developmentBundle, { recursive: true })
expect(
resolveWslWatcherBundlePath({
packaged: false,
cwd: root,
moduleDir: join(root, 'out', 'main')
})
).toBe(developmentBundle)
const version = 'a'.repeat(20)
const immutableBundle = join(root, 'out', 'wsl-watcher.builds', version)
await mkdir(immutableBundle, { recursive: true })
await writeFile(
join(root, 'out', 'wsl-watcher.current.json'),
JSON.stringify({
protocol: 1,
bundleVersion: version,
relativePath: `wsl-watcher.builds/${version}`
})
)
expect(
resolveWslWatcherBundlePath({
packaged: false,
cwd: root,
moduleDir: join(root, 'out', 'main')
})
).toBe(immutableBundle)
expect(() =>
resolveWslWatcherBundlePath({
packaged: true,
resourcesPath: join(root, 'resources'),
cwd: root,
moduleDir: join(root, 'app.asar', 'out', 'main')
})
).toThrow('Missing packaged WSL watcher resource')
})
})
+180 -173
View File
@@ -1,110 +1,102 @@
import { execFile, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { execFile } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import {
createWslRuntimeCancellationError,
installWslWatcherRuntime,
WslWatcherCompatibilityError,
type InstalledWslWatcherRuntime,
type WslWatcherManifest
} from './filesystem-watcher-wsl-runtime-installer'
type WslWatcherManifest = {
protocol: 1
installLayout: 1
nodeVersion: string
bundleVersion: string
}
export {
createWslRuntimeTransferPaths,
INSTALL_SCRIPT,
parseInstallerResponse,
PROBE_BUNDLE_SENTINEL,
WslWatcherCompatibilityError
} from './filesystem-watcher-wsl-runtime-installer'
export type { InstalledWslWatcherRuntime } from './filesystem-watcher-wsl-runtime-installer'
export type InstalledWslWatcherRuntime = {
nodePath: string
hostPath: string
type BundlePathOptions = {
cwd?: string
moduleDir?: string
packaged?: boolean
resourcesPath?: string
}
const execFileAsync = promisify(execFile)
const installationPromises = new Map<string, Promise<InstalledWslWatcherRuntime>>()
type RuntimeInstallation = {
abortController: AbortController
owners: Set<symbol>
promise: Promise<InstalledWslWatcherRuntime>
settled: boolean
}
const installations = new Map<string, RuntimeInstallation>()
const runningQueries = new Map<string, Promise<boolean>>()
const INSTALL_SCRIPT = String.raw`
set -efu
bundle_windows=$1
version=$2
node_version=$3
case "$(uname -m)" in
x86_64) arch=x64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "unsupported WSL architecture: $(uname -m)" >&2; exit 70 ;;
esac
if ! getconf GNU_LIBC_VERSION >/dev/null 2>&1; then
echo "managed WSL watcher requires a glibc distro" >&2
exit 71
fi
bundle_linux=$(wslpath -u "$bundle_windows")
archive="$bundle_linux/$arch/node.tar.xz"
watcher="$bundle_linux/$arch/watcher.node"
host="$bundle_linux/host.js"
watcher_license="$bundle_linux/parcel-watcher-LICENSE"
test -f "$archive" && test -f "$watcher" && test -f "$host" && test -f "$watcher_license"
base="$HOME/.local/share/orca/wsl-watcher"
install="$base/$version/$arch"
complete="$install/.complete"
if test -x "$install/node" && test -f "$complete"; then
printf '%s\n%s\n' "$install/node" "$install/host.js"
exit 0
fi
mkdir -p "$base/$version"
lock="$base/$version/.install-$arch.lock"
attempt=0
while ! mkdir "$lock" 2>/dev/null; do
if test -r "$lock/pid"; then
owner=$(cat "$lock/pid" 2>/dev/null || true)
if test -n "$owner" && ! kill -0 "$owner" 2>/dev/null; then
rm -rf -- "$lock"
continue
fi
fi
if test -x "$install/node" && test -f "$complete"; then
printf '%s\n%s\n' "$install/node" "$install/host.js"
exit 0
fi
attempt=$((attempt + 1))
test "$attempt" -lt 300 || { echo "timed out waiting for WSL watcher install" >&2; exit 72; }
sleep 0.1
done
printf '%s\n' "$$" >"$lock/pid"
if test -x "$install/node" && test -f "$complete"; then
rm -rf -- "$lock"
printf '%s\n%s\n' "$install/node" "$install/host.js"
exit 0
fi
stage="$base/version-$version-$arch-$$.tmp"
cleanup() { rm -rf -- "$stage" "$lock"; }
trap cleanup EXIT HUP INT TERM
rm -rf -- "$stage"
mkdir -p "$stage"
tar -xJf "$archive" -C "$stage" --strip-components=2 \
"node-v$node_version-linux-$arch/bin/node"
tar -xJf "$archive" -C "$stage" --strip-components=1 \
"node-v$node_version-linux-$arch/LICENSE"
cp "$host" "$stage/host.js"
cp "$watcher" "$stage/watcher.node"
cp "$watcher_license" "$stage/parcel-watcher-LICENSE"
chmod 700 "$stage/node" "$stage/host.js"
"$stage/node" "$stage/host.js" --check >/dev/null
printf '%s\n' "$version" >"$stage/.complete"
rm -rf -- "$install"
mv "$stage" "$install"
printf '%s\n%s\n' "$install/node" "$install/host.js"
`
function resolveDevelopmentBundlePointer(cwd: string): string | null {
const pointerPath = resolve(cwd, 'out', 'wsl-watcher.current.json')
if (!existsSync(pointerPath)) {
return null
}
let pointer: { protocol?: unknown; bundleVersion?: unknown; relativePath?: unknown }
try {
pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
} catch {
throw new WslWatcherCompatibilityError(`Invalid managed WSL watcher pointer at ${pointerPath}`)
}
const version = pointer.bundleVersion
const expectedRelativePath = typeof version === 'string' ? `wsl-watcher.builds/${version}` : null
if (
pointer.protocol !== 1 ||
typeof version !== 'string' ||
!/^[a-f0-9]{20}$/.test(version) ||
!expectedRelativePath ||
pointer.relativePath !== expectedRelativePath
) {
throw new WslWatcherCompatibilityError(`Invalid managed WSL watcher pointer at ${pointerPath}`)
}
const sourcePath = resolve(dirname(pointerPath), ...expectedRelativePath.split('/'))
if (!existsSync(sourcePath)) {
throw new WslWatcherCompatibilityError(
`Missing immutable managed WSL watcher source at ${sourcePath}`
)
}
return sourcePath
}
function resolveBundlePath(): string {
if (process.resourcesPath) {
const packaged = join(process.resourcesPath, 'wsl-watcher')
if (existsSync(packaged)) {
return packaged
export function resolveWslWatcherBundlePath(options: BundlePathOptions = {}): string {
const resourcesPath = options.resourcesPath ?? process.resourcesPath
const moduleDir = options.moduleDir ?? __dirname
const packaged =
options.packaged ??
Boolean(
(resourcesPath && existsSync(join(resourcesPath, 'app.asar'))) ||
moduleDir.includes('app.asar')
)
const packagedPath = resourcesPath ? join(resourcesPath, 'wsl-watcher') : ''
if (packaged) {
if (packagedPath && existsSync(packagedPath)) {
return packagedPath
}
throw new WslWatcherCompatibilityError(
`Missing packaged WSL watcher resource at ${packagedPath}`
)
}
const development = resolve(process.cwd(), 'out', 'wsl-watcher')
if (existsSync(development)) {
return development
const cwd = options.cwd ?? process.cwd()
const pointedSource = resolveDevelopmentBundlePointer(cwd)
if (pointedSource) {
return pointedSource
}
// In development __dirname is out/main and the prepared bundle is out/wsl-watcher.
return resolve(__dirname, '..', 'wsl-watcher')
const fromCwd = resolve(cwd, 'out', 'wsl-watcher')
if (existsSync(fromCwd)) {
return fromCwd
}
return resolve(moduleDir, '..', 'wsl-watcher')
}
function validateManifest(value: unknown): WslWatcherManifest {
@@ -117,100 +109,122 @@ function validateManifest(value: unknown): WslWatcherManifest {
typeof manifest.bundleVersion !== 'string' ||
!/^[a-f0-9]{20}$/.test(manifest.bundleVersion)
) {
throw new Error('Invalid managed WSL watcher manifest')
throw new WslWatcherCompatibilityError('Invalid managed WSL watcher manifest')
}
return manifest as WslWatcherManifest
}
async function installRuntime(distro: string): Promise<InstalledWslWatcherRuntime> {
const bundlePath = resolveBundlePath()
async function installRuntime(
distro: string,
signal?: AbortSignal
): Promise<InstalledWslWatcherRuntime> {
const bundlePath = resolveWslWatcherBundlePath()
const manifest = validateManifest(
JSON.parse(await readFile(join(bundlePath, 'manifest.json'), 'utf8')) as unknown
JSON.parse(await readFile(join(bundlePath, 'manifest.json'), 'utf8'))
)
const stdout = await runInstaller(distro, [
bundlePath,
manifest.bundleVersion,
manifest.nodeVersion
])
const [nodePath, hostPath] = stdout
.replaceAll(String.fromCharCode(0), '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (!nodePath?.startsWith('/') || !hostPath?.startsWith('/')) {
throw new Error('Managed WSL watcher installer returned invalid paths')
}
return { nodePath, hostPath }
return installWslWatcherRuntime(distro, bundlePath, manifest, signal)
}
function runInstaller(distro: string, args: string[]): Promise<string> {
function createInstallation(distro: string): RuntimeInstallation {
const abortController = new AbortController()
const installation: RuntimeInstallation = {
abortController,
owners: new Set(),
promise: Promise.resolve(null as never),
settled: false
}
installation.promise = installRuntime(distro, abortController.signal)
const release = (): void => {
installation.settled = true
if (installations.get(distro) === installation) {
installations.delete(distro)
}
}
void installation.promise.then(release, release)
installations.set(distro, installation)
return installation
}
function attachInstallationOwner(
installation: RuntimeInstallation,
signal?: AbortSignal
): Promise<InstalledWslWatcherRuntime> {
const owner = Symbol('runtime-installation-owner')
installation.owners.add(owner)
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
let stdout = ''
let stderr = ''
let settled = false
const settle = (error?: Error): void => {
const release = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
if (error) {
rejectPromise(error)
} else {
resolvePromise(stdout)
signal?.removeEventListener('abort', onAbort)
installation.owners.delete(owner)
if (!installation.settled && installation.owners.size === 0) {
// Why: installation work is shared per distro; one cancelled watcher
// must not stop the process while another watcher still owns it.
installation.abortController.abort()
}
}
const timer = setTimeout(() => {
child.kill()
settle(new Error(`Timed out installing managed WSL watcher for ${distro}`))
}, 120_000)
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
if (stdout.length > 64 * 1024) {
child.kill()
settle(new Error('Managed WSL watcher installer produced too much output'))
const onAbort = (): void => {
release()
rejectPromise(createWslRuntimeCancellationError())
}
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
return
}
void installation.promise.then(
(runtime) => {
if (!settled) {
release()
resolvePromise(runtime)
}
},
(error: unknown) => {
if (!settled) {
release()
rejectPromise(error)
}
}
})
child.stderr.on('data', (chunk: Buffer) => {
stderr = (stderr + chunk.toString('utf8')).slice(-4096)
})
child.once('error', (error) => settle(error))
child.once('close', (code, signal) => {
if (code === 0) {
settle()
} else {
settle(
new Error(
`Managed WSL watcher install failed (${code ?? signal})${stderr.trim() ? `: ${stderr.trim()}` : ''}`
)
)
}
})
child.stdin.once('error', (error) => settle(error))
child.stdin.end(INSTALL_SCRIPT)
)
})
}
export function ensureWslWatcherRuntime(distro: string): Promise<InstalledWslWatcherRuntime> {
let pending = installationPromises.get(distro)
if (!pending) {
pending = installRuntime(distro).catch((error) => {
installationPromises.delete(distro)
throw error
})
installationPromises.set(distro, pending)
export function ensureWslWatcherRuntime(
distro: string,
signal?: AbortSignal
): Promise<InstalledWslWatcherRuntime> {
if (signal?.aborted) {
return Promise.reject(createWslRuntimeCancellationError())
}
return pending
let installation = installations.get(distro)
if (installation?.abortController.signal.aborted) {
installations.delete(distro)
installation = undefined
}
installation ??= createInstallation(distro)
return attachInstallationOwner(installation, signal)
}
export function decodeWslListOutput(output: string | Buffer): string {
return typeof output === 'string'
? output
: output.toString(output.includes(0) ? 'utf16le' : 'utf8').replace(/^\uFEFF/, '')
}
export function parseRunningWslDistros(output: string | Buffer): string[] {
return decodeWslListOutput(output)
.replaceAll('\0', '')
.split(/\r?\n/)
.map((line) => line.trim().replace(/^\*\s*/, ''))
.filter(Boolean)
}
async function queryWslDistroRunning(distro: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('wsl.exe', ['--list', '--running', '--quiet'], {
encoding: 'utf8',
timeout: 5_000,
maxBuffer: 64 * 1024
})
@@ -222,14 +236,6 @@ async function queryWslDistroRunning(distro: string): Promise<boolean> {
}
}
export function parseRunningWslDistros(output: string): string[] {
return output
.replaceAll(String.fromCharCode(0), '')
.split(/\r?\n/)
.map((line) => line.trim().replace(/^\*\s*/, ''))
.filter(Boolean)
}
export function isWslDistroRunning(distro: string): Promise<boolean> {
let pending = runningQueries.get(distro)
if (!pending) {
@@ -240,8 +246,9 @@ export function isWslDistroRunning(distro: string): Promise<boolean> {
}
export function resetWslWatcherRuntimeForTest(): void {
installationPromises.clear()
for (const installation of installations.values()) {
installation.abortController.abort()
}
installations.clear()
runningQueries.clear()
}
export { INSTALL_SCRIPT }
@@ -0,0 +1,62 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
vi.mock('node:child_process', () => ({ execFile: vi.fn(), spawn: spawnMock }))
import { createWslSnapshotEngine } from './filesystem-watcher-wsl-engine'
import { MAX_SNAPSHOT_RECORD_CHARS } from './filesystem-watcher-wsl-snapshot'
class FakeSnapshotChild extends EventEmitter {
stdin = new PassThrough()
stdout = new PassThrough()
stderr = new PassThrough()
kill = vi.fn()
}
afterEach(() => spawnMock.mockReset())
function createEngine(child: FakeSnapshotChild, onOverflow = vi.fn()) {
spawnMock.mockReturnValueOnce(child)
return {
engine: createWslSnapshotEngine({
distro: 'Ubuntu',
linuxPath: '/repo',
worktreePath: '\\\\wsl.localhost\\Ubuntu\\repo',
ignoreDirs: [],
onEvents: vi.fn(),
onOverflow
}),
onOverflow
}
}
describe('WSL snapshot stream parser', () => {
it('incrementally accepts frames larger than 10 MiB with chunked Unicode', async () => {
const child = new FakeSnapshotChild()
const { engine, onOverflow } = createEngine(child)
const unicode = '测试🌊'
const records = `${Array.from(
{ length: 13_000 },
(_, index) => `f\t1.0\t/repo/${unicode}-${index}-${'x'.repeat(780)}\0`
).join('')}\0`
const bytes = Buffer.from(records)
for (let offset = 0; offset < bytes.length; offset += 997) {
child.stdout.write(bytes.subarray(offset, offset + 997))
}
await expect(engine.ready).resolves.toBeUndefined()
expect(bytes.length).toBeGreaterThan(10 * 1024 * 1024)
expect(onOverflow).not.toHaveBeenCalled()
engine.stop()
})
it('bounds a single unterminated record', () => {
const child = new FakeSnapshotChild()
const { engine, onOverflow } = createEngine(child)
child.stdout.write('x'.repeat(MAX_SNAPSHOT_RECORD_CHARS + 1))
expect(onOverflow).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledOnce()
engine.stop()
})
})
@@ -0,0 +1,132 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawn } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { buildSnapshotScript, parseSnapshotFrame } from './filesystem-watcher-wsl-snapshot'
const temporaryRoots: string[] = []
async function runSnapshotScript(
root: string,
statScript: string
): Promise<{ code: number | null; stderr: string; stdout: Buffer }> {
const bin = join(root, '.test-bin')
await mkdir(bin)
const statPath = join(bin, 'stat')
await writeFile(statPath, statScript)
await chmod(statPath, 0o700)
const child = spawn('sh', ['-s', '--', root], {
env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ''}` },
stdio: ['pipe', 'pipe', 'pipe']
})
const stdout: Buffer[] = []
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
child.stdin.end(buildSnapshotScript(['.test-bin'], { forcePortable: true, once: true }))
const code = await new Promise<number | null>((resolve) => child.once('close', resolve))
return { code, stderr, stdout: Buffer.concat(stdout) }
}
afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
)
})
describe('WSL snapshot compatibility', () => {
it('selects GNU find fast-path and a batched BusyBox stat fallback', () => {
const script = buildSnapshotScript(['node_modules'])
expect(script).toContain('-printf')
expect(script).toContain('snapshot_find=portable')
expect(script).toContain('stat -c "%F\t%y"')
expect(script).toContain('-exec sh -c')
expect(script).toContain('stat -c "%F\t%y" -- "$@"')
expect(script).toContain("-name 'node_modules'")
expect(script).not.toContain('-maxdepth')
})
it('parses portable stat metadata and preserves unusual path characters', () => {
const frame =
'regular file\t2026-07-09 12:34:56.123456789 +0000\t/home/me/repo/a\nname.md\0' +
'directory\t2026-07-09 12:34:57.000000000 +0000\t/home/me/repo/docs\0'
expect([...parseSnapshotFrame(frame, 'Ubuntu').values()]).toEqual([
{
type: 'regular file',
mtime: '2026-07-09 12:34:56.123456789 +0000',
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\a\nname.md'
},
{
type: 'directory',
mtime: '2026-07-09 12:34:57.000000000 +0000',
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\docs'
}
])
})
it('does not reserve legal filename control bytes as frame markers', () => {
const path = '/home/me/repo/control-\x1e-\x1f.md'
const snapshot = parseSnapshotFrame(`f\t1.0\t${path}\0`, 'Ubuntu')
expect(
snapshot.get('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\control-\x1e-\x1f.md')
).toBeDefined()
})
it.skipIf(process.platform === 'win32')(
'executes the forced portable path and skips files that vanish during a failed stat batch',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-portable-churn-'))
temporaryRoots.push(root)
const vanished = join(root, 'vanished.md')
const retained = join(root, 'retained.md')
await writeFile(vanished, 'gone')
await writeFile(retained, 'kept')
const marker = join(root, '.batch-failed')
const result = await runSnapshotScript(
root,
`#!/bin/sh
format=$2; shift 3
if test "$#" -gt 1 && test ! -e ${JSON.stringify(marker)}; then
: >${JSON.stringify(marker)}; rm -f ${JSON.stringify(vanished)}; exit 1
fi
for path do
test -e "$path" || { echo "stat: $path: No such file or directory" >&2; exit 1; }
printf 'regular file\\t1\\n'
done
`
)
expect(result.code).toBe(0)
expect(result.stderr).toBe('')
const frame = result.stdout.toString('utf8')
expect(frame).toContain(`regular file\t1\t${retained}\0`)
expect(frame).not.toContain(vanished)
}
)
it.skipIf(process.platform === 'win32')(
'surfaces real portable stat failures instead of treating them as churn',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-portable-error-'))
temporaryRoots.push(root)
const denied = join(root, 'denied.md')
await writeFile(denied, 'still exists')
const result = await runSnapshotScript(
root,
`#!/bin/sh
shift 3
if test "$#" -gt 1; then exit 1; fi
if test "$1" = ${JSON.stringify(denied)}; then echo 'permission denied' >&2; exit 1; fi
printf 'directory\\t1\\n'
`
)
expect(result.code).toBe(75)
expect(result.stderr).toContain('permission denied')
}
)
})
+73 -27
View File
@@ -2,10 +2,13 @@ import type { Event as WatcherEvent } from '@parcel/watcher'
const POLL_INTERVAL_SECONDS = 5
const SAFE_IGNORE_NAME = /^[A-Za-z0-9_.-]+$/
export const SNAPSHOT_START = '\x1e'
export const SNAPSHOT_END = '\x1f'
// NUL cannot occur in a Linux filename. An empty NUL record therefore closes
// a frame without reserving legal filename bytes as stream sentinels.
export const SNAPSHOT_START = ''
export const SNAPSHOT_END = '\0'
export const MAX_SNAPSHOT_RECORD_CHARS = 64 * 1024
type WslSnapshotEntry = {
export type WslSnapshotEntry = {
path: string
type: string
mtime: string
@@ -34,45 +37,88 @@ function buildPruneExpression(ignoreDirs: readonly string[]): string {
return `\\( -type d \\( ${names} \\) -prune \\) -o`
}
export function buildSnapshotScript(ignoreDirs: readonly string[]): string {
// Why: files can vanish after find batches them. Retry a failed batch one path
// at a time so churn is skipped without hiding errors for paths that still exist.
const BUSYBOX_STAT_BATCH_SCRIPT =
'tmp="${TMPDIR:-/tmp}/orca-wsl-snapshot-$$"; error="$tmp.error"; ' +
'trap \'rm -f "$tmp" "$error"\' EXIT; ' +
'if stat -c "%F\t%y" -- "$@" >"$tmp" 2>"$error"; then ' +
'exec 3<"$tmp"; for path do IFS= read -r metadata <&3 || exit 75; ' +
'printf "%s\\t%s\\0" "$metadata" "$path"; done; ' +
'else for path do if metadata=$(stat -c "%F\t%y" -- "$path" 2>"$error"); then ' +
'printf "%s\\t%s\\0" "$metadata" "$path"; ' +
'elif test ! -e "$path" && test ! -L "$path"; then :; ' +
'else cat "$error" >&2; exit 75; fi; done; fi'
function quoteShellArgument(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
type SnapshotScriptOptions = {
forcePortable?: boolean
once?: boolean
}
export function buildSnapshotScript(
ignoreDirs: readonly string[],
options: SnapshotScriptOptions = {}
): string {
const prune = buildPruneExpression(ignoreDirs)
const findMode = options.forcePortable
? 'snapshot_find=portable'
: 'if find "$root" -printf "" -quit >/dev/null 2>&1; then snapshot_find=gnu; else snapshot_find=portable; fi'
const repeat = options.once ? ' exit 0' : ` sleep ${POLL_INTERVAL_SECONDS} || exit 0`
return [
'set -efu',
'root=$1',
'command -v find >/dev/null',
'command -v stat >/dev/null',
'stat -c "%F\t%y" -- "$root" >/dev/null',
findMode,
'while :; do',
" printf '\\036'",
' if [ -d "$root" ]; then',
// Why: this is the compatibility path; correctness still requires every
// nested open file while high-churn directories are pruned during traversal.
` find "$root" -mindepth 1 ${prune} -printf '%y\\t%T@\\t%p\\0' 2>/dev/null || true`,
// Why: GNU find is the zero-fork fast path. BusyBox batches paths through
// one stat process per ARG_MAX group while retaining NUL-safe filenames.
` if [ "$snapshot_find" = gnu ]; then find "$root" -mindepth 1 ${prune} -printf '%y\\t%T@\\t%p\\0';`,
` else find "$root" -mindepth 1 ${prune} -exec sh -c ${quoteShellArgument(BUSYBOX_STAT_BATCH_SCRIPT)} sh {} +; fi`,
' fi',
" printf '\\037'",
` sleep ${POLL_INTERVAL_SECONDS} || exit 0`,
" printf '\\0'",
repeat,
'done'
].join('\n')
}
export function parseSnapshotRecord(
rawEntry: string,
distro: string
): [path: string, entry: WslSnapshotEntry] | null {
if (!rawEntry || rawEntry.length > MAX_SNAPSHOT_RECORD_CHARS) {
return null
}
const firstTab = rawEntry.indexOf('\t')
const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1)
if (firstTab <= 0 || secondTab <= firstTab + 1) {
return null
}
const linuxPath = rawEntry.slice(secondTab + 1)
if (!linuxPath.startsWith('/')) {
return null
}
const entry: WslSnapshotEntry = {
type: rawEntry.slice(0, firstTab),
mtime: rawEntry.slice(firstTab + 1, secondTab),
path: toWslUncPath(linuxPath, distro)
}
return [entry.path, entry]
}
export function parseSnapshotFrame(frame: string, distro: string): WslSnapshot {
const snapshot: WslSnapshot = new Map()
for (const rawEntry of frame.split('\0')) {
if (!rawEntry) {
continue
const parsed = parseSnapshotRecord(rawEntry, distro)
if (parsed) {
snapshot.set(...parsed)
}
const firstTab = rawEntry.indexOf('\t')
const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1)
if (firstTab <= 0 || secondTab <= firstTab + 1) {
continue
}
const linuxPath = rawEntry.slice(secondTab + 1)
if (!linuxPath.startsWith('/')) {
continue
}
const entry: WslSnapshotEntry = {
type: rawEntry.slice(0, firstTab),
mtime: rawEntry.slice(firstTab + 1, secondTab),
path: toWslUncPath(linuxPath, distro)
}
snapshot.set(entry.path, entry)
}
return snapshot
}
+2 -1
View File
@@ -11,7 +11,8 @@ const { ensureRuntimeMock, isDistroRunningMock, spawnMock } = vi.hoisted(() => (
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
vi.mock('./filesystem-watcher-wsl-runtime', () => ({
ensureWslWatcherRuntime: ensureRuntimeMock,
isWslDistroRunning: isDistroRunningMock
isWslDistroRunning: isDistroRunningMock,
WslWatcherCompatibilityError: class WslWatcherCompatibilityError extends Error {}
}))
import { createWslWatcher } from './filesystem-watcher-wsl'
+187 -42
View File
@@ -2,6 +2,9 @@
import type { WebContents } from 'electron'
import type { Event as WatcherEvent } from '@parcel/watcher'
import { queueWatcherEvents } from './filesystem-watcher-event-batch'
import { clearWslWatcherTimers, stopWslWatcherEngines } from './filesystem-watcher-wsl-disposal'
import { isPermanentWslNativeFailure } from './filesystem-watcher-wsl-failure-policy'
import { canonicalizeWslLinuxPath } from './filesystem-watcher-wsl-host-lifecycle'
import {
createWslNativeEngine,
createWslSnapshotEngine,
@@ -31,11 +34,16 @@ export type WatchedRoot = {
export type WslWatcherDeps = {
ignoreDirs: string[]
scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void
signal?: AbortSignal
}
const RESTART_DELAYS_MS = [500, 1_000, 2_000, 5_000, 10_000] as const
const STABLE_ENGINE_MS = 30_000
const STOPPED_DISTRO_RECHECK_MS = 5_000
const STABLE_ENGINE_MS = 30_000,
STOPPED_DISTRO_RECHECK_MS = 5_000
const NATIVE_REPROBE_DELAY_MS = 30_000
const UNSTABLE_NATIVE_EXIT_LIMIT = 3
type StartedEngine = { engine: WslWatchEngine; kind: 'native' | 'snapshot' }
function markOverflow(root: WatchedRoot): void {
if (root.batch.timer) {
@@ -62,17 +70,50 @@ export async function createWslWatcher(
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }
}
let activeEngine: WslWatchEngine | null = null
let startingEngine: WslWatchEngine | null = null
let activeEngineKind: StartedEngine['kind'] | null = null
const startingEngines = new Set<WslWatchEngine>()
let startGeneration = 0
let disposed = false
let nativeUnavailable = false
let nativePermanentlyUnavailable = false
let nativeRetryNeeded = false
let unstableNativeExits = 0
let restartAttempt = 0
let restartTimer: ReturnType<typeof setTimeout> | null = null
let stabilityTimer: ReturnType<typeof setTimeout> | null = null
let nativeRetryTimer: ReturnType<typeof setTimeout> | null = null
const maximumRestartDelay = RESTART_DELAYS_MS.at(-1) ?? 10_000
const dispose = (): void => {
if (!disposed) {
disposed = true
clearWslWatcherTimers([restartTimer, stabilityTimer, nativeRetryTimer])
startGeneration += 1
stopWslWatcherEngines(startingEngines, activeEngine)
activeEngine = null
activeEngineKind = null
}
}
const beginEngineStart = (engine: WslWatchEngine): number => {
const generation = ++startGeneration
// Why: a newer recovery attempt supersedes every older pending attempt;
// stopping all of them prevents late readiness from leaking a watcher.
for (const pending of startingEngines) {
pending.stop()
}
startingEngines.add(engine)
return generation
}
const finishEngineStart = (engine: WslWatchEngine): void => {
startingEngines.delete(engine)
}
const isCurrentStart = (generation: number): boolean =>
!disposed && generation === startGeneration
const context: WslEngineContext = {
distro: wsl.distro,
linuxPath: wsl.linuxPath,
linuxPath: canonicalizeWslLinuxPath(wsl.linuxPath),
worktreePath,
ignoreDirs: deps.ignoreDirs,
onEvents: (events) => {
@@ -85,37 +126,79 @@ export async function createWslWatcher(
}
}
const startEngine = async (): Promise<WslWatchEngine> => {
const recordNativeFailure = (error: unknown): void => {
nativeUnavailable = true
if (isPermanentWslNativeFailure(error)) {
nativePermanentlyUnavailable = true
nativeRetryNeeded = false
} else {
nativeRetryNeeded = true
}
}
const startEngine = async (): Promise<StartedEngine | null> => {
if (disposed) {
return null
}
if (!nativeUnavailable) {
const native = createWslNativeEngine(context)
startingEngine = native
const generation = beginEngineStart(native)
try {
await native.ready
startingEngine = null
return native
} catch (error) {
startingEngine = null
nativeUnavailable = true
native.stop()
if (disposed) {
throw error
finishEngineStart(native)
if (!isCurrentStart(generation)) {
native.stop()
return null
}
return { engine: native, kind: 'native' }
} catch (error) {
finishEngineStart(native)
native.stop()
if (!isCurrentStart(generation)) {
return null
}
recordNativeFailure(error)
}
}
if (disposed) {
return null
}
const snapshot = createWslSnapshotEngine(context)
startingEngine = snapshot
const generation = beginEngineStart(snapshot)
try {
await snapshot.ready
} catch (error) {
finishEngineStart(snapshot)
snapshot.stop()
if (!isCurrentStart(generation)) {
return null
}
// Why: without a live snapshot, transient native failures must be
// retried by the bounded restart loop instead of pinning an unwatched root.
if (nativeRetryNeeded && !nativePermanentlyUnavailable) {
nativeUnavailable = false
}
throw error
} finally {
startingEngine = null
}
return snapshot
finishEngineStart(snapshot)
if (!isCurrentStart(generation)) {
snapshot.stop()
return null
}
return { engine: snapshot, kind: 'snapshot' }
}
let installEngine: () => Promise<void>
let reprobeNative: () => Promise<void>
const scheduleNativeReprobe = (): void => {
if (disposed || nativePermanentlyUnavailable || !nativeRetryNeeded || nativeRetryTimer) {
return
}
nativeRetryTimer = setTimeout(() => {
nativeRetryTimer = null
void reprobeNative()
}, NATIVE_REPROBE_DELAY_MS)
}
const scheduleRestart = (delay: number): void => {
restartTimer = setTimeout(() => {
restartTimer = null
@@ -138,51 +221,113 @@ export async function createWslWatcher(
}, delay)
}
installEngine = async (): Promise<void> => {
const engine = await startEngine()
if (disposed) {
engine.stop()
return
const activateEngine = (started: StartedEngine): void => {
const { engine } = started
if (stabilityTimer) {
clearTimeout(stabilityTimer)
}
activeEngine = engine
activeEngineKind = started.kind
stabilityTimer = setTimeout(() => {
restartAttempt = 0
if (started.kind === 'native') {
unstableNativeExits = 0
}
}, STABLE_ENGINE_MS)
void engine.stopped.then(() => {
if (started.kind === 'snapshot') {
scheduleNativeReprobe()
}
void engine.stopped.then((reason) => {
if (disposed || activeEngine !== engine) {
return
}
activeEngine = null
activeEngineKind = null
if (stabilityTimer) {
clearTimeout(stabilityTimer)
}
if (started.kind === 'native' && reason !== 'topology') {
unstableNativeExits += 1
if (unstableNativeExits >= UNSTABLE_NATIVE_EXIT_LIMIT) {
// Why: a host that repeatedly starts and dies is less reliable than
// the scanner; circuit-break temporarily, then probe native again.
nativeUnavailable = true
nativeRetryNeeded = true
}
}
markOverflow(root)
deps.scheduleBatchFlush(rootKey, root)
const delay =
RESTART_DELAYS_MS.at(Math.min(restartAttempt, RESTART_DELAYS_MS.length - 1)) ??
maximumRestartDelay
restartAttempt += 1
RESTART_DELAYS_MS.at(
reason === 'topology' ? 0 : Math.min(restartAttempt, RESTART_DELAYS_MS.length - 1)
) ?? maximumRestartDelay
if (reason !== 'topology') {
restartAttempt += 1
}
// Why: WSL shutdowns and transient distro failures must not permanently
// orphan an active renderer subscription.
scheduleRestart(delay)
})
}
await installEngine()
root.subscription = {
unsubscribe: async () => {
disposed = true
if (restartTimer) {
clearTimeout(restartTimer)
}
if (stabilityTimer) {
clearTimeout(stabilityTimer)
}
startingEngine?.stop()
startingEngine = null
activeEngine?.stop()
activeEngine = null
reprobeNative = async (): Promise<void> => {
const snapshot = activeEngineKind === 'snapshot' ? activeEngine : null
if (disposed || !snapshot) {
return
}
const native = createWslNativeEngine(context)
const generation = beginEngineStart(native)
try {
await native.ready
} catch (error) {
finishEngineStart(native)
native.stop()
if (!isCurrentStart(generation)) {
return
}
recordNativeFailure(error)
scheduleNativeReprobe()
return
}
finishEngineStart(native)
if (!isCurrentStart(generation) || activeEngine !== snapshot) {
native.stop()
return
}
// Why: keep snapshots live until native is ready so recovery probes never
// create an unwatched gap after a transient or unstable native failure.
nativeUnavailable = false
nativeRetryNeeded = false
// Why: snapshot output and native readiness have no shared sequence token;
// one conservative refresh closes the handoff race without duplicate paths.
markOverflow(root)
deps.scheduleBatchFlush(rootKey, root)
activateEngine({ engine: native, kind: 'native' })
snapshot.stop()
}
installEngine = async (): Promise<void> => {
const started = await startEngine()
if (!started) {
return
}
activateEngine(started)
}
const onAbort = (): void => dispose()
if (deps.signal?.aborted) {
dispose()
throw new Error('WSL watcher startup was cancelled')
}
deps.signal?.addEventListener('abort', onAbort, { once: true })
try {
await installEngine()
if (disposed) {
throw new Error('WSL watcher startup was cancelled')
}
root.subscription = { unsubscribe: async () => dispose() }
return root
} finally {
deps.signal?.removeEventListener('abort', onAbort)
}
return root
}
+39
View File
@@ -30,6 +30,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { stat } from 'node:fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
import { createWslWatcher } from './filesystem-watcher-wsl'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
@@ -43,6 +44,7 @@ describe('registerFilesystemWatcherHandlers', () => {
getSshFilesystemProviderMock.mockReset()
vi.mocked(stat).mockReset()
vi.mocked(subscribeParcelWatcher).mockReset()
vi.mocked(createWslWatcher).mockReset()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
@@ -79,6 +81,43 @@ describe('registerFilesystemWatcherHandlers', () => {
await closeAllWatchers()
})
it('retains WSL attach-window overflow until the renderer listener is installed', async () => {
vi.useFakeTimers()
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
const root = {
subscription: { unsubscribe: vi.fn(async () => undefined) },
listeners: new Map(),
batch: { events: [], overflowed: true, timer: null, firstEventAt: 0 }
}
let resolveWatcher!: (value: typeof root) => void
vi.mocked(createWslWatcher).mockImplementation((rootKey, _worktreePath, deps) => {
deps.scheduleBatchFlush(rootKey, root as never)
return new Promise((resolve) => {
resolveWatcher = resolve
}) as never
})
const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
const pending = handlers['fs:watchWorktree']({ sender }, { worktreePath }) as Promise<unknown>
await vi.waitFor(() => expect(createWslWatcher).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(500)
expect(root.batch.overflowed).toBe(true)
expect(sender.send).not.toHaveBeenCalled()
resolveWatcher(root)
await pending
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath,
events: [{ kind: 'overflow', absolutePath: worktreePath }]
})
})
it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+109 -35
View File
@@ -12,7 +12,11 @@ import { isWslPath } from '../wsl'
import { createWslWatcher } from './filesystem-watcher-wsl'
import type { WatchedRoot } from './filesystem-watcher-wsl'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch'
import {
hasQueuedWatcherChanges,
MAX_BATCHED_WATCHER_EVENTS,
queueWatcherEvents
} from './filesystem-watcher-event-batch'
import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process'
// Why: high-churn directories are suppressed at the native watcher level so
// events never leave the OS/daemon. This list is separate from the File
@@ -70,6 +74,7 @@ const pendingTeardowns = new Map<string, ReturnType<typeof setTimeout>>()
// cleanup can start it before app shutdown, so will-quit must still await it.
const pendingLocalUnsubscribes = new Set<Promise<void>>()
type LocalWatcherInstallToken = {
abortController: AbortController
cancelled: boolean
listeners: Map<number, WebContents>
}
@@ -78,6 +83,7 @@ type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
// the same root must share one install or later resolves can orphan listeners.
const inFlightLocalInstalls = new Map<string, LocalWatcherInstallToken>()
const pendingLocalInstallPromises = new Map<string, Promise<LocalWatcherInstallResult>>()
let localWatcherLifecycleGeneration = 0
function addInFlightLocalInstallListener(
token: LocalWatcherInstallToken,
@@ -91,12 +97,17 @@ function addInFlightLocalInstallListener(
registerSenderCleanup(sender)
}
function cancelLocalInstallIfUnused(token: LocalWatcherInstallToken): void {
if (token.listeners.size === 0) {
token.cancelled = true
token.abortController.abort()
}
}
function cleanupInFlightLocalInstallsForSender(senderId: number): void {
for (const token of inFlightLocalInstalls.values()) {
token.listeners.delete(senderId)
if (token.listeners.size === 0) {
token.cancelled = true
}
cancelLocalInstallIfUnused(token)
}
}
@@ -206,13 +217,19 @@ function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
}
async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
root.batch.timer = null
// Why: WSL can report attach-window changes before the pending renderer
// listener is installed; retain them until installation completes.
if (root.listeners.size === 0) {
root.batch.firstEventAt = 0
return
}
const overflowed = root.batch.overflowed
const rawEvents = root.batch.events.splice(0)
root.batch.overflowed = false
root.batch.timer = null
root.batch.firstEventAt = 0
if ((rawEvents.length === 0 && !overflowed) || root.listeners.size === 0) {
if (rawEvents.length === 0 && !overflowed) {
return
}
@@ -254,6 +271,14 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
}
function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
if (root.listeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
root.batch.firstEventAt = 0
return
}
const now = Date.now()
if (root.batch.firstEventAt === 0) {
@@ -418,10 +443,14 @@ function addLocalWatchListener(rootKey: string, sender: WebContents): void {
}
root.listeners.set(sender.id, sender)
registerSenderCleanup(sender)
if (!root.batch.timer && hasQueuedWatcherChanges(root.batch)) {
scheduleBatchFlush(rootKey, root)
}
}
async function subscribe(worktreePath: string, sender: WebContents): Promise<void> {
const rootKey = normalizeRootPath(worktreePath)
const lifecycleGeneration = localWatcherLifecycleGeneration
if (sender.isDestroyed()) {
return
}
@@ -449,6 +478,22 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise<voi
const pendingInstall = pendingLocalInstallPromises.get(rootKey)
if (pendingInstall) {
const inFlight = inFlightLocalInstalls.get(rootKey)
if (inFlight?.abortController.signal.aborted) {
inFlight.listeners.set(sender.id, sender)
registerSenderCleanup(sender)
await pendingInstall.catch(() => undefined)
if (pendingLocalInstallPromises.get(rootKey) === pendingInstall) {
pendingLocalInstallPromises.delete(rootKey)
}
if (
lifecycleGeneration === localWatcherLifecycleGeneration &&
!sender.isDestroyed() &&
inFlight.listeners.has(sender.id)
) {
await subscribe(worktreePath, sender)
}
return
}
if (inFlight) {
// Why: an unwatch may cancel an install while another renderer is still
// awaiting the same root; a new live listener should keep it alive.
@@ -466,7 +511,11 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise<voi
return
}
const cancelToken: LocalWatcherInstallToken = { cancelled: false, listeners: new Map() }
const cancelToken: LocalWatcherInstallToken = {
abortController: new AbortController(),
cancelled: false,
listeners: new Map()
}
inFlightLocalInstalls.set(rootKey, cancelToken)
addInFlightLocalInstallListener(cancelToken, sender)
const installPromise = doInstallLocalWatcher(rootKey, worktreePath, cancelToken)
@@ -486,33 +535,47 @@ async function doInstallLocalWatcher(
cancelToken: LocalWatcherInstallToken
): Promise<LocalWatcherInstallResult> {
let root: WatchedRoot
// Why: stat is part of the published install too; every exit after token
// registration must release that exact token so shutdown and retries are clean.
try {
const s = await stat(rootKey)
if (!s.isDirectory()) {
console.warn(`[filesystem-watcher] not a directory: ${rootKey}`)
try {
const s = await stat(rootKey)
if (!s.isDirectory()) {
console.warn(`[filesystem-watcher] not a directory: ${rootKey}`)
rememberUnwatchableRoot(rootKey)
return 'unavailable'
}
} catch {
if (cancelToken.abortController.signal.aborted) {
return 'cancelled'
}
console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`)
rememberUnwatchableRoot(rootKey)
return 'unavailable'
}
} catch {
console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`)
rememberUnwatchableRoot(rootKey)
return 'unavailable'
}
if (cancelToken.abortController.signal.aborted) {
return 'cancelled'
}
try {
// Why: WSL paths are watched inside Linux so native kernel events remain
// reliable; native host paths use the platform watcher process.
root = isWslPath(worktreePath)
? await createWslWatcher(rootKey, worktreePath, {
ignoreDirs: WATCHER_IGNORE_DIRS,
scheduleBatchFlush
})
: await createWatcher(rootKey, rootKey)
} catch {
// Why: createWatcher / createWslWatcher already logged the error. Swallow
// it here so the renderer's watchWorktree call resolves without crashing.
rememberUnwatchableRoot(rootKey)
return 'unavailable'
try {
// Why: WSL paths are watched inside Linux so native kernel events remain
// reliable; native host paths use the platform watcher process.
root = isWslPath(worktreePath)
? await createWslWatcher(rootKey, worktreePath, {
ignoreDirs: WATCHER_IGNORE_DIRS,
scheduleBatchFlush,
signal: cancelToken.abortController.signal
})
: await createWatcher(rootKey, rootKey)
} catch {
if (cancelToken.abortController.signal.aborted) {
return 'cancelled'
}
// Why: createWatcher / createWslWatcher already logged the error. Swallow
// it here so the renderer's watchWorktree call resolves without crashing.
rememberUnwatchableRoot(rootKey)
return 'unavailable'
}
} finally {
if (inFlightLocalInstalls.get(rootKey) === cancelToken) {
inFlightLocalInstalls.delete(rootKey)
@@ -525,13 +588,18 @@ async function doInstallLocalWatcher(
if (cancelToken.cancelled || liveListeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
void trackLocalUnsubscribe(rootKey, root)
root.batch.firstEventAt = 0
await trackLocalUnsubscribe(rootKey, root)
return 'cancelled'
}
root.listeners = liveListeners
watchedRoots.set(rootKey, root)
if (hasQueuedWatcherChanges(root.batch)) {
scheduleBatchFlush(rootKey, root)
}
for (const listener of liveListeners.values()) {
registerSenderCleanup(listener)
}
@@ -543,7 +611,7 @@ function unsubscribe(worktreePath: string, senderId: number): void {
const inFlight = inFlightLocalInstalls.get(rootKey)
if (inFlight) {
inFlight.listeners.delete(senderId)
inFlight.cancelled = inFlight.listeners.size === 0
cancelLocalInstallIfUnused(inFlight)
}
const root = watchedRoots.get(rootKey)
@@ -558,7 +626,9 @@ function unsubscribe(worktreePath: string, senderId: number): void {
if (root.listeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
root.batch.firstEventAt = 0
// Why: duplicate renderer cleanup can call unwatch more than once for a
// root; keep one tracked grace timer instead of leaking overwritten timers.
@@ -594,7 +664,7 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
// Why: Windows keeps watched directories locked; deletion must be able to
// cancel an in-flight subscription before Git tries to remove the tree.
inFlight.listeners.clear()
inFlight.cancelled = true
cancelLocalInstallIfUnused(inFlight)
}
await pendingLocalInstallPromises.get(rootKey)?.catch(() => undefined)
@@ -903,6 +973,7 @@ export function registerFilesystemWatcherHandlers(): void {
/** Tear down all watchers on app shutdown. */
export async function closeAllWatchers(): Promise<void> {
localWatcherLifecycleGeneration += 1
senderCleanupRegistered.clear()
unwatchableRoots.clear()
@@ -924,16 +995,19 @@ export async function closeAllWatchers(): Promise<void> {
}
for (const token of inFlightLocalInstalls.values()) {
token.listeners.clear()
token.cancelled = true
cancelLocalInstallIfUnused(token)
}
const shutdownWork: Promise<unknown>[] = Array.from(pendingLocalInstallPromises.values())
for (const [rootKey, root] of watchedRoots) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
await trackLocalUnsubscribe(rootKey, root)
root.batch.firstEventAt = 0
shutdownWork.push(trackLocalUnsubscribe(rootKey, root))
}
watchedRoots.clear()
await Promise.allSettled(shutdownWork)
await Promise.allSettled(Array.from(pendingLocalUnsubscribes))
// Why: with every local subscription released, drop the forked watcher
// process outright — process death frees any remaining native handles
@@ -0,0 +1,37 @@
export type WatcherBindingWatchdog = {
watch<Result>(operation: string, pending: Promise<Result>): Promise<Result>
}
export function createWatcherBindingWatchdog(
exit: (code: number) => void,
timeoutMs: number
): WatcherBindingWatchdog {
let recycling = false
const recycleHost = (operation: string): void => {
if (recycling) {
return
}
recycling = true
process.stderr.write(`[wsl-watcher-host] native ${operation} did not settle\n`)
exit(4)
}
return {
watch: async <Result>(operation: string, pending: Promise<Result>): Promise<Result> => {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
recycleHost(operation)
reject(new Error(`Native watcher ${operation} timed out`))
}, timeoutMs)
timer.unref?.()
})
try {
return await Promise.race([pending, timeout])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
}
}
@@ -0,0 +1,156 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createWatcherBindingWatchdog } from './wsl-watcher-host-binding-watchdog'
import { startWslWatcherCanary } from './wsl-watcher-host-canary'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
async function expectRealExitCleansCanary(stall: 'subscribe' | 'unsubscribe'): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'orca-canary-exit-test-'))
const runner = join(root, 'runner.cjs')
const report = join(root, 'canary-dir.txt')
const canaryModule = resolve('src/main/ipc/wsl-watcher-host-canary.ts').replaceAll('\\', '/')
const watchdogModule = resolve('src/main/ipc/wsl-watcher-host-binding-watchdog.ts').replaceAll(
'\\',
'/'
)
let canaryDir = ''
try {
await build({
bundle: true,
format: 'cjs',
outfile: runner,
platform: 'node',
stdin: {
contents: `
import { writeFileSync } from 'node:fs'
import { startWslWatcherCanary } from ${JSON.stringify(canaryModule)}
import { createWatcherBindingWatchdog } from ${JSON.stringify(watchdogModule)}
const keepAlive = setInterval(() => undefined, 1_000)
const stall = ${JSON.stringify(stall)}
const binding = {
subscribe: (dir) => {
writeFileSync(process.argv[2], dir)
return stall === 'subscribe' ? new Promise(() => undefined) : Promise.resolve()
},
unsubscribe: () =>
stall === 'unsubscribe' ? new Promise(() => undefined) : Promise.resolve()
}
void startWslWatcherCanary(
binding,
createWatcherBindingWatchdog((code) => process.exit(code), 10)
)
.then((canary) => stall === 'unsubscribe' ? canary.close() : undefined)
.finally(() => clearInterval(keepAlive))
`,
loader: 'ts',
resolveDir: process.cwd(),
sourcefile: 'canary-exit-runner.ts'
}
})
const child = spawn(process.execPath, [runner, report], { stdio: 'ignore' })
const exitCode = await new Promise<number | null>((resolvePromise, rejectPromise) => {
child.once('error', rejectPromise)
child.once('exit', resolvePromise)
})
canaryDir = await readFile(report, 'utf8')
expect(exitCode).toBe(4)
expect(existsSync(canaryDir)).toBe(false)
} finally {
if (canaryDir) {
await rm(canaryDir, { recursive: true, force: true })
}
await rm(root, { recursive: true, force: true })
}
}
describe('WSL watcher host canary', () => {
it('removes its directory when the real host exits before subscribe settles', async () => {
await expectRealExitCleansCanary('subscribe')
})
it('removes its directory when real host exit interrupts unsubscribe', async () => {
await expectRealExitCleansCanary('unsubscribe')
})
it('recycles the host and removes its directory when subscribe never settles', async () => {
vi.useFakeTimers()
const exit = vi.fn()
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
let canaryDir = ''
const binding = {
subscribe: vi.fn((dir: string) => {
canaryDir = dir
return new Promise<void>(() => undefined)
}),
unsubscribe: vi.fn(async () => undefined)
}
const started = startWslWatcherCanary(binding, createWatcherBindingWatchdog(exit, 10))
const rejected = expect(started).rejects.toThrow('Native watcher canary subscribe timed out')
await vi.advanceTimersByTimeAsync(10)
expect(exit).toHaveBeenCalledWith(4)
await rejected
expect(canaryDir).not.toBe('')
expect(existsSync(canaryDir)).toBe(false)
})
it('recycles the host and cleans all resources when unsubscribe never settles', async () => {
vi.useFakeTimers()
const exit = vi.fn()
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
let canaryDir = ''
const binding = {
subscribe: vi.fn(async (dir: string) => {
canaryDir = dir
}),
unsubscribe: vi.fn(() => new Promise<void>(() => undefined))
}
const listenerCount = process.listenerCount('exit')
const canary = await startWslWatcherCanary(binding, createWatcherBindingWatchdog(exit, 10))
const closing = canary.close()
const rejected = expect(closing).rejects.toThrow('Native watcher canary unsubscribe timed out')
await vi.advanceTimersByTimeAsync(10)
expect(exit).toHaveBeenCalledWith(4)
await rejected
expect(existsSync(canaryDir)).toBe(false)
expect(process.listenerCount('exit')).toBe(listenerCount)
expect(vi.getTimerCount()).toBe(0)
})
it('unsubscribes and cleans resources on an ordinary close', async () => {
vi.useFakeTimers()
let canaryDir = ''
let callback: ((error: Error | null) => void) | undefined
const binding = {
subscribe: vi.fn(async (dir: string, subscribedCallback: (error: Error | null) => void) => {
canaryDir = dir
callback = subscribedCallback
}),
unsubscribe: vi.fn(async () => undefined)
}
const listenerCount = process.listenerCount('exit')
const canary = await startWslWatcherCanary(binding, createWatcherBindingWatchdog(vi.fn(), 10))
await canary.close()
await canary.close()
expect(binding.unsubscribe).toHaveBeenCalledOnce()
expect(binding.unsubscribe).toHaveBeenCalledWith(canaryDir, callback, {})
expect(existsSync(canaryDir)).toBe(false)
expect(process.listenerCount('exit')).toBe(listenerCount)
expect(vi.getTimerCount()).toBe(0)
})
})
+93
View File
@@ -0,0 +1,93 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { WatcherBindingWatchdog } from './wsl-watcher-host-binding-watchdog'
type CanaryWatcherBinding = {
subscribe(dir: string, callback: (error: Error | null) => void, options: object): Promise<void>
unsubscribe(dir: string, callback: (error: Error | null) => void, options: object): Promise<void>
}
export type WslWatcherCanary = { close(): Promise<void> }
const CANARY_INTERVAL_MS = 10_000
const CANARY_EVENT_TIMEOUT_MS = 5_000
const CANARY_MAX_MISSES = 2
export async function startWslWatcherCanary(
binding: CanaryWatcherBinding,
watchdog: WatcherBindingWatchdog
): Promise<WslWatcherCanary> {
const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-watcher-'))
let lastEventAt = 0
const callback = (error: Error | null): void => {
if (!error) {
lastEventAt = Date.now()
}
}
const options = {}
const removeDirectory = (): void => {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// Temporary canary cleanup is best-effort during process exit.
}
}
let clearTimers = (): void => undefined
const cleanupOnExit = (): void => {
clearTimers()
removeDirectory()
}
// Why: process.exit from a subscribe watchdog does not unwind to the catch;
// the temporary directory must already be owned by an exit handler.
process.once('exit', cleanupOnExit)
try {
await watchdog.watch('canary subscribe', binding.subscribe(dir, callback, options))
} catch (error) {
process.removeListener('exit', cleanupOnExit)
removeDirectory()
throw error
}
let misses = 0
const probes = new Set<ReturnType<typeof setTimeout>>()
const interval = setInterval(() => {
const probedAt = Date.now()
try {
writeFileSync(join(dir, 'canary.txt'), String(probedAt))
} catch {
return
}
const probe = setTimeout(() => {
probes.delete(probe)
if (lastEventAt >= probedAt) {
misses = 0
} else if (++misses >= CANARY_MAX_MISSES) {
process.stderr.write('[wsl-watcher-host] native event delivery stalled\n')
process.exit(2)
}
}, CANARY_EVENT_TIMEOUT_MS)
probes.add(probe)
}, CANARY_INTERVAL_MS)
clearTimers = (): void => {
clearInterval(interval)
for (const probe of probes) {
clearTimeout(probe)
}
probes.clear()
}
let closing: Promise<void> | undefined
return {
close: () => {
closing ??= (async () => {
clearTimers()
try {
await watchdog.watch('canary unsubscribe', binding.unsubscribe(dir, callback, options))
} finally {
process.removeListener('exit', cleanupOnExit)
removeDirectory()
}
})()
return closing
}
}
}
+298
View File
@@ -0,0 +1,298 @@
import type { Dirent, Stats } from 'node:fs'
import { lstat, readdir } from 'node:fs/promises'
import { join } from 'node:path'
export type SafetySnapshotEntry = {
directory: boolean
signature: string
}
export type SafetySnapshot = Map<string, SafetySnapshotEntry>
export type SafetyScanResult =
| { kind: 'complete'; snapshot: SafetySnapshot }
| { kind: 'cancelled' | 'entry-limit' | 'time-limit' | 'resource-limit' | 'io-error' }
export type SafetyScanOptions = {
signal?: AbortSignal
maxEntries?: number
maxDurationMs?: number
maxResourceWaitMs?: number
now?: () => number
fileSystem?: SafetyFileSystem
}
export type SafetyFileSystem = {
lstat(path: string): Promise<Stats>
readdir(path: string): Promise<readonly Dirent[]>
}
export type BoundedFileSystemResult<T> =
| { kind: 'complete'; value: T }
| { kind: 'error'; error: unknown }
| { kind: 'cancelled' }
| { kind: 'resource-limit' }
| { kind: 'time-limit' }
const MAX_CONCURRENT_SCANS = 2
const MAX_PHYSICAL_FILE_SYSTEM_OPERATIONS = 8
const DEFAULT_MAX_SCAN_ENTRIES = 100_000
const DEFAULT_MAX_SCAN_DURATION_MS = 10_000
type PermitWaiter = {
signal?: AbortSignal
resolve: (release: (() => void) | null) => void
onAbort: () => void
}
class FairPermitPool {
private available: number
private readonly waiters: PermitWaiter[] = []
constructor(capacity: number) {
this.available = capacity
}
acquire(signal?: AbortSignal): Promise<(() => void) | null> {
if (signal?.aborted) {
return Promise.resolve(null)
}
if (this.available > 0 && this.waiters.length === 0) {
this.available -= 1
return Promise.resolve(this.createRelease())
}
return new Promise((resolve) => {
const waiter: PermitWaiter = {
signal,
resolve,
onAbort: () => {
const index = this.waiters.indexOf(waiter)
if (index !== -1) {
this.waiters.splice(index, 1)
}
resolve(null)
}
}
this.waiters.push(waiter)
signal?.addEventListener('abort', waiter.onAbort, { once: true })
})
}
private createRelease(): () => void {
let released = false
return () => {
if (released) {
return
}
released = true
while (this.waiters.length > 0) {
const waiter = this.waiters.shift() as PermitWaiter
waiter.signal?.removeEventListener('abort', waiter.onAbort)
if (!waiter.signal?.aborted) {
waiter.resolve(this.createRelease())
return
}
}
this.available += 1
}
}
}
let scanPermits = new FairPermitPool(MAX_CONCURRENT_SCANS)
let physicalFileSystemPermits = new FairPermitPool(MAX_PHYSICAL_FILE_SYSTEM_OPERATIONS)
export function resetWatcherHostFileSystemPermitsForTest(): void {
scanPermits = new FairPermitPool(MAX_CONCURRENT_SCANS)
physicalFileSystemPermits = new FairPermitPool(MAX_PHYSICAL_FILE_SYSTEM_OPERATIONS)
}
export const defaultSafetyFileSystem: SafetyFileSystem = {
lstat,
readdir: (path) => readdir(path, { withFileTypes: true })
}
export function fileSystemErrorCode(error: unknown): string | undefined {
return (error as NodeJS.ErrnoException | undefined)?.code
}
export function createSafetySnapshotEntry(stats: Stats): SafetySnapshotEntry {
const directory = stats.isDirectory()
return {
directory,
// Directory content changes must not look like missed events; inode changes
// still reveal delete/recreate races that invalidate recursive watches.
signature: directory
? `d:${stats.dev}:${stats.ino}`
: `f:${stats.mode}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}:${stats.ino}`
}
}
export function boundedFileSystemOperation<T>(
operation: () => Promise<T>,
signal: AbortSignal | undefined,
deadline: number,
now: () => number
): Promise<BoundedFileSystemResult<T>> {
if (signal?.aborted) {
return Promise.resolve({ kind: 'cancelled' })
}
const remaining = deadline - now()
if (remaining <= 0) {
return Promise.resolve({ kind: 'time-limit' })
}
return acquirePhysicalPermit(signal, remaining).then((admission) => {
if (admission === 'cancelled') {
return { kind: 'cancelled' }
}
if (admission === 'resource-limit') {
return { kind: 'resource-limit' }
}
const afterAdmission = deadline - now()
if (afterAdmission <= 0) {
admission()
return { kind: 'time-limit' }
}
const physicalOperation = Promise.resolve().then(operation)
void physicalOperation.then(admission, admission)
return settleLogicalFileSystemOperation(physicalOperation, signal, afterAdmission)
})
}
function acquirePhysicalPermit(
signal: AbortSignal | undefined,
timeoutMs: number
): Promise<(() => void) | 'cancelled' | 'resource-limit'> {
const waiting = new AbortController()
const onAbort = (): void => waiting.abort()
signal?.addEventListener('abort', onAbort, { once: true })
const timer = setTimeout(() => waiting.abort(), timeoutMs)
timer.unref?.()
return physicalFileSystemPermits.acquire(waiting.signal).then((release) => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
if (release && signal?.aborted) {
release()
return 'cancelled'
}
if (release) {
return release
}
return signal?.aborted ? 'cancelled' : 'resource-limit'
})
}
function settleLogicalFileSystemOperation<T>(
operation: Promise<T>,
signal: AbortSignal | undefined,
timeoutMs: number
): Promise<BoundedFileSystemResult<T>> {
return new Promise((resolve) => {
let settled = false
const finish = (result: BoundedFileSystemResult<T>): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
resolve(result)
}
const onAbort = (): void => finish({ kind: 'cancelled' })
const timer = setTimeout(() => finish({ kind: 'time-limit' }), timeoutMs)
timer.unref?.()
signal?.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => finish({ kind: 'complete', value }),
(error: unknown) => finish({ kind: 'error', error })
)
})
}
export async function scanWatcherSafetySnapshot(
root: string,
ignoreDirs: ReadonlySet<string>,
options: SafetyScanOptions = {}
): Promise<SafetyScanResult> {
const now = options.now ?? Date.now
const release = await scanPermits.acquire(options.signal)
if (!release) {
return { kind: 'cancelled' }
}
if (options.signal?.aborted) {
release()
return { kind: 'cancelled' }
}
try {
return await scanWithLimits(root, ignoreDirs, options, now)
} finally {
release()
}
}
async function scanWithLimits(
root: string,
ignoreDirs: ReadonlySet<string>,
options: SafetyScanOptions,
now: () => number
): Promise<SafetyScanResult> {
const snapshot: SafetySnapshot = new Map()
const pending = [root]
const deadline = now() + (options.maxDurationMs ?? DEFAULT_MAX_SCAN_DURATION_MS)
const fileSystem = options.fileSystem ?? defaultSafetyFileSystem
while (pending.length > 0) {
if (options.signal?.aborted) {
return { kind: 'cancelled' }
}
if (snapshot.size >= (options.maxEntries ?? DEFAULT_MAX_SCAN_ENTRIES)) {
return { kind: 'entry-limit' }
}
if (now() >= deadline) {
return { kind: 'time-limit' }
}
const current = pending.pop() as string
const statResult = await boundedFileSystemOperation(
() => fileSystem.lstat(current),
options.signal,
deadline,
now
)
if (
statResult.kind === 'cancelled' ||
statResult.kind === 'time-limit' ||
statResult.kind === 'resource-limit'
) {
return statResult
}
if (statResult.kind === 'error') {
if (fileSystemErrorCode(statResult.error) === 'ENOENT') {
continue
}
return { kind: 'io-error' }
}
snapshot.set(current, createSafetySnapshotEntry(statResult.value))
if (!statResult.value.isDirectory()) {
continue
}
const readResult = await boundedFileSystemOperation(
() => fileSystem.readdir(current),
options.signal,
deadline,
now
)
if (
readResult.kind === 'cancelled' ||
readResult.kind === 'time-limit' ||
readResult.kind === 'resource-limit'
) {
return readResult
}
if (readResult.kind === 'error') {
if (fileSystemErrorCode(readResult.error) !== 'ENOENT') {
return { kind: 'io-error' }
}
continue
}
for (const entry of readResult.value) {
if (!ignoreDirs.has(entry.name)) {
pending.push(join(current, entry.name))
}
}
}
return { kind: 'complete', snapshot }
}
+179 -1
View File
@@ -1,6 +1,15 @@
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { buildIgnoreGlobs, startWslWatcherHost } from './wsl-watcher-host-entry'
import {
watcherSafetyDelay,
type SafetyScanResult,
type SafetySnapshot
} from './wsl-watcher-host-safety'
function checkpoint(snapshot: SafetySnapshot) {
return { kind: 'complete' as const, snapshot }
}
describe('WSL watcher Linux host', () => {
it('builds nested directory exclusions without treating names as regex', () => {
@@ -42,7 +51,7 @@ describe('WSL watcher Linux host', () => {
})
callback?.(null, [{ type: 'update', path: '/home/me/repo/README.md' }])
expect(outputText).toContain('"op":"events","id":7')
await vi.waitFor(() => expect(outputText).toContain('"op":"events","id":7'))
input.write(`${JSON.stringify({ op: 'unsubscribe', id: 7 })}\n`)
await vi.waitFor(() => expect(outputText).toContain('"op":"unsubscribed","id":7'))
expect(unsubscribe).toHaveBeenCalledOnce()
@@ -66,4 +75,173 @@ describe('WSL watcher Linux host', () => {
expect(subscribe).not.toHaveBeenCalled()
input.end()
})
it('turns watched-root deletion into a recoverable interruption', async () => {
const input = new PassThrough()
const output = new PassThrough()
let outputText = ''
output.on('data', (chunk: Buffer) => {
outputText += chunk.toString('utf8')
})
let callback: ((error: Error | null, events: never[]) => void) | undefined
const subscribe = vi.fn(async (_dir, createdCallback) => {
callback = createdCallback
})
startWslWatcherHost(
{ subscribe, unsubscribe: vi.fn(async () => undefined) } as never,
input,
output,
vi.fn()
)
input.write(
`${JSON.stringify({ op: 'subscribe', id: 9, dir: '/missing/repo', ignoreDirs: [] })}\n`
)
await vi.waitFor(() => expect(outputText).toContain('"op":"subscribed","id":9'))
callback?.(null, [{ type: 'delete', path: '/missing/repo' }] as never)
await vi.waitFor(() => expect(outputText).toContain('"op":"watch-error","id":9'))
input.end()
})
it('marks attach-window topology failures for a retained overflow restart', async () => {
const input = new PassThrough()
const output = new PassThrough()
let outputText = ''
output.on('data', (chunk: Buffer) => {
outputText += chunk.toString('utf8')
})
const before = new Map([['/repo', { directory: true, signature: 'd:1' }]])
const after = new Map([
['/repo', { directory: true, signature: 'd:1' }],
['/repo/imported', { directory: true, signature: 'd:2' }]
])
const scan = vi
.fn()
.mockResolvedValueOnce(checkpoint(before))
.mockResolvedValueOnce(checkpoint(after))
startWslWatcherHost(
{ subscribe: vi.fn(async () => undefined), unsubscribe: vi.fn(async () => undefined) },
input,
output,
vi.fn(),
scan
)
input.write(`${JSON.stringify({ op: 'subscribe', id: 10, dir: '/repo', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(outputText).toContain('"op":"watch-error","id":10'))
expect(outputText).toContain('"reason":"topology"')
expect(outputText).not.toContain('"op":"subscribed","id":10')
input.end()
})
it('reconciles mutations across native attachment before reporting ready', async () => {
const input = new PassThrough()
const output = new PassThrough()
let outputText = ''
output.on('data', (chunk: Buffer) => {
outputText += chunk.toString('utf8')
})
const before = new Map([
['/repo', { directory: true, signature: 'd:1' }],
['/repo/file', { directory: false, signature: 'f:before' }]
])
const after = new Map([
['/repo', { directory: true, signature: 'd:1' }],
['/repo/file', { directory: false, signature: 'f:after' }]
])
const scan = vi
.fn()
.mockResolvedValueOnce(checkpoint(before))
.mockResolvedValueOnce(checkpoint(after))
startWslWatcherHost(
{ subscribe: vi.fn(async () => undefined), unsubscribe: vi.fn(async () => undefined) },
input,
output,
vi.fn(),
scan
)
input.write(`${JSON.stringify({ op: 'subscribe', id: 11, dir: '/repo', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(outputText).toContain('"op":"subscribed","id":11'))
const eventIndex = outputText.indexOf('"op":"events","id":11')
expect(eventIndex).toBeGreaterThan(-1)
expect(eventIndex).toBeLessThan(outputText.indexOf('"op":"subscribed","id":11'))
input.end()
})
it('cancels an abandoned initial checkpoint before attaching native watching', async () => {
const input = new PassThrough()
const output = new PassThrough()
let outputText = ''
output.on('data', (chunk: Buffer) => {
outputText += chunk.toString('utf8')
})
const subscribe = vi.fn()
const scan = vi.fn((_root, _ignored, options) => {
return new Promise<SafetyScanResult>((resolve) => {
options.signal.addEventListener('abort', () => resolve({ kind: 'cancelled' }), {
once: true
})
})
})
startWslWatcherHost({ subscribe, unsubscribe: vi.fn() } as never, input, output, vi.fn(), scan)
input.write(`${JSON.stringify({ op: 'subscribe', id: 12, dir: '/repo', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(scan).toHaveBeenCalledOnce())
input.write(`${JSON.stringify({ op: 'unsubscribe', id: 12 })}\n`)
await vi.waitFor(() => expect(outputText).toContain('"op":"unsubscribed","id":12'))
expect(subscribe).not.toHaveBeenCalled()
input.end()
})
it('finishes reconciliation under sustained native traffic', async () => {
vi.useFakeTimers()
const input = new PassThrough()
const output = new PassThrough()
let outputText = ''
output.on('data', (chunk: Buffer) => {
outputText += chunk.toString('utf8')
})
let callback: (error: Error | null, events: { type: 'update'; path: string }[]) => void
let finishPeriodic!: (value: ReturnType<typeof checkpoint>) => void
const empty = new Map()
const scan = vi
.fn()
.mockResolvedValueOnce(checkpoint(empty))
.mockResolvedValueOnce(checkpoint(empty))
.mockImplementationOnce(
() => new Promise((resolve) => (finishPeriodic = resolve as typeof finishPeriodic))
)
.mockResolvedValue(checkpoint(empty))
startWslWatcherHost(
{
subscribe: vi.fn(async (_dir, createdCallback) => {
callback = createdCallback
}),
unsubscribe: vi.fn(async () => undefined)
} as never,
input,
output,
vi.fn(),
scan
)
input.write(`${JSON.stringify({ op: 'subscribe', id: 13, dir: '/busy', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(outputText).toContain('"op":"subscribed","id":13'))
await vi.advanceTimersByTimeAsync(watcherSafetyDelay('/busy', 0, 1))
expect(scan).toHaveBeenCalledTimes(3)
for (let index = 0; index < 100; index += 1) {
callback!(null, [{ type: 'update', path: `/busy/file-${index}` }])
}
finishPeriodic(checkpoint(empty))
await vi.advanceTimersByTimeAsync(0)
expect(outputText).not.toContain('"op":"watch-error","id":13')
expect(scan).toHaveBeenCalledTimes(3)
vi.useRealTimers()
input.end()
})
})
+155 -71
View File
@@ -1,13 +1,27 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { posix } from 'node:path'
import { createInterface, type Interface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Readable } from 'node:stream'
import {
createWatcherBindingWatchdog,
type WatcherBindingWatchdog
} from './wsl-watcher-host-binding-watchdog'
import { startWslWatcherCanary } from './wsl-watcher-host-canary'
import {
createBoundedProtocolWriter,
scanWatcherSafetySnapshot,
type WatcherHostOutput
} from './wsl-watcher-host-safety'
import {
createWatcherHostResourceBudget,
createWatcherReconciliation,
releaseWatcherReconciliationResources,
replaceWatcherSafetySnapshot,
type WatcherHostResourceBudget,
type WatcherReconciliationState,
type WatcherSafetyScanner
} from './wsl-watcher-host-reconciliation'
type WatcherEvent = {
type: 'create' | 'update' | 'delete'
path: string
}
type WatcherEvent = { type: 'create' | 'update' | 'delete'; path: string }
type NativeWatcherOptions = {
ignoreGlobs?: string[]
@@ -30,18 +44,14 @@ type HostCommand =
| { op: 'subscribe'; id: number; dir: string; ignoreDirs: string[] }
| { op: 'unsubscribe'; id: number }
type Subscription = {
dir: string
type Subscription = WatcherReconciliationState & {
callback: (error: Error | null, events: WatcherEvent[]) => void
options: NativeWatcherOptions
ready: Promise<void>
attached: boolean
}
type HostOutput = Pick<Writable, 'write'>
const CANARY_INTERVAL_MS = 10_000
const CANARY_EVENT_TIMEOUT_MS = 5_000
const CANARY_MAX_MISSES = 2
const NATIVE_BINDING_TIMEOUT_MS = 30_000
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
@@ -87,44 +97,154 @@ function parseCommand(line: string): HostCommand | null {
export function startWslWatcherHost(
binding: NativeWatcherBinding,
input: Readable,
output: HostOutput,
exit: (code: number) => void = process.exit
): { close(): Promise<void> } {
output: WatcherHostOutput,
exit: (code: number) => void = process.exit,
scanSafety: WatcherSafetyScanner = scanWatcherSafetySnapshot,
resourceBudget: WatcherHostResourceBudget = createWatcherHostResourceBudget(),
bindingTimeoutMs = NATIVE_BINDING_TIMEOUT_MS
): { close(): Promise<void>; bindingWatchdog: WatcherBindingWatchdog } {
const subscriptions = new Map<number, Subscription>()
const send = (message: object): void => {
output.write(`${JSON.stringify(message)}\n`)
const send = createBoundedProtocolWriter(output, exit)
const bindingWatchdog = createWatcherBindingWatchdog(exit, bindingTimeoutMs)
const interrupt = (subscription: Subscription, message: string, reason?: 'topology'): void => {
if (subscription.interrupted) {
return
}
subscription.interrupted = true
if (subscription.reconcileTimer) {
clearTimeout(subscription.reconcileTimer)
subscription.reconcileTimer = null
}
subscription.abortController.abort()
send({
op: 'watch-error',
id: subscription.id,
message,
...(reason ? { reason } : {})
})
}
const reconciliation = createWatcherReconciliation<Subscription>({
isCurrent: (state) => subscriptions.get(state.id) === state,
interrupt,
emitEvents: (state, events) => send({ op: 'events', id: state.id, events }),
scan: scanSafety
})
const subscribe = (command: Extract<HostCommand, { op: 'subscribe' }>): void => {
if (subscriptions.has(command.id)) {
send({ op: 'subscribe-failed', id: command.id, message: 'duplicate subscription id' })
return
}
const dir = posix.resolve(command.dir)
const options = { ignoreGlobs: buildIgnoreGlobs(command.ignoreDirs) }
let subscription: Subscription
const callback = (error: Error | null, events: WatcherEvent[]): void => {
if (error) {
send({ op: 'watch-error', id: command.id, message: errorMessage(error) })
interrupt(subscription, errorMessage(error))
} else if (events.length > 0) {
send({ op: 'events', id: command.id, events })
reconciliation.recordNativeEvents(subscription, events)
}
}
const ready = binding
.subscribe(command.dir, callback, options)
.then(() => send({ op: 'subscribed', id: command.id }))
.catch((error: unknown) => {
subscriptions.delete(command.id)
send({ op: 'subscribe-failed', id: command.id, message: errorMessage(error) })
})
subscriptions.set(command.id, { dir: command.dir, callback, options, ready })
subscription = {
id: command.id,
dir,
callback,
options,
ready: Promise.resolve(),
ignoreDirs: new Set(command.ignoreDirs),
safetySnapshot: new Map(),
reconcileTimer: null,
interrupted: false,
abortController: new AbortController(),
attached: false,
scanEvents: null,
scanEventsOverflowed: false,
nativeEventCount: 0,
idleRounds: 0,
operationTail: Promise.resolve(),
pendingNativeEvents: 0,
resourceBudget,
reservedSnapshotEntries: 0,
reservedJournalEvents: 0,
reservedPendingEvents: 0
}
subscriptions.set(command.id, subscription)
subscription.ready = (async () => {
try {
const before = await scanSafety(dir, subscription.ignoreDirs, {
signal: subscription.abortController.signal
})
if (before.kind !== 'complete') {
throw new Error(`Initial watcher checkpoint ${before.kind}`)
}
if (
subscription.abortController.signal.aborted ||
subscriptions.get(command.id) !== subscription
) {
return
}
if (!replaceWatcherSafetySnapshot(subscription, before.snapshot)) {
interrupt(subscription, 'recursive watch snapshot resource-limit', 'topology')
return
}
subscription.scanEvents = []
await bindingWatchdog.watch('subscribe', binding.subscribe(dir, callback, options))
subscription.attached = true
if (
subscription.abortController.signal.aborted ||
subscriptions.get(command.id) !== subscription
) {
return
}
if (!(await reconciliation.checkpoint(subscription)) || subscription.interrupted) {
return
}
send({ op: 'subscribed', id: command.id })
reconciliation.schedule(subscription)
} catch (error) {
if (!subscription.abortController.signal.aborted) {
send({ op: 'subscribe-failed', id: command.id, message: errorMessage(error) })
}
if (subscriptions.get(command.id) === subscription) {
subscriptions.delete(command.id)
releaseWatcherReconciliationResources(subscription)
}
} finally {
if (
subscription.attached &&
(subscription.abortController.signal.aborted ||
subscriptions.get(command.id) !== subscription)
) {
await bindingWatchdog
.watch('unsubscribe', binding.unsubscribe(dir, callback, options))
.catch(() => undefined)
subscription.attached = false
}
}
})()
}
const unsubscribe = async (id: number): Promise<void> => {
const subscription = subscriptions.get(id)
subscriptions.delete(id)
if (subscription?.reconcileTimer) {
clearTimeout(subscription.reconcileTimer)
subscription.reconcileTimer = null
}
subscription?.abortController.abort()
if (subscription) {
releaseWatcherReconciliationResources(subscription)
}
try {
await subscription?.ready
if (subscription) {
await binding.unsubscribe(subscription.dir, subscription.callback, subscription.options)
if (subscription?.attached) {
await bindingWatchdog.watch(
'unsubscribe',
binding.unsubscribe(subscription.dir, subscription.callback, subscription.options)
)
subscription.attached = false
}
} catch (error) {
process.stderr.write(`[wsl-watcher-host] unsubscribe ${id}: ${errorMessage(error)}\n`)
@@ -155,43 +275,7 @@ export function startWslWatcherHost(
void close().finally(() => exit(0))
})
send({ op: 'ready', protocol: 1 })
return { close }
}
async function startCanary(binding: NativeWatcherBinding): Promise<void> {
const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-watcher-'))
const options: NativeWatcherOptions = {}
let lastEventAt = 0
const callback = (error: Error | null): void => {
if (!error) {
lastEventAt = Date.now()
}
}
await binding.subscribe(dir, callback, options)
process.once('exit', () => {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// Temporary canary cleanup is best-effort during process exit.
}
})
let misses = 0
setInterval(() => {
const probedAt = Date.now()
try {
writeFileSync(join(dir, 'canary.txt'), String(probedAt))
} catch {
return
}
setTimeout(() => {
if (lastEventAt >= probedAt) {
misses = 0
} else if (++misses >= CANARY_MAX_MISSES) {
process.stderr.write('[wsl-watcher-host] native event delivery stalled\n')
process.exit(2)
}
}, CANARY_EVENT_TIMEOUT_MS)
}, CANARY_INTERVAL_MS)
return { close, bindingWatchdog }
}
function main(): void {
@@ -200,10 +284,10 @@ function main(): void {
process.stdout.write('ok\n')
return
}
startWslWatcherHost(binding, process.stdin, process.stdout)
const host = startWslWatcherHost(binding, process.stdin, process.stdout)
// Why: the native watcher can remain alive while its delivery thread stalls;
// a private canary converts that silent failure into a recoverable host exit.
void startCanary(binding).catch((error: unknown) => {
void startWslWatcherCanary(binding, host.bindingWatchdog).catch((error: unknown) => {
process.stderr.write(`[wsl-watcher-host] canary unavailable: ${errorMessage(error)}\n`)
})
}
@@ -0,0 +1,284 @@
import {
applyNativeEventsToSafetySnapshot,
reconcileWatcherSafetySnapshots,
scanWatcherSafetySnapshot,
watcherSafetyDelay,
type SafetyScanOptions,
type SafetyScanResult,
type SafetySnapshot,
type SafetyWatcherEvent
} from './wsl-watcher-host-safety'
export type WatcherSafetyScanner = (
root: string,
ignoreDirs: ReadonlySet<string>,
options?: SafetyScanOptions
) => Promise<SafetyScanResult>
export type WatcherReconciliationState = {
id: number
dir: string
ignoreDirs: Set<string>
safetySnapshot: SafetySnapshot
reconcileTimer: ReturnType<typeof setTimeout> | null
interrupted: boolean
abortController: AbortController
scanEvents: SafetyWatcherEvent[] | null
scanEventsOverflowed: boolean
nativeEventCount: number
idleRounds: number
operationTail: Promise<void>
pendingNativeEvents: number
resourceBudget: WatcherHostResourceBudget
reservedSnapshotEntries: number
reservedJournalEvents: number
reservedPendingEvents: number
}
type ResourceKind = 'journalEvents' | 'pendingEvents' | 'snapshotEntries'
export type WatcherHostResourceLimits = Partial<Record<ResourceKind, number>>
export type WatcherHostResourceBudget = {
reserve(kind: ResourceKind, count: number): boolean
release(kind: ResourceKind, count: number): void
}
type ReconciliationDeps<State extends WatcherReconciliationState> = {
isCurrent: (state: State) => boolean
interrupt: (state: State, message: string, reason?: 'topology') => void
emitEvents: (state: State, events: SafetyWatcherEvent[]) => void
scan?: WatcherSafetyScanner
}
const MAX_SCAN_JOURNAL_EVENTS = 10_000
const MAX_PENDING_NATIVE_EVENTS = 10_000
const DEFAULT_RESOURCE_LIMITS: Record<ResourceKind, number> = {
snapshotEntries: 500_000,
journalEvents: 50_000,
pendingEvents: 50_000
}
export function createWatcherHostResourceBudget(
overrides: WatcherHostResourceLimits = {}
): WatcherHostResourceBudget {
const limits = { ...DEFAULT_RESOURCE_LIMITS, ...overrides }
const usage: Record<ResourceKind, number> = {
snapshotEntries: 0,
journalEvents: 0,
pendingEvents: 0
}
return {
reserve: (kind, count) => {
if (count < 0 || usage[kind] + count > limits[kind]) {
return false
}
usage[kind] += count
return true
},
release: (kind, count) => {
usage[kind] = Math.max(0, usage[kind] - count)
}
}
}
export function replaceWatcherSafetySnapshot(
state: WatcherReconciliationState,
snapshot: SafetySnapshot
): boolean {
const difference = snapshot.size - state.reservedSnapshotEntries
if (difference > 0 && !state.resourceBudget.reserve('snapshotEntries', difference)) {
return false
}
if (difference < 0) {
state.resourceBudget.release('snapshotEntries', -difference)
}
state.reservedSnapshotEntries = snapshot.size
state.safetySnapshot = snapshot
return true
}
export function releaseWatcherReconciliationResources(state: WatcherReconciliationState): void {
state.resourceBudget.release('snapshotEntries', state.reservedSnapshotEntries)
state.resourceBudget.release('journalEvents', state.reservedJournalEvents)
state.resourceBudget.release('pendingEvents', state.reservedPendingEvents)
state.reservedSnapshotEntries = 0
state.reservedJournalEvents = 0
state.reservedPendingEvents = 0
state.safetySnapshot = new Map()
state.scanEvents = null
}
export function createWatcherReconciliation<State extends WatcherReconciliationState>(
deps: ReconciliationDeps<State>
) {
const scan = deps.scan ?? scanWatcherSafetySnapshot
const serialize = <Result>(state: State, operation: () => Promise<Result>): Promise<Result> => {
const result = state.operationTail.then(operation, operation)
state.operationTail = result.then(
() => undefined,
() => undefined
)
return result
}
const checkpoint = async (state: State): Promise<boolean> => {
if (state.interrupted || !deps.isCurrent(state)) {
return false
}
state.scanEvents ??= []
let result: SafetyScanResult
try {
result = await scan(state.dir, state.ignoreDirs, {
signal: state.abortController.signal
})
} catch {
state.scanEvents = null
deps.interrupt(state, 'recursive watch reconciliation io-error')
return false
}
const concurrentEvents = state.scanEvents
state.scanEvents = null
const journalReservation = state.reservedJournalEvents
state.reservedJournalEvents = 0
if (state.interrupted || !deps.isCurrent(state) || result.kind === 'cancelled') {
state.resourceBudget.release('journalEvents', journalReservation)
return false
}
if (state.scanEventsOverflowed) {
state.resourceBudget.release('journalEvents', journalReservation)
deps.interrupt(state, 'recursive watch reconciliation journal-limit')
return false
}
if (result.kind === 'resource-limit') {
state.resourceBudget.release('journalEvents', journalReservation)
state.scanEventsOverflowed = false
return true
}
if (result.kind !== 'complete') {
state.resourceBudget.release('journalEvents', journalReservation)
deps.interrupt(state, `recursive watch reconciliation ${result.kind}`)
return false
}
state.scanEventsOverflowed = false
return serialize(state, async () => {
try {
if (state.interrupted || !deps.isCurrent(state)) {
return false
}
const applied = await applyNativeEventsToSafetySnapshot(
state.dir,
result.snapshot,
concurrentEvents ?? [],
{ signal: state.abortController.signal }
)
if (applied === 'cancelled') {
return false
}
if (applied === 'topology') {
deps.interrupt(state, 'recursive watch reconciliation required', 'topology')
return false
}
const difference = reconcileWatcherSafetySnapshots(state.safetySnapshot, result.snapshot)
if (difference.topologyChanged || !replaceWatcherSafetySnapshot(state, result.snapshot)) {
deps.interrupt(state, 'recursive watch reconciliation required', 'topology')
return false
}
if (difference.events.length > 0) {
deps.emitEvents(state, difference.events)
}
return true
} finally {
state.resourceBudget.release('journalEvents', journalReservation)
}
})
}
let schedule: (state: State) => void
const arm = (state: State, delay: number): void => {
if (state.interrupted || !deps.isCurrent(state)) {
return
}
state.reconcileTimer = setTimeout(() => {
state.reconcileTimer = null
void checkpoint(state).then((complete) => {
if (complete) {
schedule(state)
}
})
}, delay)
state.reconcileTimer.unref?.()
}
schedule = (state: State): void => {
const eventCount = state.nativeEventCount
state.nativeEventCount = 0
state.idleRounds = eventCount === 0 ? state.idleRounds + 1 : 0
arm(state, watcherSafetyDelay(state.dir, eventCount, state.idleRounds))
}
const recordNativeEvents = (state: State, events: SafetyWatcherEvent[]): boolean => {
if (state.interrupted || !deps.isCurrent(state)) {
return false
}
if (
state.pendingNativeEvents + events.length > MAX_PENDING_NATIVE_EVENTS ||
!state.resourceBudget.reserve('pendingEvents', events.length)
) {
deps.interrupt(state, 'recursive watch native event queue-limit', 'topology')
return false
}
state.reservedPendingEvents += events.length
if (state.scanEvents) {
if (
state.scanEvents.length + events.length > MAX_SCAN_JOURNAL_EVENTS ||
!state.resourceBudget.reserve('journalEvents', events.length)
) {
state.resourceBudget.release('pendingEvents', events.length)
state.reservedPendingEvents -= events.length
state.scanEvents = []
state.scanEventsOverflowed = true
deps.interrupt(state, 'recursive watch reconciliation journal-limit', 'topology')
return false
} else {
state.reservedJournalEvents += events.length
state.scanEvents.push(...events)
}
}
const previousCount = state.nativeEventCount
state.nativeEventCount += events.length
if (previousCount < 1_000 && state.nativeEventCount >= 1_000 && state.reconcileTimer) {
clearTimeout(state.reconcileTimer)
state.reconcileTimer = null
arm(state, watcherSafetyDelay(state.dir, state.nativeEventCount, 0))
}
state.pendingNativeEvents += events.length
void serialize(state, async () => {
try {
if (state.interrupted || !deps.isCurrent(state)) {
return
}
const applied = await applyNativeEventsToSafetySnapshot(
state.dir,
state.safetySnapshot,
events,
{ signal: state.abortController.signal }
)
if (applied === 'topology') {
deps.interrupt(state, 'recursive watch topology changed', 'topology')
} else if (applied === 'applied' && !state.interrupted && deps.isCurrent(state)) {
deps.emitEvents(state, events)
}
} catch {
deps.interrupt(state, 'recursive watch topology uncertain', 'topology')
} finally {
state.pendingNativeEvents -= events.length
const released = Math.min(events.length, state.reservedPendingEvents)
state.reservedPendingEvents -= released
state.resourceBudget.release('pendingEvents', released)
}
})
return true
}
return { checkpoint, recordNativeEvents, schedule }
}
@@ -0,0 +1,185 @@
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startWslWatcherHost } from './wsl-watcher-host-entry'
import { createWatcherHostResourceBudget } from './wsl-watcher-host-reconciliation'
import { watcherSafetyDelay } from './wsl-watcher-host-safety'
function checkpoint(snapshot = new Map()) {
return { kind: 'complete' as const, snapshot }
}
function outputCapture(): { output: PassThrough; text: () => string } {
const output = new PassThrough()
let outputText = ''
output.on('data', (chunk: Buffer) => {
outputText += chunk.toString('utf8')
})
return { output, text: () => outputText }
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('WSL watcher host aggregate resources', () => {
it('reschedules resource contention without interrupting a healthy root', async () => {
vi.useFakeTimers()
const input = new PassThrough()
const captured = outputCapture()
const scan = vi
.fn()
.mockResolvedValueOnce(checkpoint())
.mockResolvedValueOnce(checkpoint())
.mockResolvedValueOnce({ kind: 'resource-limit' })
.mockResolvedValue(checkpoint())
startWslWatcherHost(
{ subscribe: vi.fn(async () => undefined), unsubscribe: vi.fn(async () => undefined) },
input,
captured.output,
vi.fn(),
scan
)
input.write(`${JSON.stringify({ op: 'subscribe', id: 20, dir: '/busy', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain('"op":"subscribed","id":20'))
await vi.advanceTimersByTimeAsync(watcherSafetyDelay('/busy', 0, 1))
expect(scan).toHaveBeenCalledTimes(3)
expect(captured.text()).not.toContain('"op":"watch-error"')
expect(vi.getTimerCount()).toBeGreaterThan(0)
input.end()
})
it('recycles the host when a new native subscribe stalls beside a retained root', async () => {
vi.useFakeTimers()
const input = new PassThrough()
const captured = outputCapture()
const subscribe = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementationOnce(() => new Promise<void>(() => undefined))
const exit = vi.fn()
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
startWslWatcherHost(
{ subscribe, unsubscribe: vi.fn(async () => undefined) } as never,
input,
captured.output,
exit,
async () => checkpoint(),
createWatcherHostResourceBudget(),
10
)
input.write(`${JSON.stringify({ op: 'subscribe', id: 21, dir: '/one', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain('"op":"subscribed","id":21'))
input.write(`${JSON.stringify({ op: 'subscribe', id: 22, dir: '/two', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2))
await vi.advanceTimersByTimeAsync(10)
expect(exit).toHaveBeenCalledWith(4)
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('native subscribe did not settle'))
input.end()
})
it('recycles the host when unsubscribe stalls while another root is retained', async () => {
vi.useFakeTimers()
const input = new PassThrough()
const captured = outputCapture()
const exit = vi.fn()
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const unsubscribe = vi
.fn()
.mockImplementationOnce(() => new Promise<void>(() => undefined))
.mockResolvedValue(undefined)
startWslWatcherHost(
{
subscribe: vi.fn(async () => undefined),
unsubscribe
} as never,
input,
captured.output,
exit,
async () => checkpoint(),
createWatcherHostResourceBudget(),
10
)
for (const id of [23, 24]) {
input.write(`${JSON.stringify({ op: 'subscribe', id, dir: `/${id}`, ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain(`"op":"subscribed","id":${id}`))
}
input.write(`${JSON.stringify({ op: 'unsubscribe', id: 23 })}\n`)
await vi.advanceTimersByTimeAsync(10)
expect(exit).toHaveBeenCalledWith(4)
input.end()
})
it('enforces and releases the host-wide snapshot budget across roots', async () => {
const input = new PassThrough()
const captured = outputCapture()
const scan = vi.fn(async (root: string) =>
checkpoint(new Map([[root, { directory: true, signature: `d:${root}` }]]))
)
startWslWatcherHost(
{
subscribe: vi.fn(async () => undefined),
unsubscribe: vi.fn(async () => undefined)
} as never,
input,
captured.output,
vi.fn(),
scan,
createWatcherHostResourceBudget({ snapshotEntries: 1 })
)
input.write(`${JSON.stringify({ op: 'subscribe', id: 31, dir: '/one', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain('"op":"subscribed","id":31'))
input.write(`${JSON.stringify({ op: 'subscribe', id: 32, dir: '/two', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain('"op":"watch-error","id":32'))
expect(captured.text()).toContain('"reason":"topology"')
input.write(`${JSON.stringify({ op: 'unsubscribe', id: 31 })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain('"op":"unsubscribed","id":31'))
input.write(`${JSON.stringify({ op: 'subscribe', id: 33, dir: '/three', ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain('"op":"subscribed","id":33'))
input.end()
})
it('uses aggregate pending and journal limits instead of per-root multiplication', () => {
const budget = createWatcherHostResourceBudget({ pendingEvents: 2, journalEvents: 3 })
expect(budget.reserve('pendingEvents', 2)).toBe(true)
expect(budget.reserve('pendingEvents', 1)).toBe(false)
budget.release('pendingEvents', 2)
expect(budget.reserve('pendingEvents', 1)).toBe(true)
expect(budget.reserve('journalEvents', 3)).toBe(true)
expect(budget.reserve('journalEvents', 1)).toBe(false)
})
it('interrupts deterministically when roots exceed the aggregate pending-event cap', async () => {
const input = new PassThrough()
const captured = outputCapture()
const callbacks: ((error: Error | null, events: unknown[]) => void)[] = []
startWslWatcherHost(
{
subscribe: vi.fn(async (_dir, callback) => {
callbacks.push(callback)
}),
unsubscribe: vi.fn(async () => undefined)
} as never,
input,
captured.output,
vi.fn(),
async () => checkpoint(),
createWatcherHostResourceBudget({ pendingEvents: 1 })
)
for (const id of [41, 42]) {
input.write(`${JSON.stringify({ op: 'subscribe', id, dir: `/${id}`, ignoreDirs: [] })}\n`)
await vi.waitFor(() => expect(captured.text()).toContain(`"op":"subscribed","id":${id}`))
}
callbacks[0]?.(null, [{ type: 'update', path: '/41/file' }])
callbacks[1]?.(null, [{ type: 'update', path: '/42/file' }])
expect(captured.text()).toContain('"op":"watch-error","id":42')
expect(captured.text()).toContain('"reason":"topology"')
input.end()
})
})
@@ -0,0 +1,289 @@
import { EventEmitter } from 'node:events'
import {
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
type Stats
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
applyNativeEventsToSafetySnapshot,
createBoundedProtocolWriter,
reconcileWatcherSafetySnapshots,
scanWatcherSafetySnapshot,
watcherSafetyDelay,
type SafetySnapshot
} from './wsl-watcher-host-safety'
import { resetWatcherHostFileSystemPermitsForTest } from './wsl-watcher-host-checkpoint'
const temporaryRoots: string[] = []
function makeTemporaryRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'orca-wsl-host-safety-'))
temporaryRoots.push(root)
return root
}
async function completeSnapshot(root: string): Promise<SafetySnapshot> {
const result = await scanWatcherSafetySnapshot(root, new Set())
expect(result.kind).toBe('complete')
return (result as Extract<typeof result, { kind: 'complete' }>).snapshot
}
class BackpressuredOutput extends EventEmitter {
writes: string[] = []
blockNextWrite = true
write(line: string): boolean {
this.writes.push(line)
if (this.blockNextWrite) {
this.blockNextWrite = false
return false
}
return true
}
}
afterEach(() => {
resetWatcherHostFileSystemPermitsForTest()
for (const root of temporaryRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe('WSL watcher host safety', () => {
it('requires a resubscribe when a populated directory moves into the root', async () => {
const fixture = makeTemporaryRoot()
const root = join(fixture, 'root')
const source = join(fixture, 'source')
mkdirSync(root)
mkdirSync(join(source, 'nested'), { recursive: true })
writeFileSync(join(source, 'nested', 'file.txt'), 'before')
const snapshot = await completeSnapshot(root)
const imported = join(root, 'imported')
renameSync(source, imported)
await expect(
applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'create', path: imported }])
).resolves.toBe('topology')
})
it('accepts empty directories but preserves the baseline across topology transitions', async () => {
const root = makeTemporaryRoot()
const file = join(root, 'entry')
writeFileSync(file, 'file')
const snapshot = await completeSnapshot(root)
const fileBaseline = snapshot.get(file)
unlinkSync(file)
mkdirSync(file)
await expect(
applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'update', path: file }])
).resolves.toBe('topology')
expect(snapshot.get(file)).toEqual(fileBaseline)
const empty = join(root, 'empty')
mkdirSync(empty)
await expect(
applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'create', path: empty }])
).resolves.toBe('applied')
expect(snapshot.get(empty)?.directory).toBe(true)
})
it('detects directory inode replacement and retains known state on unknown stat errors', async () => {
const root = makeTemporaryRoot()
const directory = join(root, 'directory')
mkdirSync(directory)
const snapshot = await completeSnapshot(root)
const baseline = snapshot.get(directory)
renameSync(directory, join(root, 'old-directory'))
mkdirSync(directory)
await expect(
applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'update', path: directory }])
).resolves.toBe('topology')
expect(snapshot.get(directory)).toEqual(baseline)
const denied = join(root, 'denied')
await expect(
applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'update', path: denied }], {
fileSystem: {
lstat: async () => {
throw Object.assign(new Error('denied'), { code: 'EACCES' })
},
readdir: async () => []
}
})
).resolves.toBe('topology')
expect(snapshot.get(directory)).toEqual(baseline)
})
it('bounds asynchronous native-event inspection without committing partial state', async () => {
const snapshot: SafetySnapshot = new Map([
['/repo', { directory: true, signature: 'd:1' }],
['/repo/known', { directory: false, signature: 'f:known' }]
])
const never = new Promise<never>(() => undefined)
await expect(
applyNativeEventsToSafetySnapshot(
'/repo',
snapshot,
[
{ type: 'delete', path: '/repo/known' },
{ type: 'update', path: '/repo/stuck' }
],
{
maxDurationMs: 10,
fileSystem: { lstat: async () => never, readdir: async () => never }
}
)
).resolves.toBe('topology')
expect(snapshot.get('/repo/known')).toEqual({ directory: false, signature: 'f:known' })
})
it('recovers file events hidden by an inotify queue overflow', async () => {
const root = makeTemporaryRoot()
const file = join(root, 'README.md')
writeFileSync(file, 'before')
const previous = await completeSnapshot(root)
writeFileSync(file, 'after-with-a-different-size')
const next = await completeSnapshot(root)
expect(reconcileWatcherSafetySnapshots(previous, next)).toEqual({
events: [{ type: 'update', path: file }],
topologyChanged: false
})
})
it('bounds scans, cancels promptly, and adapts jittered cadence to event risk', async () => {
const root = makeTemporaryRoot()
writeFileSync(join(root, 'one'), '1')
writeFileSync(join(root, 'two'), '2')
await expect(scanWatcherSafetySnapshot(root, new Set(), { maxEntries: 1 })).resolves.toEqual({
kind: 'entry-limit'
})
let clock = 0
await expect(
scanWatcherSafetySnapshot(root, new Set(), {
maxDurationMs: 1,
now: () => (clock += 2)
})
).resolves.toEqual({ kind: 'time-limit' })
const aborted = new AbortController()
aborted.abort()
await expect(
scanWatcherSafetySnapshot(root, new Set(), { signal: aborted.signal })
).resolves.toEqual({ kind: 'cancelled' })
expect(watcherSafetyDelay('/repo-a', 1_000, 0)).toBeLessThan(6_100)
expect(watcherSafetyDelay('/repo-a', 1, 0)).toBeGreaterThan(23_000)
expect(watcherSafetyDelay('/repo-a', 0, 3)).toBeGreaterThan(230_000)
expect(watcherSafetyDelay('/repo-a', 0, 1)).not.toBe(watcherSafetyDelay('/repo-b', 0, 1))
})
it('releases scan capacity when filesystem operations never settle', async () => {
const root = makeTemporaryRoot()
const never = new Promise<never>(() => undefined)
const stuckFileSystem = {
lstat: async () => never,
readdir: async () => never
}
const first = scanWatcherSafetySnapshot('/stuck-a', new Set(), {
fileSystem: stuckFileSystem,
maxDurationMs: 20
})
const second = scanWatcherSafetySnapshot('/stuck-b', new Set(), {
fileSystem: stuckFileSystem,
maxDurationMs: 20
})
const progressing = scanWatcherSafetySnapshot(root, new Set(), { maxResourceWaitMs: 500 })
await expect(first).resolves.toEqual({ kind: 'time-limit' })
await expect(second).resolves.toEqual({ kind: 'time-limit' })
await expect(progressing).resolves.toMatchObject({ kind: 'complete' })
const controller = new AbortController()
const cancelled = scanWatcherSafetySnapshot('/stuck-c', new Set(), {
fileSystem: stuckFileSystem,
maxDurationMs: 10_000,
signal: controller.signal
})
controller.abort()
await expect(cancelled).resolves.toEqual({ kind: 'cancelled' })
})
it('admits three or more queued roots in FIFO order without resource rejection', async () => {
const started: string[] = []
const releases = new Map<string, () => void>()
const fileSystem = {
lstat: (path: string) =>
new Promise<Stats>((resolve) => {
started.push(path)
releases.set(path, () => resolve({ isDirectory: () => false } as never))
}),
readdir: async () => []
}
const scans = ['/one', '/two', '/three', '/four'].map((root) =>
scanWatcherSafetySnapshot(root, new Set(), {
fileSystem,
maxDurationMs: 1_000,
maxResourceWaitMs: 1
})
)
await vi.waitFor(() => expect(started).toEqual(['/one', '/two']))
releases.get('/one')?.()
await vi.waitFor(() => expect(started).toEqual(['/one', '/two', '/three']))
releases.get('/two')?.()
await vi.waitFor(() => expect(started).toEqual(['/one', '/two', '/three', '/four']))
releases.get('/three')?.()
releases.get('/four')?.()
await expect(Promise.all(scans)).resolves.toEqual(
Array.from({ length: 4 }, () => expect.objectContaining({ kind: 'complete' }))
)
})
it('caps physical operations across repeated scans whose I/O never settles', async () => {
const lstat = vi.fn(() => new Promise<never>(() => undefined))
const scans = Array.from({ length: 20 }, (_, index) =>
scanWatcherSafetySnapshot(`/stuck-${index}`, new Set(), {
fileSystem: { lstat, readdir: async () => [] },
maxDurationMs: 5
})
)
await Promise.all(scans)
expect(lstat).toHaveBeenCalledTimes(8)
})
it('orders protocol messages across backpressure and exits at the memory cap', () => {
const output = new BackpressuredOutput()
const exit = vi.fn()
const send = createBoundedProtocolWriter(output, exit)
send({ op: 'first' })
send({ op: 'second' })
expect(output.writes).toHaveLength(1)
output.emit('drain')
expect(output.writes.map((line) => JSON.parse(line))).toEqual([
{ op: 'first' },
{ op: 'second' }
])
const cappedOutput = new BackpressuredOutput()
const cappedExit = vi.fn()
const cappedSend = createBoundedProtocolWriter(cappedOutput, cappedExit)
cappedSend({ payload: 'x'.repeat(4 * 1024 * 1024) })
expect(cappedExit).toHaveBeenCalledWith(3)
expect(cappedOutput.writes).toHaveLength(0)
})
})
+221
View File
@@ -0,0 +1,221 @@
import {
boundedFileSystemOperation,
createSafetySnapshotEntry,
defaultSafetyFileSystem,
fileSystemErrorCode,
type SafetyScanOptions,
type SafetySnapshot,
type SafetySnapshotEntry
} from './wsl-watcher-host-checkpoint'
export { scanWatcherSafetySnapshot } from './wsl-watcher-host-checkpoint'
export type {
SafetyFileSystem,
SafetyScanOptions,
SafetyScanResult,
SafetySnapshot,
SafetySnapshotEntry
} from './wsl-watcher-host-checkpoint'
export type SafetyWatcherEvent = {
type: 'create' | 'update' | 'delete'
path: string
}
export type NativeEventApplicationResult = 'applied' | 'cancelled' | 'topology'
export type WatcherHostOutput = {
write(chunk: string): boolean
once(event: 'drain' | 'error', listener: () => void): unknown
}
const MAX_PROTOCOL_QUEUE_BYTES = 4 * 1024 * 1024
const DEFAULT_NATIVE_EVENT_DURATION_MS = 5_000
export function watcherSafetyDelay(key: string, eventCount: number, idleRounds: number): number {
const base =
eventCount >= 1_000
? 5_000
: eventCount > 0
? 30_000
: Math.min(60_000 * 2 ** Math.min(idleRounds, 3), 300_000)
let hash = 0
for (const character of key) {
hash = (hash * 31 + character.charCodeAt(0)) >>> 0
}
return Math.round(base * (0.8 + (hash % 401) / 1_000))
}
function effectiveSnapshotEntry(
snapshot: SafetySnapshot,
changes: Map<string, SafetySnapshotEntry | null>,
path: string
): SafetySnapshotEntry | undefined {
const changed = changes.get(path)
return changes.has(path) ? (changed ?? undefined) : snapshot.get(path)
}
export async function applyNativeEventsToSafetySnapshot(
root: string,
snapshot: SafetySnapshot,
events: SafetyWatcherEvent[],
options: SafetyScanOptions = {}
): Promise<NativeEventApplicationResult> {
const changes = new Map<string, SafetySnapshotEntry | null>()
const now = options.now ?? Date.now
const deadline = now() + (options.maxDurationMs ?? DEFAULT_NATIVE_EVENT_DURATION_MS)
const fileSystem = options.fileSystem ?? defaultSafetyFileSystem
for (const event of events) {
const eventPath = event.path
const previous = effectiveSnapshotEntry(snapshot, changes, eventPath)
if (event.type === 'delete') {
if (eventPath === root || previous?.directory) {
return 'topology'
}
changes.set(eventPath, null)
continue
}
const statResult = await boundedFileSystemOperation(
() => fileSystem.lstat(eventPath),
options.signal,
deadline,
now
)
if (statResult.kind === 'cancelled') {
return 'cancelled'
}
if (statResult.kind === 'time-limit' || statResult.kind === 'resource-limit') {
return 'topology'
}
if (statResult.kind === 'error') {
if (fileSystemErrorCode(statResult.error) !== 'ENOENT') {
// Why: keep the known-good baseline when permissions or I/O failures
// leave the path's recursive-watch topology uncertain.
return 'topology'
}
if (previous?.directory) {
return 'topology'
}
changes.set(eventPath, null)
continue
}
const next = createSafetySnapshotEntry(statResult.value)
if (previous?.directory !== next.directory && previous !== undefined) {
return 'topology'
}
if (next.directory && previous?.signature !== undefined) {
if (previous.signature !== next.signature) {
return 'topology'
}
} else if (next.directory) {
const readResult = await boundedFileSystemOperation(
() => fileSystem.readdir(eventPath),
options.signal,
deadline,
now
)
if (readResult.kind === 'cancelled') {
return 'cancelled'
}
if (readResult.kind !== 'complete' || readResult.value.length > 0) {
return 'topology'
}
}
changes.set(eventPath, next)
}
for (const [path, entry] of changes) {
if (entry) {
snapshot.set(path, entry)
} else {
snapshot.delete(path)
}
}
return 'applied'
}
export function reconcileWatcherSafetySnapshots(
previous: SafetySnapshot,
next: SafetySnapshot
): { events: SafetyWatcherEvent[]; topologyChanged: boolean } {
const events: SafetyWatcherEvent[] = []
let topologyChanged = false
for (const [entryPath, nextEntry] of next) {
const prior = previous.get(entryPath)
if (!prior) {
topologyChanged ||= nextEntry.directory
events.push({ type: 'create', path: entryPath })
} else if (
prior.directory !== nextEntry.directory ||
(nextEntry.directory && prior.signature !== nextEntry.signature)
) {
topologyChanged = true
} else if (prior.signature !== nextEntry.signature) {
events.push({ type: 'update', path: entryPath })
}
}
for (const [entryPath, prior] of previous) {
if (!next.has(entryPath)) {
topologyChanged ||= prior.directory
events.push({ type: 'delete', path: entryPath })
}
}
return { events, topologyChanged }
}
export function createBoundedProtocolWriter(
output: WatcherHostOutput,
exit: (code: number) => void
): (message: object) => void {
const queued: { line: string; bytes: number }[] = []
let pendingBytes = 0
let blockedBytes = 0
let blocked = false
let stopped = false
const fail = (): void => {
if (stopped) {
return
}
stopped = true
queued.length = 0
pendingBytes = 0
exit(3)
}
const flush = (): void => {
while (!stopped && !blocked && queued.length > 0) {
const next = queued.shift() as { line: string; bytes: number }
try {
if (output.write(next.line)) {
pendingBytes -= next.bytes
} else {
blocked = true
blockedBytes = next.bytes
output.once('drain', () => {
pendingBytes -= blockedBytes
blockedBytes = 0
blocked = false
flush()
})
}
} catch {
fail()
}
}
}
output.once('error', fail)
return (message: object): void => {
if (stopped) {
return
}
const line = `${JSON.stringify(message)}\n`
const bytes = Buffer.byteLength(line)
if (pendingBytes + bytes > MAX_PROTOCOL_QUEUE_BYTES) {
// Why: parent stalls must become a recoverable watcher exit, never
// unbounded memory growth in the managed Linux host.
fail()
return
}
pendingBytes += bytes
queued.push({ line, bytes })
flush()
}
}