diff --git a/build-plugins/plain-node-entry-guard.test.ts b/build-plugins/plain-node-entry-guard.test.ts new file mode 100644 index 00000000000..89b45fc4e77 --- /dev/null +++ b/build-plugins/plain-node-entry-guard.test.ts @@ -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 + ) + } +} diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index 1fddb5f6a12..e9caacde790 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -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 +): 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) + } } } diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ab3758fa171..e4661312dcf 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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 diff --git a/config/scripts/electron-builder-before-build.cjs b/config/scripts/electron-builder-before-build.cjs index dd34dfb19af..b2a6169f76f 100644 --- a/config/scripts/electron-builder-before-build.cjs +++ b/config/scripts/electron-builder-before-build.cjs @@ -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 diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 5d669688a06..3d6b7924bac 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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 { diff --git a/config/scripts/prepare-wsl-watcher-runtime-cache.test.mjs b/config/scripts/prepare-wsl-watcher-runtime-cache.test.mjs new file mode 100644 index 00000000000..a37fdc6a86b --- /dev/null +++ b/config/scripts/prepare-wsl-watcher-runtime-cache.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/prepare-wsl-watcher-runtime-recovery.test.mjs b/config/scripts/prepare-wsl-watcher-runtime-recovery.test.mjs new file mode 100644 index 00000000000..72cf98e3f70 --- /dev/null +++ b/config/scripts/prepare-wsl-watcher-runtime-recovery.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/prepare-wsl-watcher-runtime.mjs b/config/scripts/prepare-wsl-watcher-runtime.mjs index 8f5e9ca3350..974d901ae5a 100644 --- a/config/scripts/prepare-wsl-watcher-runtime.mjs +++ b/config/scripts/prepare-wsl-watcher-runtime.mjs @@ -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`) + } } diff --git a/config/scripts/prepare-wsl-watcher-runtime.test.mjs b/config/scripts/prepare-wsl-watcher-runtime.test.mjs new file mode 100644 index 00000000000..9cec2532835 --- /dev/null +++ b/config/scripts/prepare-wsl-watcher-runtime.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/wsl-watcher-runtime-build-source.mjs b/config/scripts/wsl-watcher-runtime-build-source.mjs new file mode 100644 index 00000000000..cfc8a29948a --- /dev/null +++ b/config/scripts/wsl-watcher-runtime-build-source.mjs @@ -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 }) + } +} diff --git a/config/scripts/wsl-watcher-runtime-build-source.test.mjs b/config/scripts/wsl-watcher-runtime-build-source.test.mjs new file mode 100644 index 00000000000..7841454c52d --- /dev/null +++ b/config/scripts/wsl-watcher-runtime-build-source.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/wsl-watcher-runtime-cache.mjs b/config/scripts/wsl-watcher-runtime-cache.mjs new file mode 100644 index 00000000000..e6ba40d7cba --- /dev/null +++ b/config/scripts/wsl-watcher-runtime-cache.mjs @@ -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 }) + } +} diff --git a/config/scripts/wsl-watcher-runtime-lock.mjs b/config/scripts/wsl-watcher-runtime-lock.mjs new file mode 100644 index 00000000000..d1b389e2b38 --- /dev/null +++ b/config/scripts/wsl-watcher-runtime-lock.mjs @@ -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) + } +} diff --git a/config/scripts/wsl-watcher-runtime-lock.test.mjs b/config/scripts/wsl-watcher-runtime-lock.test.mjs new file mode 100644 index 00000000000..f8fd0f632b0 --- /dev/null +++ b/config/scripts/wsl-watcher-runtime-lock.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/wsl-watcher-runtime-publication.mjs b/config/scripts/wsl-watcher-runtime-publication.mjs new file mode 100644 index 00000000000..a3f05fc42bf --- /dev/null +++ b/config/scripts/wsl-watcher-runtime-publication.mjs @@ -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 }) + } +} diff --git a/src/main/ipc/filesystem-watcher-event-batch.ts b/src/main/ipc/filesystem-watcher-event-batch.ts index 761cf352486..23692ccf7a5 100644 --- a/src/main/ipc/filesystem-watcher-event-batch.ts +++ b/src/main/ipc/filesystem-watcher-event-batch.ts @@ -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[] diff --git a/src/main/ipc/filesystem-watcher-pending-wsl-install.test.ts b/src/main/ipc/filesystem-watcher-pending-wsl-install.test.ts new file mode 100644 index 00000000000..519f48a2264 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-pending-wsl-install.test.ts @@ -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 Promise | 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 = 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 + 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 + 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 + 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 + 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 + 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) => void + let resolveUnsubscribe!: () => void + const unsubscribe = vi.fn(() => new Promise((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 + 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) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl-disposal.ts b/src/main/ipc/filesystem-watcher-wsl-disposal.ts new file mode 100644 index 00000000000..88d9ae5e7a5 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-disposal.ts @@ -0,0 +1,22 @@ +import type { WslWatchEngine } from './filesystem-watcher-wsl-engine' + +export function clearWslWatcherTimers( + timers: readonly (ReturnType | null)[] +): void { + for (const timer of timers) { + if (timer) { + clearTimeout(timer) + } + } +} + +export function stopWslWatcherEngines( + startingEngines: Set, + activeEngine: WslWatchEngine | null +): void { + for (const engine of startingEngines) { + engine.stop() + } + startingEngines.clear() + activeEngine?.stop() +} diff --git a/src/main/ipc/filesystem-watcher-wsl-engine.ts b/src/main/ipc/filesystem-watcher-wsl-engine.ts index 8544f63e1a6..653a2cecc1c 100644 --- a/src/main/ipc/filesystem-watcher-wsl-engine.ts +++ b/src/main/ipc/filesystem-watcher-wsl-engine.ts @@ -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 - stopped: Promise + stopped: Promise 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((resolve, reject) => { resolveReady = resolve rejectReady = reject }) - const stopped = new Promise((resolve) => { + const stopped = new Promise((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((resolve) => { + let resolveStopped!: (reason: WslHostStopReason) => void + const stopped = new Promise((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) } } } diff --git a/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts b/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts new file mode 100644 index 00000000000..6fd995f2119 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts @@ -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')) + ) +} diff --git a/src/main/ipc/filesystem-watcher-wsl-host-client.ts b/src/main/ipc/filesystem-watcher-wsl-host-client.ts index afabf5b2723..cf443f8217c 100644 --- a/src/main/ipc/filesystem-watcher-wsl-host-client.ts +++ b/src/main/ipc/filesystem-watcher-wsl-host-client.ts @@ -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 | null -} - -type DistroHost = { - distro: string - process: ChildProcessWithoutNullStreams | null - starting: Promise | null - subscriptions: Map - nextId: number - streamBuffer: string - stdoutDecoder: StringDecoder - stderrDecoder: StringDecoder - stderrTail: string -} - -const hosts = new Map() 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 { - const runtime = await ensureWslWatcherRuntime(host.distro) +async function startHost(host: WslDistroHost, signal: AbortSignal): Promise { + 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 { 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 { - 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 { + 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 { + if (!signal) { + return ensureHost(host) + } + let rejectAbort!: (error: Error) => void + const aborted = new Promise((_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((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((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() } diff --git a/src/main/ipc/filesystem-watcher-wsl-host-lifecycle.test.ts b/src/main/ipc/filesystem-watcher-wsl-host-lifecycle.test.ts new file mode 100644 index 00000000000..75f1baafc7c --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-host-lifecycle.test.ts @@ -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 } { + 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>> { + 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() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl-host-lifecycle.ts b/src/main/ipc/filesystem-watcher-wsl-host-lifecycle.ts new file mode 100644 index 00000000000..81900d9748e --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-host-lifecycle.ts @@ -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 | null +} + +export type WslDistroHost = { + distro: string + process: ChildProcessWithoutNullStreams | null + starting: Promise | null + bootstrapAbortController: AbortController | null + retired: boolean + pendingAcquisitions: number + subscriptions: Map + nextId: number + streamBuffer: string + stdoutDecoder: StringDecoder + stderrDecoder: StringDecoder + stderrTail: string +} + +const hosts = new Map() + +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() +} diff --git a/src/main/ipc/filesystem-watcher-wsl-host-protocol.ts b/src/main/ipc/filesystem-watcher-wsl-host-protocol.ts index b5aaf5b30bf..c62d79bce24 100644 --- a/src/main/ipc/filesystem-watcher-wsl-host-protocol.ts +++ b/src/main/ipc/filesystem-watcher-wsl-host-protocol.ts @@ -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 } diff --git a/src/main/ipc/filesystem-watcher-wsl-live.test.ts b/src/main/ipc/filesystem-watcher-wsl-live.test.ts index 1a43d887fa9..a536c5224b9 100644 --- a/src/main/ipc/filesystem-watcher-wsl-live.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl-live.test.ts @@ -66,5 +66,5 @@ describe.skipIf(!runLive)('WSL watcher live integration', () => { } finally { await root.subscription.unsubscribe() } - }) + }, 15_000) }) diff --git a/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts b/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts new file mode 100644 index 00000000000..e1486513b27 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts @@ -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 { + 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 { + return createWslWatcher(rootPath, rootPath, { + ignoreDirs: ['node_modules', '.git'], + scheduleBatchFlush + }) +} + +async function waitForSpawnCount(count: number): Promise { + 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() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl-runtime-cache.test.ts b/src/main/ipc/filesystem-watcher-wsl-runtime-cache.test.ts new file mode 100644 index 00000000000..a1346802173 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-runtime-cache.test.ts @@ -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; resolve: () => void } { + let resolvePromise!: () => void + const promise = new Promise((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()) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl-runtime-installer.ts b/src/main/ipc/filesystem-watcher-wsl-runtime-installer.ts new file mode 100644 index 00000000000..ea2f42bb995 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-runtime-installer.ts @@ -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 { + 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 { + 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 { + 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) + ) + ) + } +} diff --git a/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts b/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts index 475ca2755b6..6801255321f 100644 --- a/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts @@ -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') }) }) diff --git a/src/main/ipc/filesystem-watcher-wsl-runtime.ts b/src/main/ipc/filesystem-watcher-wsl-runtime.ts index a124b3c8de2..38e547fb480 100644 --- a/src/main/ipc/filesystem-watcher-wsl-runtime.ts +++ b/src/main/ipc/filesystem-watcher-wsl-runtime.ts @@ -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>() +type RuntimeInstallation = { + abortController: AbortController + owners: Set + promise: Promise + settled: boolean +} + +const installations = new Map() const runningQueries = new Map>() -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 { - const bundlePath = resolveBundlePath() +async function installRuntime( + distro: string, + signal?: AbortSignal +): Promise { + 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 { +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 { + 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 { - 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 { + 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 { 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 { } } -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 { let pending = runningQueries.get(distro) if (!pending) { @@ -240,8 +246,9 @@ export function isWslDistroRunning(distro: string): Promise { } export function resetWslWatcherRuntimeForTest(): void { - installationPromises.clear() + for (const installation of installations.values()) { + installation.abortController.abort() + } + installations.clear() runningQueries.clear() } - -export { INSTALL_SCRIPT } diff --git a/src/main/ipc/filesystem-watcher-wsl-snapshot-stream.test.ts b/src/main/ipc/filesystem-watcher-wsl-snapshot-stream.test.ts new file mode 100644 index 00000000000..04829777449 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-snapshot-stream.test.ts @@ -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() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts b/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts new file mode 100644 index 00000000000..8cc8e195067 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts @@ -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((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') + } + ) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl-snapshot.ts b/src/main/ipc/filesystem-watcher-wsl-snapshot.ts index f51e9ebf0cb..f4fa57048ca 100644 --- a/src/main/ipc/filesystem-watcher-wsl-snapshot.ts +++ b/src/main/ipc/filesystem-watcher-wsl-snapshot.ts @@ -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 } diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 7e08e48a53f..58172e2ece6 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -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' diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 0f9f191f557..c37fd009102 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -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() + let startGeneration = 0 let disposed = false let nativeUnavailable = false + let nativePermanentlyUnavailable = false + let nativeRetryNeeded = false + let unstableNativeExits = 0 let restartAttempt = 0 let restartTimer: ReturnType | null = null let stabilityTimer: ReturnType | null = null + let nativeRetryTimer: ReturnType | 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 => { + const recordNativeFailure = (error: unknown): void => { + nativeUnavailable = true + if (isPermanentWslNativeFailure(error)) { + nativePermanentlyUnavailable = true + nativeRetryNeeded = false + } else { + nativeRetryNeeded = true + } + } + + const startEngine = async (): Promise => { + 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 + let reprobeNative: () => Promise + 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 => { - 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 => { + 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 => { + 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 } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 0b3b07ca071..f1f1e1277e4 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -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 Promise | 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 + 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(() => {}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 1134fbf55d0..0f902b8c0fe 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -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>() // cleanup can start it before app shutdown, so will-quit must still await it. const pendingLocalUnsubscribes = new Set>() type LocalWatcherInstallToken = { + abortController: AbortController cancelled: boolean listeners: Map } @@ -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() const pendingLocalInstallPromises = new Map>() +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 { + 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 { } 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 { const rootKey = normalizeRootPath(worktreePath) + const lifecycleGeneration = localWatcherLifecycleGeneration if (sender.isDestroyed()) { return } @@ -449,6 +478,22 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise 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 { 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 { + localWatcherLifecycleGeneration += 1 senderCleanupRegistered.clear() unwatchableRoots.clear() @@ -924,16 +995,19 @@ export async function closeAllWatchers(): Promise { } for (const token of inFlightLocalInstalls.values()) { token.listeners.clear() - token.cancelled = true + cancelLocalInstallIfUnused(token) } - + const shutdownWork: Promise[] = 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 diff --git a/src/main/ipc/wsl-watcher-host-binding-watchdog.ts b/src/main/ipc/wsl-watcher-host-binding-watchdog.ts new file mode 100644 index 00000000000..ecb14596c77 --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-binding-watchdog.ts @@ -0,0 +1,37 @@ +export type WatcherBindingWatchdog = { + watch(operation: string, pending: Promise): Promise +} + +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 (operation: string, pending: Promise): Promise => { + let timer: ReturnType | undefined + const timeout = new Promise((_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) + } + } + } + } +} diff --git a/src/main/ipc/wsl-watcher-host-canary.test.ts b/src/main/ipc/wsl-watcher-host-canary.test.ts new file mode 100644 index 00000000000..feed002c0c3 --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-canary.test.ts @@ -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 { + 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((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(() => 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(() => 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) + }) +}) diff --git a/src/main/ipc/wsl-watcher-host-canary.ts b/src/main/ipc/wsl-watcher-host-canary.ts new file mode 100644 index 00000000000..991f2e3e201 --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-canary.ts @@ -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 + unsubscribe(dir: string, callback: (error: Error | null) => void, options: object): Promise +} + +export type WslWatcherCanary = { close(): Promise } + +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 { + 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>() + 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 | 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 + } + } +} diff --git a/src/main/ipc/wsl-watcher-host-checkpoint.ts b/src/main/ipc/wsl-watcher-host-checkpoint.ts new file mode 100644 index 00000000000..85da6890bef --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-checkpoint.ts @@ -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 +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 + readdir(path: string): Promise +} +export type BoundedFileSystemResult = + | { 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( + operation: () => Promise, + signal: AbortSignal | undefined, + deadline: number, + now: () => number +): Promise> { + 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( + operation: Promise, + signal: AbortSignal | undefined, + timeoutMs: number +): Promise> { + return new Promise((resolve) => { + let settled = false + const finish = (result: BoundedFileSystemResult): 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, + options: SafetyScanOptions = {} +): Promise { + 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, + options: SafetyScanOptions, + now: () => number +): Promise { + 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 } +} diff --git a/src/main/ipc/wsl-watcher-host-entry.test.ts b/src/main/ipc/wsl-watcher-host-entry.test.ts index e0a3ecdc185..1a49b8ffb7c 100644 --- a/src/main/ipc/wsl-watcher-host-entry.test.ts +++ b/src/main/ipc/wsl-watcher-host-entry.test.ts @@ -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((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) => 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() + }) }) diff --git a/src/main/ipc/wsl-watcher-host-entry.ts b/src/main/ipc/wsl-watcher-host-entry.ts index cda6a4f1cd3..f4fcb159b15 100644 --- a/src/main/ipc/wsl-watcher-host-entry.ts +++ b/src/main/ipc/wsl-watcher-host-entry.ts @@ -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 + attached: boolean } -type HostOutput = Pick - -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 } { + output: WatcherHostOutput, + exit: (code: number) => void = process.exit, + scanSafety: WatcherSafetyScanner = scanWatcherSafetySnapshot, + resourceBudget: WatcherHostResourceBudget = createWatcherHostResourceBudget(), + bindingTimeoutMs = NATIVE_BINDING_TIMEOUT_MS +): { close(): Promise; bindingWatchdog: WatcherBindingWatchdog } { const subscriptions = new Map() - 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({ + isCurrent: (state) => subscriptions.get(state.id) === state, + interrupt, + emitEvents: (state, events) => send({ op: 'events', id: state.id, events }), + scan: scanSafety + }) + const subscribe = (command: Extract): 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 => { 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 { - 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`) }) } diff --git a/src/main/ipc/wsl-watcher-host-reconciliation.ts b/src/main/ipc/wsl-watcher-host-reconciliation.ts new file mode 100644 index 00000000000..ba9e0015d31 --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-reconciliation.ts @@ -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, + options?: SafetyScanOptions +) => Promise + +export type WatcherReconciliationState = { + id: number + dir: string + ignoreDirs: Set + safetySnapshot: SafetySnapshot + reconcileTimer: ReturnType | null + interrupted: boolean + abortController: AbortController + scanEvents: SafetyWatcherEvent[] | null + scanEventsOverflowed: boolean + nativeEventCount: number + idleRounds: number + operationTail: Promise + pendingNativeEvents: number + resourceBudget: WatcherHostResourceBudget + reservedSnapshotEntries: number + reservedJournalEvents: number + reservedPendingEvents: number +} + +type ResourceKind = 'journalEvents' | 'pendingEvents' | 'snapshotEntries' +export type WatcherHostResourceLimits = Partial> +export type WatcherHostResourceBudget = { + reserve(kind: ResourceKind, count: number): boolean + release(kind: ResourceKind, count: number): void +} + +type ReconciliationDeps = { + 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 = { + snapshotEntries: 500_000, + journalEvents: 50_000, + pendingEvents: 50_000 +} + +export function createWatcherHostResourceBudget( + overrides: WatcherHostResourceLimits = {} +): WatcherHostResourceBudget { + const limits = { ...DEFAULT_RESOURCE_LIMITS, ...overrides } + const usage: Record = { + 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( + deps: ReconciliationDeps +) { + const scan = deps.scan ?? scanWatcherSafetySnapshot + + const serialize = (state: State, operation: () => Promise): Promise => { + const result = state.operationTail.then(operation, operation) + state.operationTail = result.then( + () => undefined, + () => undefined + ) + return result + } + + const checkpoint = async (state: State): Promise => { + 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 } +} diff --git a/src/main/ipc/wsl-watcher-host-resource.test.ts b/src/main/ipc/wsl-watcher-host-resource.test.ts new file mode 100644 index 00000000000..a6ce37313ba --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-resource.test.ts @@ -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(() => 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(() => 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() + }) +}) diff --git a/src/main/ipc/wsl-watcher-host-safety.test.ts b/src/main/ipc/wsl-watcher-host-safety.test.ts new file mode 100644 index 00000000000..5657f7baf3f --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-safety.test.ts @@ -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 { + const result = await scanWatcherSafetySnapshot(root, new Set()) + expect(result.kind).toBe('complete') + return (result as Extract).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(() => 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(() => 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 void>() + const fileSystem = { + lstat: (path: string) => + new Promise((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(() => 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) + }) +}) diff --git a/src/main/ipc/wsl-watcher-host-safety.ts b/src/main/ipc/wsl-watcher-host-safety.ts new file mode 100644 index 00000000000..c236afbae66 --- /dev/null +++ b/src/main/ipc/wsl-watcher-host-safety.ts @@ -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, + 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 { + const changes = new Map() + 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() + } +}