mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
fix(fs): harden managed WSL watcher lifecycle
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'))
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user