diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..681fe94fbb84 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Why only ripgrep: the bundled rg ships in every artifact and to SSH remotes, and a bump is a + # one-line pin change (the SSH cache keys on the binary's hash). Other dependencies stay manual. + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + allow: + - dependency-name: '@vscode/ripgrep-universal' + open-pull-requests-limit: 1 diff --git a/.gitignore b/.gitignore index 6799b25607b4..5e05c6d30507 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,6 @@ tests/e2e/.cross-version-checkouts/ # Generated by config/scripts/sync-anti-slop-plugin.mjs from the pinned oxlint-plugin-anti-slop .anti-slop-plugin/ + +# Generated by the CI unit-test sequencer (and by reproducing a shard locally). +ci-shards/ diff --git a/AGENTS.md b/AGENTS.md index dad66f4bba83..8ac5cd365cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md). - **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one. +- **Ripgrep**: Orca bundles `rg` for every platform, WSL, and SSH remotes. Spawn it through `spawnBundledRipgrep` (main) or `resolveRelayRipgrepCommand` (relay), never a bare `'rg'` — Windows resolves a bare name in the spawn cwd before PATH. Don't add git/readdir fallbacks locally; the relay's chain exists only for hosts an upload never reached. - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md). - **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. Before changing the per-PTY job or debugging `windows-msys-job.win32.test.ts`, read [`docs/reference/windows-msys-job-breakaway.md`](./docs/reference/windows-msys-job-breakaway.md). - **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md). diff --git a/config/bundled-ripgrep-resources.cjs b/config/bundled-ripgrep-resources.cjs new file mode 100644 index 000000000000..b1689fdf9dd8 --- /dev/null +++ b/config/bundled-ripgrep-resources.cjs @@ -0,0 +1,73 @@ +const { chmodSync, existsSync } = require('node:fs') +const { join } = require('node:path') + +// Why every relay platform in every artifact: SSH deploys upload the remote host's rg from the +// local bundle, and WSL runs the Linux build from the Windows install directory. +const BUNDLED_RIPGREP_PLATFORMS = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' +] +const RIPGREP_PACKAGE_BIN_DIR = 'node_modules/@vscode/ripgrep-universal/bin' +const RIPGREP_RESOURCE_DIR = 'ripgrep' + +function ripgrepBinaryName(platform) { + return platform.startsWith('win32-') ? 'rg.exe' : 'rg' +} + +const bundledRipgrepExtraResources = [ + { + from: RIPGREP_PACKAGE_BIN_DIR, + to: RIPGREP_RESOURCE_DIR, + filter: BUNDLED_RIPGREP_PLATFORMS.map((platform) => `${platform}/**`) + }, + // Why: the binaries statically link PCRE2, and on Linux musl, jemalloc and LLVM libunwind -- + // all of which require their notice on binary redistribution. Whole dir, so notices can be added. + { from: 'resources/licenses/ripgrep', to: `${RIPGREP_RESOURCE_DIR}/licenses` } +] + +// Why: codesign would try to sign the Linux/Windows builds; they are inert data on macOS. +const bundledRipgrepMacSignIgnore = ['/ripgrep/(linux|win32)-'] + +// Why: electron-builder only warns on a missing extraResources source. +function assertBundledRipgrepInstalled(projectDir = join(__dirname, '..')) { + const missing = BUNDLED_RIPGREP_PLATFORMS.filter( + (platform) => + !existsSync(join(projectDir, RIPGREP_PACKAGE_BIN_DIR, platform, ripgrepBinaryName(platform))) + ) + if (missing.length > 0) { + throw new Error( + `@vscode/ripgrep-universal is missing binaries for ${missing.join(', ')}; run pnpm install.` + ) + } +} + +function finalizePackagedRipgrep(resourcesDir) { + for (const platform of BUNDLED_RIPGREP_PLATFORMS) { + const binaryPath = join( + resourcesDir, + RIPGREP_RESOURCE_DIR, + platform, + ripgrepBinaryName(platform) + ) + if (!existsSync(binaryPath)) { + throw new Error(`Packaged app is missing bundled ripgrep: ${binaryPath}`) + } + // Why: the upstream tarball's exec bits are not guaranteed after copying. + chmodSync(binaryPath, 0o755) + } +} + +module.exports = { + BUNDLED_RIPGREP_PLATFORMS, + RIPGREP_PACKAGE_BIN_DIR, + RIPGREP_RESOURCE_DIR, + ripgrepBinaryName, + assertBundledRipgrepInstalled, + bundledRipgrepExtraResources, + bundledRipgrepMacSignIgnore, + finalizePackagedRipgrep +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 71c140dc0c50..84c31376833a 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -19,6 +19,12 @@ const { assertMobileWebBundleBuilt } = require('./scripts/verify-packaged-mobile-web-bundle.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { + assertBundledRipgrepInstalled, + bundledRipgrepExtraResources, + bundledRipgrepMacSignIgnore, + finalizePackagedRipgrep +} = require('./bundled-ripgrep-resources.cjs') const { verifyPackagedWindowsNodePty } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') @@ -105,6 +111,7 @@ const emojiShortcodeDatasetResource = { } const commonExtraResources = [ relayExtraResource, + ...bundledRipgrepExtraResources, bundledPluginResources, skillFreshnessResources, emojiShortcodeDatasetResource @@ -297,6 +304,7 @@ module.exports = { // so a test can point the guard at a scratch bundle instead of needing the repo's out/ built. beforePack: (context, mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR) => { assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) + assertBundledRipgrepInstalled() assertMobileWebBundleBuilt(mobileWebBundleDir) }, afterPack: async (context) => { @@ -384,6 +392,7 @@ module.exports = { // Why: inspect electron-builder's real output so a broken extraResources // mapping fails packaging before bundled content reaches users. verifyPackagedPluginResources(resourcesDir) + finalizePackagedRipgrep(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { @@ -482,6 +491,7 @@ module.exports = { icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', + signIgnore: bundledRipgrepMacSignIgnore, extendInfo: { NSAppleEventsUsageDescription: 'Orca allows terminal-launched developer tools to automate local apps when you request it.', diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 69c12f0ecc95..99a53fa09815 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -9,14 +9,12 @@ */ import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' -import { createHash } from 'node:crypto' import { chmodSync, copyFileSync, - existsSync, + cpSync, mkdirSync, mkdtempSync, - readFileSync, rmSync, writeFileSync } from 'node:fs' @@ -24,10 +22,10 @@ import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' import { - ORCAD_VERSION, ORCAD_VERSION_FILENAME, - orcadArtifactFilenames + ORCAD_RIPGREP_ARTIFACTS } from '../../src/shared/orcad-artifacts.ts' +import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = join(ROOT, 'out', 'orcad') @@ -83,6 +81,23 @@ copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT) if (process.platform !== 'win32') { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } +// Why every platform: an SSH deployment can target a different host than the build machine. +for (const artifact of ORCAD_RIPGREP_ARTIFACTS) { + const [, ripgrepPlatform, ripgrepName] = artifact.split('/') + const outputDir = join(OUT_DIR, 'ripgrep', ripgrepPlatform) + mkdirSync(outputDir, { recursive: true }) + const outputPath = join(outputDir, ripgrepName) + copyFileSync( + join(ROOT, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', ripgrepPlatform, ripgrepName), + outputPath + ) + if (!ripgrepPlatform.startsWith('win32-')) { + chmodSync(outputPath, 0o755) + } +} +cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', 'licenses'), { + recursive: true +}) /** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source * directory under `outdir`, and both children must land flat beside orcad.js — that is where @@ -246,18 +261,7 @@ if (graphErrors.length > 0) { // already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale // bytes while reporting the new version. if (process.exitCode !== 1) { - const hash = createHash('sha256') - for (const filename of orcadArtifactFilenames()) { - const artifactPath = join(OUT_DIR, filename) - if (!existsSync(artifactPath)) { - throw new Error( - `orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` + - 'step, or drop it from src/shared/orcad-artifacts.ts.' - ) - } - hash.update(readFileSync(artifactPath)) - } - const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` + const fullVersion = computeOrcadFullVersion(OUT_DIR) writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion) console.log( `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 6eb34aff896a..77f45f621131 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -410,6 +410,7 @@ describe('packaged runtime resources', () => { join(resourcesDir, 'plugins', 'launch'), { recursive: true } ) + await seedBundledRipgrep(resourcesDir) const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') await mkdir(unpackedMainDir, { recursive: true }) @@ -476,6 +477,7 @@ describe('packaged runtime resources', () => { join(resourcesDir, 'plugins', 'launch'), { recursive: true } ) + await seedBundledRipgrep(resourcesDir) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) // Why: afterPack now fails hard when the unpacked daemon entry is // missing, so the fixture must carry one like a real package layout. @@ -528,6 +530,16 @@ describe('packaged runtime resources', () => { // Why source-anchored: the bundler renames a createRequire()'d require, so // verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging // stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs. +// Why stubs: afterPack only checks each platform binary exists; non-ELF bytes skip the glibc scan. +async function seedBundledRipgrep(resourcesDir) { + const { BUNDLED_RIPGREP_PLATFORMS } = require('../bundled-ripgrep-resources.cjs') + for (const platform of BUNDLED_RIPGREP_PLATFORMS) { + const dir = join(resourcesDir, 'ripgrep', platform) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, platform.startsWith('win32-') ? 'rg.exe' : 'rg'), '', 'utf8') + } +} + function collectLazyRequireSpecifiers(directory, found = new Map()) { for (const entry of readdirSync(directory, { withFileTypes: true })) { const entryPath = join(directory, entry.name) diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 5afefa965ef0..8146aa13f0ca 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -460,8 +460,6 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * modules 4221 -> 4219 (-2) * local modules 1035 -> 1033 (-2) */ -const SESSION_ROUTE_MODULES = 4219 - /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ 'app/h/[hostId]/session/[worktreeId].web.tsx', @@ -523,10 +521,8 @@ describeClosure( const { modules } = await mobileWebAppRouteClosure(SESSION_ROUTE) // The engine is here, as the one artifact the loader imports. expect(artifactModules(modules)).toHaveLength(1) - // And the package's own file tree is not, anywhere: it is inside that artifact. Meaningful - // only beside the line above, which is why the two sit together. + // Package sources stay inside that artifact; unrelated module counts are not this boundary. expect(packageModules(modules)).toEqual([]) - expect(modules).toHaveLength(SESSION_ROUTE_MODULES) const download = await mobileWebAppRouteChunkClosure(SESSION_ROUTE) // The fence: nothing of the engine is reachable from the route's own chunk by an import diff --git a/config/scripts/orcad-artifact-version.mjs b/config/scripts/orcad-artifact-version.mjs new file mode 100644 index 000000000000..ef85540bbaa5 --- /dev/null +++ b/config/scripts/orcad-artifact-version.mjs @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { ORCAD_VERSION, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts' + +export function computeOrcadFullVersion(artifactDir) { + const hash = createHash('sha256') + for (const filename of orcadArtifactFilenames()) { + const artifactPath = join(artifactDir, filename) + if (!existsSync(artifactPath)) { + throw new Error( + `orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` + + 'step, or drop it from src/shared/orcad-artifacts.ts.' + ) + } + hash.update(readFileSync(artifactPath)) + } + return `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` +} diff --git a/config/scripts/orcad-artifact-version.test.mjs b/config/scripts/orcad-artifact-version.test.mjs new file mode 100644 index 000000000000..a6d316b7f41c --- /dev/null +++ b/config/scripts/orcad-artifact-version.test.mjs @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + ORCAD_RIPGREP_ARTIFACTS, + orcadArtifactFilenames +} from '../../src/shared/orcad-artifacts.ts' +import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' + +describe('standalone runtime version', () => { + it('changes when a shipped search binary changes and rejects a missing binary', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-version-')) + try { + for (const filename of orcadArtifactFilenames()) { + const path = join(dir, filename) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, filename) + } + const before = computeOrcadFullVersion(dir) + const binary = join(dir, ORCAD_RIPGREP_ARTIFACTS[0]) + writeFileSync(binary, 'updated binary') + expect(computeOrcadFullVersion(dir)).not.toBe(before) + rmSync(binary) + expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 22677bb152fe..d586f27ff1a6 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -536,7 +536,8 @@ describe('PR E2E gate contract', () => { ) } for (const source of [ - 'src/main/ipc/rg-availability.ts', + 'src/main/ripgrep/bundled-ripgrep-path.ts', + 'src/shared/bundled-ripgrep.ts', 'src/shared/ripgrep-process-availability.ts' ]) { expect(selectPrE2eSpecs([source]), source).toEqual([ diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 7efe4cca8b02..9a8f678860f0 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -137,7 +137,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'], matches: (file) => isProductSource(file) && - /^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test( + /^(?:src\/main\/ipc\/filesystem-(?:list-files|search-file-paths)\.ts|src\/main\/ripgrep\/bundled-ripgrep-path\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain|relay-bundled-ripgrep)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability|bundled-ripgrep)\.ts)$/.test( file ) }, diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index 3a138ed894b0..3d4a0ca3c1d0 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -215,10 +215,11 @@ function declaredArchFromPath(filePath) { return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null } -function findArchViolation(filePath, targetArch) { +function findArchViolation(filePath, targetArch, rootDir) { // A path that names an architecture is judged against that name, so a per-arch vendored package // is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught. - const declared = declaredArchFromPath(filePath) + // Why relative: the arm64 slice's own dir (`linux-arm64-unpacked`) must not declare every file arm64. + const declared = declaredArchFromPath(rootDir ? relative(rootDir, filePath) : filePath) const expectedArch = declared ?? targetArch const expected = ELF_MACHINE_BY_ARCH[expectedArch] if (expected === undefined) { @@ -373,7 +374,15 @@ function readDynamicInfo(filePath, objdumpPath) { /** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */ function readImportedSymbols(filePath, objdumpPath) { - return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath)) + try { + return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath)) + } catch (error) { + // Why: a statically linked binary (bundled ripgrep) has no dynamic symbol table to import from. + if (error instanceof Error && error.message.includes('not a dynamic object')) { + return new Set() + } + throw error + } } /** @@ -403,7 +412,7 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { // Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but // meaningless, so reporting a floor violation for it would send the reader down the wrong path. const archOffenders = binaries - .map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) })) + .map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch, rootDir) })) .filter(({ violation }) => violation !== null) if (archOffenders.length > 0) { const detail = archOffenders diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs index d8d82165053f..e2b76b7841f6 100644 --- a/config/scripts/verify-linux-glibc-floor.test.mjs +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) @@ -375,6 +375,17 @@ describe('bundled native binary architecture', () => { await rm(dir, { recursive: true, force: true }) }) + // Release arm64 slices live in `linux-arm64-unpacked`; bundled ripgrep ships linux-x64 beside it. + it('reads arch tokens only below the slice root', async () => { + const root = join(await mkdtemp(join(tmpdir(), 'orca-elf-arch-')), 'linux-arm64-unpacked') + const dir = join(root, 'resources', 'ripgrep', 'linux-x64') + await mkdir(dir, { recursive: true }) + const file = join(dir, 'rg') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'arm64', root)).toBeNull() + await rm(dirname(root), { recursive: true, force: true }) + }) + it('flags an x86-64 binary in an arm64 slice', async () => { const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) const file = join(dir, 'pty.node') diff --git a/package.json b/package.json index a75cc005b999..5e88d8ab9940 100644 --- a/package.json +++ b/package.json @@ -238,6 +238,7 @@ "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.2.0", + "@vscode/ripgrep-universal": "1.18.0", "@xterm/addon-fit": "0.12.0-beta.300", "@xterm/addon-image": "0.10.0-beta.300", "@xterm/addon-ligatures": "0.11.0-beta.300", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ba1cc7f4f5e..7600806cae3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.2.0 version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) + '@vscode/ripgrep-universal': + specifier: 1.18.0 + version: 1.18.0 '@xterm/addon-fit': specifier: 0.12.0-beta.300 version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -3788,6 +3791,9 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vscode/ripgrep-universal@1.18.0': + resolution: {integrity: sha512-kgrAVy1L/v/8erAjo+I720ifTf7o4rvYUwgsuEu97XAEBtPT9sk3HhcSgFpxMydvNmwdJtXo9/AVee7JjocTrg==} + '@vscode/windows-process-tree@0.8.0': resolution: {integrity: sha512-TI+h2GRwX+igD/YYJMQQVAFcsCNSg7Te2yYxQpKMzwto5RsJ8d2KKgOeur/p/6sAOQwZvopiTDOClyTHEn9MhQ==} @@ -10479,6 +10485,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vscode/ripgrep-universal@1.18.0': {} + '@vscode/windows-process-tree@0.8.0(patch_hash=e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7)': dependencies: node-addon-api: 7.1.0 diff --git a/resources/licenses/ripgrep/JEMALLOC-COPYING b/resources/licenses/ripgrep/JEMALLOC-COPYING new file mode 100644 index 000000000000..3b7fd3585d2b --- /dev/null +++ b/resources/licenses/ripgrep/JEMALLOC-COPYING @@ -0,0 +1,27 @@ +Unless otherwise specified, files in the jemalloc source distribution are +subject to the following license: +-------------------------------------------------------------------------------- +Copyright (C) 2002-present Jason Evans . +All rights reserved. +Copyright (C) 2007-2012 Mozilla Foundation. All rights reserved. +Copyright (C) 2009-present Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright notice(s), + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice(s), + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL THE COPYRIGHT HOLDER(S) BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- diff --git a/resources/licenses/ripgrep/LICENSE-MIT b/resources/licenses/ripgrep/LICENSE-MIT new file mode 100644 index 000000000000..3b0a5dc09c1e --- /dev/null +++ b/resources/licenses/ripgrep/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/resources/licenses/ripgrep/LLVM-LIBUNWIND-LICENSE.TXT b/resources/licenses/ripgrep/LLVM-LIBUNWIND-LICENSE.TXT new file mode 100644 index 000000000000..1e3120621cae --- /dev/null +++ b/resources/licenses/ripgrep/LLVM-LIBUNWIND-LICENSE.TXT @@ -0,0 +1,311 @@ +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +============================================================================== +Software from third parties included in the LLVM Project: +============================================================================== +The LLVM Project contains third party software which is under different license +terms. All such code will be identified clearly using at least one of two +mechanisms: +1) It will be in a separate directory tree with its own `LICENSE.txt` or + `LICENSE` file at the top containing the specific license and restrictions + which apply to that software, or +2) It will contain specific license and restriction terms at the top of every + file. + +============================================================================== +Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy): +============================================================================== + +The libunwind library is dual licensed under both the University of Illinois +"BSD-Like" license and the MIT license. As a user of this code you may choose +to use it under either license. As a contributor, you agree to allow your code +to be used under both. + +Full text of the relevant licenses is included below. + +============================================================================== + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2009-2019 by the contributors listed in CREDITS.TXT + +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +============================================================================== + +Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/resources/licenses/ripgrep/MUSL-COPYRIGHT b/resources/licenses/ripgrep/MUSL-COPYRIGHT new file mode 100644 index 000000000000..2f15edc7a179 --- /dev/null +++ b/resources/licenses/ripgrep/MUSL-COPYRIGHT @@ -0,0 +1,193 @@ +musl as a whole is licensed under the following standard MIT license: + +---------------------------------------------------------------------- +Copyright © 2005-2020 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------------------------------------------------------------- + +Authors/contributors include: + +A. Wilcox +Ada Worcester +Alex Dowad +Alex Suykov +Alexander Monakov +Andre McCurdy +Andrew Kelley +Anthony G. Basile +Aric Belsito +Arvid Picciani +Bartosz Brachaczek +Benjamin Peterson +Bobby Bingham +Boris Brezillon +Brent Cook +Chris Spiegel +Clément Vasseur +Daniel Micay +Daniel Sabogal +Daurnimator +David Carlier +David Edelsohn +Denys Vlasenko +Dmitry Ivanov +Dmitry V. Levin +Drew DeVault +Emil Renner Berthing +Fangrui Song +Felix Fietkau +Felix Janda +Gianluca Anzolin +Hauke Mehrtens +He X +Hiltjo Posthuma +Isaac Dunham +Jaydeep Patil +Jens Gustedt +Jeremy Huntwork +Jo-Philipp Wich +Joakim Sindholt +John Spencer +Julien Ramseier +Justin Cormack +Kaarle Ritvanen +Khem Raj +Kylie McClain +Leah Neukirchen +Luca Barbato +Luka Perkov +Lynn Ochs +M Farkas-Dyck (Strake) +Mahesh Bodapati +Markus Wichmann +Masanori Ogino +Michael Clark +Michael Forney +Mikhail Kremnyov +Natanael Copa +Nicholas J. Kain +orc +Pascal Cuoq +Patrick Oppenlander +Petr Hosek +Petr Skocik +Pierre Carrier +Reini Urban +Rich Felker +Richard Pennington +Ryan Fairfax +Samuel Holland +Segev Finer +Shiz +sin +Solar Designer +Stefan Kristiansson +Stefan O'Rear +Szabolcs Nagy +Timo Teräs +Trutz Behn +Will Dietz +William Haddon +William Pitcock + +Portions of this software are derived from third-party works licensed +under terms compatible with the above MIT license: + +The TRE regular expression implementation (src/regex/reg* and +src/regex/tre*) is Copyright © 2001-2008 Ville Laurikari and licensed +under a 2-clause BSD license (license text in the source files). The +included version has been heavily modified by Rich Felker in 2012, in +the interests of size, simplicity, and namespace cleanliness. + +Much of the math library code (src/math/* and src/complex/*) is +Copyright © 1993,2004 Sun Microsystems or +Copyright © 2003-2011 David Schultz or +Copyright © 2003-2009 Steven G. Kargl or +Copyright © 2003-2009 Bruce D. Evans or +Copyright © 2008 Stephen L. Moshier or +Copyright © 2017-2018 Arm Limited +and labelled as such in comments in the individual source files. All +have been licensed under extremely permissive terms. + +The ARM memcpy code (src/string/arm/memcpy.S) is Copyright © 2008 +The Android Open Source Project and is licensed under a two-clause BSD +license. It was taken from Bionic libc, used on Android. + +The AArch64 memcpy and memset code (src/string/aarch64/*) are +Copyright © 1999-2019, Arm Limited. + +The implementation of DES for crypt (src/crypt/crypt_des.c) is +Copyright © 1994 David Burren. It is licensed under a BSD license. + +The implementation of blowfish crypt (src/crypt/crypt_blowfish.c) was +originally written by Solar Designer and placed into the public +domain. The code also comes with a fallback permissive license for use +in jurisdictions that may not recognize the public domain. + +The smoothsort implementation (src/stdlib/qsort.c) is Copyright © 2011 +Lynn Ochs and is licensed under an MIT-style license. + +The x86_64 port was written by Nicholas J. Kain and is licensed under +the standard MIT terms. + +The mips and microblaze ports were originally written by Richard +Pennington for use in the ellcc project. The original code was adapted +by Rich Felker for build system and code conventions during upstream +integration. It is licensed under the standard MIT terms. + +The mips64 port was contributed by Imagination Technologies and is +licensed under the standard MIT terms. + +The powerpc port was also originally written by Richard Pennington, +and later supplemented and integrated by John Spencer. It is licensed +under the standard MIT terms. + +All other files which have no copyright comments are original works +produced specifically for use as part of this library, written either +by Rich Felker, the main author of the library, or by one or more +contibutors listed above. Details on authorship of individual files +can be found in the git version control history of the project. The +omission of copyright and license comments in each file is in the +interest of source tree size. + +In addition, permission is hereby granted for all public header files +(include/* and arch/*/bits/*) and crt files intended to be linked into +applications (crt/*, ldso/dlstart.c, and arch/*/crt_arch.h) to omit +the copyright notice and permission notice otherwise required by the +license, and to use these files without any requirement of +attribution. These files include substantial contributions from: + +Bobby Bingham +John Spencer +Nicholas J. Kain +Rich Felker +Richard Pennington +Stefan Kristiansson +Szabolcs Nagy + +all of whom have explicitly granted such permission. + +This file previously contained text expressing a belief that most of +the files covered by the above exception were sufficiently trivial not +to be subject to copyright, resulting in confusion over whether it +negated the permissions granted in the license. In the spirit of +permissive licensing, and of not having licensing issues being an +obstacle to adoption, that text has been removed. diff --git a/resources/licenses/ripgrep/PCRE2-LICENCE.md b/resources/licenses/ripgrep/PCRE2-LICENCE.md new file mode 100644 index 000000000000..f6fba35db25b --- /dev/null +++ b/resources/licenses/ripgrep/PCRE2-LICENCE.md @@ -0,0 +1,104 @@ +PCRE2 Licence +============= + +| SPDX-License-Identifier: | BSD-3-Clause WITH PCRE2-exception | +|---------|-------| + +PCRE2 is a library of functions to support regular expressions whose syntax +and semantics are as close as possible to those of the Perl 5 language. + +Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD" +licence, as specified below, with one exemption for certain binary +redistributions. The documentation for PCRE2, supplied in the "doc" directory, +is distributed under the same terms as the software itself. The data in the +testdata directory is not copyrighted and is in the public domain. + +The basic library functions are written in C and are freestanding. Also +included in the distribution is a just-in-time compiler that can be used to +optimize pattern matching. This is an optional feature that can be omitted when +the library is built. The just-in-time compiler is separately licensed under the +"2-clause BSD" licence. + + +COPYRIGHT +--------- + +### The basic library functions + + Written by: Philip Hazel + Email local part: Philip.Hazel + Email domain: gmail.com + + Retired from University of Cambridge Computing Service, + Cambridge, England. + + Copyright (c) 1997-2007 University of Cambridge + Copyright (c) 2007-2024 Philip Hazel + All rights reserved. + +### PCRE2 Just-In-Time compilation support + + Written by: Zoltan Herczeg + Email local part: hzmester + Email domain: freemail.hu + + Copyright (c) 2010-2024 Zoltan Herczeg + All rights reserved. + +### Stack-less Just-In-Time compiler + + Written by: Zoltan Herczeg + Email local part: hzmester + Email domain: freemail.hu + + Copyright (c) 2009-2024 Zoltan Herczeg + All rights reserved. + +The code in the `deps/sljit` directory has its own LICENSE file. + +### All other contributions + +Many other contributors have participated in the authorship of PCRE2. As PCRE2 +has never required a Contributor Licensing Agreement, or other copyright +assignment agreement, all contributions have copyright retained by each +original contributor or their employer. + + +THE "BSD" LICENCE +----------------- + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notices, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notices, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of the University of Cambridge nor the names of any + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES +------------------------------------------ + +The second condition in the BSD licence (covering binary redistributions) does +not apply all the way down a chain of software. If binary package A includes +PCRE2, it must respect the condition, but if package B is software that +includes package A, the condition is not imposed on package B unless it uses +PCRE2 independently. diff --git a/resources/licenses/ripgrep/README.md b/resources/licenses/ripgrep/README.md new file mode 100644 index 000000000000..35e6067ea837 --- /dev/null +++ b/resources/licenses/ripgrep/README.md @@ -0,0 +1,28 @@ +# Bundled ripgrep notices + +Orca ships prebuilt ripgrep (`rg`) binaries from `@vscode/ripgrep-universal` under +`Resources/ripgrep/` and in the standalone runtime's `ripgrep/` directory, +for local, WSL, and SSH-remote search. + +- ripgrep: dual-licensed MIT (`LICENSE-MIT`) or Unlicense (`UNLICENSE`). +- PCRE2, statically linked into ripgrep's `--pcre2` support: BSD (`PCRE2-LICENCE.md`). +- musl libc, statically linked into the Linux builds: MIT (`MUSL-COPYRIGHT`). +- jemalloc, ripgrep's allocator on the musl builds: BSD-2-Clause (`JEMALLOC-COPYING`). +- LLVM libunwind, statically linked into the Linux builds by Rust's musl target: + Apache-2.0 WITH LLVM-exception (`LLVM-LIBUNWIND-LICENSE.TXT`). +- PCRE2's sljit compiler: BSD-2-Clause (`SLJIT-LICENSE`). +- Rust dependencies: `RUST-CRATE-NOTICES.txt`, sourced from the ripgrep 15.0.0 + `Cargo.lock` and checksum-verified crate archives. This includes build and test + dependencies as a conservative superset of the six shipped targets. MIT is + selected wherever offered; `ryu` uses BSL-1.0. Additional WHATWG, Unicode and + Crossbeam third-party notices are included. + +The sljit notice is copied from the `sljitLir.c` header in the checksum-verified +`pcre2-sys-0.2.10` archive (which omits the standalone sljit license file). + +When updating `@vscode/ripgrep-universal`, check the matching upstream lockfile +and refresh these notices from the exact crate archives; binary strings alone +cannot enumerate dependencies reliably. + +The jemalloc and libunwind notices apply to the Linux binaries only; `strings` finds their symbols in +`linux-x64` and `linux-arm64` and in neither the darwin nor win32 builds. diff --git a/resources/licenses/ripgrep/RUST-CRATE-NOTICES.txt b/resources/licenses/ripgrep/RUST-CRATE-NOTICES.txt new file mode 100644 index 000000000000..ac79c03559af --- /dev/null +++ b/resources/licenses/ripgrep/RUST-CRATE-NOTICES.txt @@ -0,0 +1,2098 @@ +Rust crate notices for ripgrep 15.0.0 (@vscode/ripgrep-universal 1.18.0). + +Source: https://github.com/BurntSushi/ripgrep/blob/15.0.0/Cargo.lock +Each crate archive was fetched from https://static.crates.io/crates/NAME/NAME-VERSION.crate +and verified against its Cargo.lock SHA-256 checksum. License texts below are copied +from those archives, with MIT elected wherever offered and BSL-1.0 elected for ryu. +Additional third-party terms are preserved alongside the elected license. + +This is a conservative superset: it includes build and test dependencies as well as +runtime dependencies for macOS, Linux and Windows. The EFI/WASI-only packages r-efi, +wasip2 and wit-bindgen are excluded because Orca ships no EFI or WASI ripgrep target. +Ripgrep's workspace crates use its MIT/Unlicense terms, supplied separately in +LICENSE-MIT and UNLICENSE. Native-library notices are supplied alongside this file. + +================================================================================ +aho-corasick 1.1.3 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +anyhow 1.0.100 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +arbitrary 1.4.2 +================================================================================ +LICENSE-MIT + +MIT License + +Copyright (c) 2019 Manish Goregaokar + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +bstr 1.12.0 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2018-2019 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +cc 1.2.41 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +cfg-if 1.0.4 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +crossbeam-channel 0.5.15 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +LICENSE-THIRD-PARTY + +=============================================================================== + +matching.go +https://creativecommons.org/licenses/by/3.0/legalcode + +Creative Commons Legal Code + +Attribution 3.0 Unported + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR + DAMAGES RESULTING FROM ITS USE. + +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE +COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY +COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS +AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE +TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY +BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS +CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND +CONDITIONS. + +1. Definitions + + a. "Adaptation" means a work based upon the Work, or upon the Work and + other pre-existing works, such as a translation, adaptation, + derivative work, arrangement of music or other alterations of a + literary or artistic work, or phonogram or performance and includes + cinematographic adaptations or any other form in which the Work may be + recast, transformed, or adapted including in any form recognizably + derived from the original, except that a work that constitutes a + Collection will not be considered an Adaptation for the purpose of + this License. For the avoidance of doubt, where the Work is a musical + work, performance or phonogram, the synchronization of the Work in + timed-relation with a moving image ("synching") will be considered an + Adaptation for the purpose of this License. + b. "Collection" means a collection of literary or artistic works, such as + encyclopedias and anthologies, or performances, phonograms or + broadcasts, or other works or subject matter other than works listed + in Section 1(f) below, which, by reason of the selection and + arrangement of their contents, constitute intellectual creations, in + which the Work is included in its entirety in unmodified form along + with one or more other contributions, each constituting separate and + independent works in themselves, which together are assembled into a + collective whole. A work that constitutes a Collection will not be + considered an Adaptation (as defined above) for the purposes of this + License. + c. "Distribute" means to make available to the public the original and + copies of the Work or Adaptation, as appropriate, through sale or + other transfer of ownership. + d. "Licensor" means the individual, individuals, entity or entities that + offer(s) the Work under the terms of this License. + e. "Original Author" means, in the case of a literary or artistic work, + the individual, individuals, entity or entities who created the Work + or if no individual or entity can be identified, the publisher; and in + addition (i) in the case of a performance the actors, singers, + musicians, dancers, and other persons who act, sing, deliver, declaim, + play in, interpret or otherwise perform literary or artistic works or + expressions of folklore; (ii) in the case of a phonogram the producer + being the person or legal entity who first fixes the sounds of a + performance or other sounds; and, (iii) in the case of broadcasts, the + organization that transmits the broadcast. + f. "Work" means the literary and/or artistic work offered under the terms + of this License including without limitation any production in the + literary, scientific and artistic domain, whatever may be the mode or + form of its expression including digital form, such as a book, + pamphlet and other writing; a lecture, address, sermon or other work + of the same nature; a dramatic or dramatico-musical work; a + choreographic work or entertainment in dumb show; a musical + composition with or without words; a cinematographic work to which are + assimilated works expressed by a process analogous to cinematography; + a work of drawing, painting, architecture, sculpture, engraving or + lithography; a photographic work to which are assimilated works + expressed by a process analogous to photography; a work of applied + art; an illustration, map, plan, sketch or three-dimensional work + relative to geography, topography, architecture or science; a + performance; a broadcast; a phonogram; a compilation of data to the + extent it is protected as a copyrightable work; or a work performed by + a variety or circus performer to the extent it is not otherwise + considered a literary or artistic work. + g. "You" means an individual or entity exercising rights under this + License who has not previously violated the terms of this License with + respect to the Work, or who has received express permission from the + Licensor to exercise rights under this License despite a previous + violation. + h. "Publicly Perform" means to perform public recitations of the Work and + to communicate to the public those public recitations, by any means or + process, including by wire or wireless means or public digital + performances; to make available to the public Works in such a way that + members of the public may access these Works from a place and at a + place individually chosen by them; to perform the Work to the public + by any means or process and the communication to the public of the + performances of the Work, including by public digital performance; to + broadcast and rebroadcast the Work by any means including signs, + sounds or images. + i. "Reproduce" means to make copies of the Work by any means including + without limitation by sound or visual recordings and the right of + fixation and reproducing fixations of the Work, including storage of a + protected performance or phonogram in digital form or other electronic + medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, +limit, or restrict any uses free from copyright or rights arising from +limitations or exceptions that are provided for in connection with the +copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, +Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +perpetual (for the duration of the applicable copyright) license to +exercise the rights in the Work as stated below: + + a. to Reproduce the Work, to incorporate the Work into one or more + Collections, and to Reproduce the Work as incorporated in the + Collections; + b. to create and Reproduce Adaptations provided that any such Adaptation, + including any translation in any medium, takes reasonable steps to + clearly label, demarcate or otherwise identify that changes were made + to the original Work. For example, a translation could be marked "The + original work was translated from English to Spanish," or a + modification could indicate "The original work has been modified."; + c. to Distribute and Publicly Perform the Work including as incorporated + in Collections; and, + d. to Distribute and Publicly Perform Adaptations. + e. For the avoidance of doubt: + + i. Non-waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme cannot be waived, the Licensor + reserves the exclusive right to collect such royalties for any + exercise by You of the rights granted under this License; + ii. Waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme can be waived, the Licensor waives the + exclusive right to collect such royalties for any exercise by You + of the rights granted under this License; and, + iii. Voluntary License Schemes. The Licensor waives the right to + collect royalties, whether individually or, in the event that the + Licensor is a member of a collecting society that administers + voluntary licensing schemes, via that society, from any exercise + by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now +known or hereafter devised. The above rights include the right to make +such modifications as are technically necessary to exercise the rights in +other media and formats. Subject to Section 8(f), all rights not expressly +granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made +subject to and limited by the following restrictions: + + a. You may Distribute or Publicly Perform the Work only under the terms + of this License. You must include a copy of, or the Uniform Resource + Identifier (URI) for, this License with every copy of the Work You + Distribute or Publicly Perform. You may not offer or impose any terms + on the Work that restrict the terms of this License or the ability of + the recipient of the Work to exercise the rights granted to that + recipient under the terms of the License. You may not sublicense the + Work. You must keep intact all notices that refer to this License and + to the disclaimer of warranties with every copy of the Work You + Distribute or Publicly Perform. When You Distribute or Publicly + Perform the Work, You may not impose any effective technological + measures on the Work that restrict the ability of a recipient of the + Work from You to exercise the rights granted to that recipient under + the terms of the License. This Section 4(a) applies to the Work as + incorporated in a Collection, but this does not require the Collection + apart from the Work itself to be made subject to the terms of this + License. If You create a Collection, upon notice from any Licensor You + must, to the extent practicable, remove from the Collection any credit + as required by Section 4(b), as requested. If You create an + Adaptation, upon notice from any Licensor You must, to the extent + practicable, remove from the Adaptation any credit as required by + Section 4(b), as requested. + b. If You Distribute, or Publicly Perform the Work or any Adaptations or + Collections, You must, unless a request has been made pursuant to + Section 4(a), keep intact all copyright notices for the Work and + provide, reasonable to the medium or means You are utilizing: (i) the + name of the Original Author (or pseudonym, if applicable) if supplied, + and/or if the Original Author and/or Licensor designate another party + or parties (e.g., a sponsor institute, publishing entity, journal) for + attribution ("Attribution Parties") in Licensor's copyright notice, + terms of service or by other reasonable means, the name of such party + or parties; (ii) the title of the Work if supplied; (iii) to the + extent reasonably practicable, the URI, if any, that Licensor + specifies to be associated with the Work, unless such URI does not + refer to the copyright notice or licensing information for the Work; + and (iv) , consistent with Section 3(b), in the case of an Adaptation, + a credit identifying the use of the Work in the Adaptation (e.g., + "French translation of the Work by Original Author," or "Screenplay + based on original Work by Original Author"). The credit required by + this Section 4 (b) may be implemented in any reasonable manner; + provided, however, that in the case of a Adaptation or Collection, at + a minimum such credit will appear, if a credit for all contributing + authors of the Adaptation or Collection appears, then as part of these + credits and in a manner at least as prominent as the credits for the + other contributing authors. For the avoidance of doubt, You may only + use the credit required by this Section for the purpose of attribution + in the manner set out above and, by exercising Your rights under this + License, You may not implicitly or explicitly assert or imply any + connection with, sponsorship or endorsement by the Original Author, + Licensor and/or Attribution Parties, as appropriate, of You or Your + use of the Work, without the separate, express prior written + permission of the Original Author, Licensor and/or Attribution + Parties. + c. Except as otherwise agreed in writing by the Licensor or as may be + otherwise permitted by applicable law, if You Reproduce, Distribute or + Publicly Perform the Work either by itself or as part of any + Adaptations or Collections, You must not distort, mutilate, modify or + take other derogatory action in relation to the Work which would be + prejudicial to the Original Author's honor or reputation. Licensor + agrees that in those jurisdictions (e.g. Japan), in which any exercise + of the right granted in Section 3(b) of this License (the right to + make Adaptations) would be deemed to be a distortion, mutilation, + modification or other derogatory action prejudicial to the Original + Author's honor and reputation, the Licensor will waive or not assert, + as appropriate, this Section, to the fullest extent permitted by the + applicable national law, to enable You to reasonably exercise Your + right under Section 3(b) of this License (right to make Adaptations) + but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR +OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY +KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, +INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, +FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF +LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, +WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION +OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE +LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR +ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES +ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS +BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + + a. This License and the rights granted hereunder will terminate + automatically upon any breach by You of the terms of this License. + Individuals or entities who have received Adaptations or Collections + from You under this License, however, will not have their licenses + terminated provided such individuals or entities remain in full + compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will + survive any termination of this License. + b. Subject to the above terms and conditions, the license granted here is + perpetual (for the duration of the applicable copyright in the Work). + Notwithstanding the above, Licensor reserves the right to release the + Work under different license terms or to stop distributing the Work at + any time; provided, however that any such election will not serve to + withdraw this License (or any other license that has been, or is + required to be, granted under the terms of this License), and this + License will continue in full force and effect unless terminated as + stated above. + +8. Miscellaneous + + a. Each time You Distribute or Publicly Perform the Work or a Collection, + the Licensor offers to the recipient a license to the Work on the same + terms and conditions as the license granted to You under this License. + b. Each time You Distribute or Publicly Perform an Adaptation, Licensor + offers to the recipient a license to the original Work on the same + terms and conditions as the license granted to You under this License. + c. If any provision of this License is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this License, and without further action + by the parties to this agreement, such provision shall be reformed to + the minimum extent necessary to make such provision valid and + enforceable. + d. No term or provision of this License shall be deemed waived and no + breach consented to unless such waiver or consent shall be in writing + and signed by the party to be charged with such waiver or consent. + e. This License constitutes the entire agreement between the parties with + respect to the Work licensed here. There are no understandings, + agreements or representations with respect to the Work not specified + here. Licensor shall not be bound by any additional provisions that + may appear in any communication from You. This License may not be + modified without the mutual written agreement of the Licensor and You. + f. The rights granted under, and the subject matter referenced, in this + License were drafted utilizing the terminology of the Berne Convention + for the Protection of Literary and Artistic Works (as amended on + September 28, 1979), the Rome Convention of 1961, the WIPO Copyright + Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 + and the Universal Copyright Convention (as revised on July 24, 1971). + These rights and subject matter take effect in the relevant + jurisdiction in which the License terms are sought to be enforced + according to the corresponding provisions of the implementation of + those treaty provisions in the applicable national law. If the + standard suite of rights granted under applicable copyright law + includes additional rights not granted under this License, such + additional rights are deemed to be included in the License; this + License is not intended to restrict the license of any rights under + applicable law. + + +Creative Commons Notice + + Creative Commons is not a party to this License, and makes no warranty + whatsoever in connection with the Work. Creative Commons will not be + liable to You or any party on any legal theory for any damages + whatsoever, including without limitation any general, special, + incidental or consequential damages arising in connection to this + license. Notwithstanding the foregoing two (2) sentences, if Creative + Commons has expressly identified itself as the Licensor hereunder, it + shall have all rights and obligations of Licensor. + + Except for the limited purpose of indicating to the public that the + Work is licensed under the CCPL, Creative Commons does not authorize + the use by either party of the trademark "Creative Commons" or any + related trademark or logo of Creative Commons without the prior + written consent of Creative Commons. Any permitted use will be in + compliance with Creative Commons' then-current trademark usage + guidelines, as may be published on its website or otherwise made + available upon request from time to time. For the avoidance of doubt, + this trademark restriction does not form part of this License. + + Creative Commons may be contacted at https://creativecommons.org/. + +=============================================================================== + +The Go Programming Language +https://golang.org/LICENSE + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +=============================================================================== + +The Rust Programming Language +https://github.com/rust-lang/rust/blob/master/LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +=============================================================================== + +The Rust Programming Language +https://github.com/rust-lang/rust/blob/master/LICENSE-APACHE + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ +crossbeam-deque 0.8.6 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +crossbeam-epoch 0.9.18 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +crossbeam-utils 0.8.21 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +derive_arbitrary 1.4.2 +================================================================================ +LICENSE-MIT + +MIT License + +Copyright (c) 2019 Manish Goregaokar + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +encoding_rs 0.8.35 +================================================================================ +LICENSE-MIT + +Copyright Mozilla Foundation + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +LICENSE-WHATWG + +Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ +encoding_rs_io 0.1.7 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +find-msvc-tools 0.1.4 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +getrandom 0.3.4 +================================================================================ +LICENSE-MIT + +Copyright (c) 2018-2025 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +glob 0.3.3 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +itoa 1.0.15 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +jobserver 0.1.34 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +lexopt 0.3.1 +================================================================================ +LICENSE + +Copyright (c) 2021 Jan Verbeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +libc 0.2.177 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014-2020 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +log 0.4.28 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +memchr 2.7.6 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +memmap2 0.9.8 +================================================================================ +LICENSE-MIT + +Copyright (c) 2020 Yevhenii Reizner +Copyright (c) 2015 Dan Burkert + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +pcre2 0.2.11 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2017 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +pcre2-sys 0.2.10 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2017 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +pkg-config 0.3.32 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +proc-macro2 1.0.101 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +quote 1.0.41 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +regex 1.12.2 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +regex-automata 0.4.13 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +regex-syntax 0.8.8 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +ryu 1.0.20 +================================================================================ +LICENSE-BOOST + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +same-file 1.0.6 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2017 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +serde 1.0.228 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +serde_core 1.0.228 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +serde_derive 1.0.228 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +serde_json 1.0.145 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +shlex 1.3.0 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2015 Nicholas Allegra (comex). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +syn 2.0.106 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +termcolor 1.4.1 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +textwrap 0.16.2 +================================================================================ +LICENSE + +MIT License + +Copyright (c) 2016 Martin Geisler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +tikv-jemalloc-sys 0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +tikv-jemallocator 0.6.0 +================================================================================ +LICENSE-MIT + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +unicode-ident 1.0.19 +================================================================================ +LICENSE-MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +LICENSE-UNICODE + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +================================================================================ +walkdir 2.5.0 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +winapi-util 0.1.11 +================================================================================ +LICENSE-MIT + +The MIT License (MIT) + +Copyright (c) 2017 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +windows-link 0.2.1 +================================================================================ +license-mit + + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +================================================================================ +windows-sys 0.61.2 +================================================================================ +license-mit + + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/resources/licenses/ripgrep/SLJIT-LICENSE b/resources/licenses/ripgrep/SLJIT-LICENSE new file mode 100644 index 000000000000..a9a4bded0626 --- /dev/null +++ b/resources/licenses/ripgrep/SLJIT-LICENSE @@ -0,0 +1,23 @@ +Stack-less Just-In-Time compiler + + Copyright Zoltan Herczeg (hzmester@freemail.hu). All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, this list + of conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND CONTRIBUTORS ``AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE COPYRIGHT HOLDER(S) OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/resources/licenses/ripgrep/UNLICENSE b/resources/licenses/ripgrep/UNLICENSE new file mode 100644 index 000000000000..68a49daad8ff --- /dev/null +++ b/resources/licenses/ripgrep/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/src/main/git/command-runner/wsl-aware-spawn.ts b/src/main/git/command-runner/wsl-aware-spawn.ts index 2b18d26424f8..ff87c4bee5dd 100644 --- a/src/main/git/command-runner/wsl-aware-spawn.ts +++ b/src/main/git/command-runner/wsl-aware-spawn.ts @@ -9,11 +9,20 @@ import { resolveCommand } from './wsl-command-resolution' export function wslAwareSpawn( command: string, args: string[], - options: SpawnOptions & { cwd?: string; wslDistro?: string; useWslLoginShell?: boolean } + options: SpawnOptions & { + cwd?: string + wslDistro?: string + useWslLoginShell?: boolean + wslShellCommand?: string + cwdFailureExitCode?: number + } ): ChildProcess { - const { wslDistro, useWslLoginShell, ...spawnOptions } = options + const { wslDistro, useWslLoginShell, wslShellCommand, cwdFailureExitCode, ...spawnOptions } = + options const resolved = resolveCommand(command, args, options.cwd, wslDistro, { - useWslLoginShell + useWslLoginShell, + wslShellCommand, + ...(cwdFailureExitCode === undefined ? {} : { cwdFailureExitCode }) }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { diff --git a/src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts b/src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts new file mode 100644 index 000000000000..4a1c6e2a0bd5 --- /dev/null +++ b/src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { resolveCommand } from './wsl-command-resolution' + +const originalPlatform = process.platform +const UNC_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo' + +describe('resolveCommand wslShellCommand', () => { + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + + it('uses the pre-quoted shell expression in place of the command inside WSL', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + const resolved = resolveCommand('rg', ['--files'], UNC_REPO, undefined, { + wslShellCommand: '"$(pick-rg)"' + }) + + expect(resolved.binary).toBe('wsl.exe') + expect(resolved.args.at(-1)).toBe(`cd '/home/dev/repo' && "$(pick-rg)" '--files'`) + }) + + it('keeps bare commands quoted when no expression is given', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + const resolved = resolveCommand('rg', ['--files'], UNC_REPO) + + expect(resolved.args.at(-1)).toBe(`cd '/home/dev/repo' && 'rg' '--files'`) + }) +}) diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index 559e1821bd12..cb5d9de41f43 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -67,6 +67,14 @@ export function resolveCommand( wslGitReadEnvironment?: WslGitReadEnvironment env?: NodeJS.ProcessEnv terminationBarrier?: boolean + /** Pre-quoted shell expression that names the program inside WSL, replacing `command`. */ + wslShellCommand?: string + /** + * Exit with this code when the `cd` fails, instead of letting `&&` swallow it as the shell's + * own exit 1. Why: ripgrep also exits 1 for "no matches", so an unreachable workspace would + * otherwise be indistinguishable from an empty result. + */ + cwdFailureExitCode?: number } = {} ): ResolvedCommand { if (process.platform !== 'win32') { @@ -85,14 +93,18 @@ export function resolveCommand( const translatedArgs = translateArgsForWsl(args) // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' - const escapedCommand = quotePosixShell(command) + const escapedCommand = options.wslShellCommand ?? quotePosixShell(command) // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. const escapedArgs = translatedArgs.map(quotePosixShell) // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) - const shellCmd = linuxCwd - ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` - : `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + const invocation = `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + const enterCwd = linuxCwd + ? options.cwdFailureExitCode === undefined + ? `cd ${quotePosixShell(linuxCwd)} && ` + : `cd ${quotePosixShell(linuxCwd)} || exit ${Math.trunc(options.cwdFailureExitCode)}; ` + : '' + const shellCmd = `${enterCwd}${invocation}` if (command === 'git' && options.wslGitReadEnvironment) { const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS diff --git a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts index 1af46a510696..18ebe1878baf 100644 --- a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts +++ b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts @@ -2,8 +2,6 @@ import { execFileSync } from 'node:child_process' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback' -import { searchWithGitGrep } from '../ipc/filesystem-search-git' import { gitExecFileAsync, gitExecFileAsyncBuffer, @@ -66,15 +64,7 @@ describe.runIf(process.platform === 'win32' && Boolean(distro))( ) ).toThrow(/not a git repository/i) - await expect(listFilesWithGit(linkedPath, [], { wslDistro: distro! })).resolves.toEqual( - expect.arrayContaining(['tracked.txt', 'untracked.txt']) - ) resetWslLinkedWorktreeGitRoutingForTests() - await expect( - searchWithGitGrep(linkedPath, { query: 'tracked', rootPath: linkedPath }, 10, { - wslDistro: distro! - }) - ).resolves.toMatchObject({ totalMatches: 2 }) let streamedStatus = '' await expect( diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts deleted file mode 100644 index 8d3601d5baaa..000000000000 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { gitSpawnMock } = vi.hoisted(() => ({ - gitSpawnMock: vi.fn() -})) - -vi.mock('../git/runner', () => ({ - gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock -})) - -import { listFilesWithGit } from './filesystem-list-files-git-fallback' -import { isFileListingCancellation } from '../../shared/file-listing-cancellation' - -const tempDirs: string[] = [] -const SHA1 = '0123456789abcdef0123456789abcdef01234567' - -function createMockProcess(): ChildProcess { - const process = new EventEmitter() as unknown as ChildProcess - ;(process as unknown as Record).stdout = new EventEmitter() - ;( - (process as unknown as Record).stdout as EventEmitter & { - setEncoding: () => void - } - ).setEncoding = vi.fn() - ;(process as unknown as Record).stderr = new EventEmitter() - ;(process as unknown as Record).kill = vi.fn() - ;(process as unknown as Record).exitCode = null - ;(process as unknown as Record).signalCode = null - return process -} - -async function writeRel(root: string, relPath: string): Promise { - const absPath = join(root, ...relPath.split('/')) - await mkdir(dirname(absPath), { recursive: true }) - await writeFile(absPath, 'x') -} - -afterEach(async () => { - vi.clearAllMocks() - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) -}) - -describe('main Quick Open git directory expansion', () => { - it('expands placeholders emitted by both directory-collapsing passes', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-main-git-ignored-dir-')) - tempDirs.push(root) - await writeRel(root, 'dist/generated.js') - await writeRel(root, 'scratch/notes.txt') - - const revParse = createMockProcess() - const primary = createMockProcess() - const ignored = createMockProcess() - gitSpawnMock - .mockResolvedValueOnce(revParse) - .mockResolvedValueOnce(primary) - .mockResolvedValueOnce(ignored) - - const promise = listFilesWithGit(root, [], { wslDistro: 'Ubuntu' }) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) - revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) - ;(primary.stdout as unknown as EventEmitter).emit( - 'data', - `100644 ${SHA1} 0\tsrc/index.ts\0scratch/\0` - ) - primary.emit('close', 0, null) - ;(ignored.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ignored.emit('close', 0, null) - - await expect(promise).resolves.toEqual([ - 'dist/generated.js', - 'scratch/notes.txt', - 'src/index.ts' - ]) - expect(gitSpawnMock.mock.calls[1][1]).toEqual( - expect.objectContaining({ admissionTier: 'interactive', wslDistro: 'Ubuntu' }) - ) - expect(gitSpawnMock.mock.calls[2][1]).toEqual( - expect.objectContaining({ admissionTier: 'interactive', wslDistro: 'Ubuntu' }) - ) - expect(gitSpawnMock.mock.calls[2][0]).toContain('--directory') - }) - - it('cancels both local Git passes when Quick Open abandons the request', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-main-git-cancel-')) - tempDirs.push(root) - const revParse = createMockProcess() - const primary = createMockProcess() - const ignored = createMockProcess() - gitSpawnMock - .mockResolvedValueOnce(revParse) - .mockResolvedValueOnce(primary) - .mockResolvedValueOnce(ignored) - - const controller = new AbortController() - const promise = listFilesWithGit(root, [], {}, controller.signal) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) - revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) - controller.abort() - - await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).toHaveBeenCalled() - }) - - it('kills a repository probe returned after cancellation', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-main-git-probe-race-')) - tempDirs.push(root) - const revParse = createMockProcess() - let resolveRevParse!: (child: ChildProcess) => void - gitSpawnMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveRevParse = resolve - }) - ) - - const controller = new AbortController() - const promise = listFilesWithGit(root, [], {}, controller.signal) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce()) - controller.abort() - resolveRevParse(revParse) - - await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(revParse.kill).toHaveBeenCalled() - expect(revParse.listenerCount('close')).toBe(0) - }) - - it('kills file scans returned after cancellation', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-main-git-scan-race-')) - tempDirs.push(root) - const revParse = createMockProcess() - const primary = createMockProcess() - const ignored = createMockProcess() - let resolvePrimary!: (child: ChildProcess) => void - let resolveIgnored!: (child: ChildProcess) => void - gitSpawnMock - .mockResolvedValueOnce(revParse) - .mockReturnValueOnce( - new Promise((resolve) => { - resolvePrimary = resolve - }) - ) - .mockReturnValueOnce( - new Promise((resolve) => { - resolveIgnored = resolve - }) - ) - - const controller = new AbortController() - const promise = listFilesWithGit(root, [], {}, controller.signal) - await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) - revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) - controller.abort() - resolvePrimary(primary) - resolveIgnored(ignored) - - await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).toHaveBeenCalled() - }) - - it('cancels a pending sibling spawn when the primary scan fails', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-main-git-sibling-failure-')) - tempDirs.push(root) - const revParse = createMockProcess() - const primary = createMockProcess() - let pendingSignal: AbortSignal | undefined - gitSpawnMock - .mockResolvedValueOnce(revParse) - .mockResolvedValueOnce(primary) - .mockImplementationOnce( - (_args: string[], options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - pendingSignal = options.signal - options.signal?.addEventListener( - 'abort', - () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), - { once: true } - ) - }) - ) - - const promise = listFilesWithGit(root, [], {}) - await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) - revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) - primary.emit('error', new Error('primary failed')) - - await expect(promise).rejects.toThrow('primary failed') - expect(pendingSignal?.aborted).toBe(true) - }) -}) diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts deleted file mode 100644 index 69dde7afaf3b..000000000000 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type * as GitRunner from '../git/runner' - -const { wslAwareSpawnMock } = vi.hoisted(() => ({ - wslAwareSpawnMock: vi.fn() -})) - -vi.mock('../git/runner', async (importOriginal) => ({ - ...(await importOriginal()), - wslAwareSpawn: wslAwareSpawnMock -})) - -import { listQuickOpenFiles } from './filesystem-list-files' - -const execFile = promisify(execFileCallback) - -function makeStore(repoPath: string): Store { - return { - getRepos: () => [ - { - id: 'repo-1', - path: repoPath, - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0, - kind: 'git' - } - ], - getSettings: () => ({}) - } as unknown as Store -} - -async function writeRel(root: string, relPath: string, content = 'x'): Promise { - const absPath = join(root, ...relPath.split('/')) - await mkdir(dirname(absPath), { recursive: true }) - await writeFile(absPath, content) -} - -async function initRepo(repoPath: string): Promise { - await mkdir(repoPath, { recursive: true }) - await execFile('git', ['init', '-q', repoPath]) - await execFile('git', ['config', 'user.email', 'orca@example.invalid'], { cwd: repoPath }) - await execFile('git', ['config', 'user.name', 'Orca Test'], { cwd: repoPath }) -} - -describe('filesystem-list-files real git fallback', () => { - let tempDir: string | null = null - - beforeEach(() => { - wslAwareSpawnMock.mockImplementation( - (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => - spawn('orca-definitely-missing-rg', [], { - cwd: options.cwd, - stdio: options.stdio - }) - ) - }) - - afterEach(async () => { - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }) - tempDir = null - } - vi.clearAllMocks() - }) - - it('returns real paths for UTF-8 filenames from the git fallback', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) - const repoPath = join(tempDir, 'repo') - await execFile('git', ['init', '-q', repoPath]) - const utf8FileName = '日本語-file.txt' - await writeFile(join(repoPath, utf8FileName), 'content') - await execFile('git', ['add', '.'], { cwd: repoPath }) - - await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([utf8FileName]) - }) - - it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-')) - const repoPath = join(tempDir, 'parent') - const appPath = join(repoPath, 'packages', 'app') - const libPath = join(repoPath, 'packages', 'lib') - await initRepo(repoPath) - await writeRel(repoPath, 'README.md') - await writeRel(repoPath, 'src/index.ts') - await execFile('git', ['add', 'README.md', 'src/index.ts'], { cwd: repoPath }) - - await initRepo(appPath) - await writeRel(appPath, 'package.json', '{}') - await writeRel(appPath, 'src/main.ts') - await writeRel(appPath, 'node_modules/pkg/index.js') - await execFile('git', ['add', '.'], { cwd: appPath }) - await execFile('git', ['commit', '-qm', 'init'], { cwd: appPath }) - const { stdout: appSha } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: appPath }) - await execFile( - 'git', - ['update-index', '--add', '--cacheinfo', `160000,${appSha.trim()},packages/app`], - { cwd: repoPath } - ) - - await initRepo(libPath) - await writeRel(libPath, 'package.json', '{}') - await writeRel(libPath, 'src/lib.ts') - - const result = await listQuickOpenFiles(repoPath, makeStore(repoPath)) - - expect(result).toEqual( - expect.arrayContaining([ - 'README.md', - 'src/index.ts', - 'packages/app/package.json', - 'packages/app/src/main.ts', - 'packages/lib/package.json', - 'packages/lib/src/lib.ts' - ]) - ) - expect(result).not.toContain('packages/app') - expect(result).not.toContain('packages/lib') - expect(result).not.toContain('packages/app/node_modules/pkg/index.js') - expect(result).not.toContain('packages/app/.git/config') - }) - - it('walks a non-git root instead of returning an empty git fallback result', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-')) - await writeRel(tempDir, 'folder/file.ts') - - await expect(listQuickOpenFiles(tempDir, makeStore(tempDir))).resolves.toEqual([ - 'folder/file.ts' - ]) - }) - - it('bounds a non-git readdir fallback without treating the limit as an error', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) - await writeRel(tempDir, 'a.ts') - await writeRel(tempDir, 'b.ts') - - const files = await listQuickOpenFiles(tempDir, makeStore(tempDir), undefined, undefined, 1) - - expect(files).toHaveLength(1) - expect(['a.ts', 'b.ts']).toContain(files[0]) - }) - - it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) - const repoPath = join(tempDir, 'repo') - await initRepo(repoPath) - await writeFile(join(repoPath, '.git', 'index'), 'not a git index') - - await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).rejects.toThrow( - 'git ls-files exited with code' - ) - }) - - it('resolves an empty repo as an empty list', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-')) - const repoPath = join(tempDir, 'repo') - await initRepo(repoPath) - - await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([]) - }) -}) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts deleted file mode 100644 index 28cee2a4d50a..000000000000 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ /dev/null @@ -1,335 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' -import { - isWslLinkedWorktreeGitRoutingCandidate, - prepareWslLinkedWorktreeGitRouting -} from '../git/wsl-linked-worktree-git-routing' -import { - buildGitLsFilesArgsForQuickOpen, - shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath -} from '../../shared/quick-open-filter' -import { - createQuickOpenReaddirBudget, - expandQuickOpenGitFileListing, - listQuickOpenFilesWithReaddir, - parseQuickOpenGitLsFilesEntry -} from '../../shared/quick-open-readdir-walk' -import { fileListingCancellationError } from '../../shared/file-listing-cancellation' - -/** - * Fallback file lister using git ls-files. Used when rg is not available. - * - * Why two git ls-files calls: the first lists tracked + untracked-but-not-ignored - * files (mirrors rg --files --hidden with gitignore respect). The second - * surfaces ignored files (mirrors the second rg call with --no-ignore-vcs). - */ -async function isInsideGitWorkTree( - rootPath: string, - localGitOptions: { wslDistro?: string }, - signal?: AbortSignal -): Promise { - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { - try { - await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal }) - } catch (error) { - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - throw error - } - } - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - const child = await gitSpawnAfterWindowsEnvironmentReady(['rev-parse', '--is-inside-work-tree'], { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - ...(signal ? { signal } : {}), - stdio: ['ignore', 'ignore', 'ignore'] - }) - return new Promise((resolve, reject) => { - let done = false - let timer: ReturnType - const cleanup = (): void => { - clearTimeout(timer) - child.off('error', handleError) - child.off('close', handleClose) - signal?.removeEventListener('abort', handleAbort) - } - const finish = (isGitRepo: boolean): void => { - if (done) { - return - } - done = true - cleanup() - resolve(isGitRepo) - } - const cancel = (): void => { - if (done) { - return - } - done = true - child.kill() - cleanup() - reject(fileListingCancellationError(signal)) - } - const handleError = (): void => finish(false) - const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => - finish(code === 0 && signal === null) - const handleAbort = (): void => cancel() - - child.once('error', handleError) - child.once('close', handleClose) - signal?.addEventListener('abort', handleAbort, { once: true }) - timer = setTimeout(() => { - child.kill() - finish(false) - }, 10_000) - if (signal?.aborted) { - cancel() - } - }) -} - -export async function listFilesWithGit( - rootPath: string, - excludePathPrefixes: readonly string[], - localGitOptions: { wslDistro?: string }, - signal?: AbortSignal, - maxResults?: number -): Promise { - const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - if (!isGitWorkTree) { - return listQuickOpenFilesWithReaddir(rootPath, { - excludePathPrefixes, - budget: createQuickOpenReaddirBudget(), - maxResults, - signal - }) - } - - const gitPaths = new Set() - const directoryPaths = new Set() - const directFileCandidates = new Set() - const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) - const children: { - child: ChildProcess - isDone: () => boolean - reject: (error: Error) => void - resolve: () => void - }[] = [] - const scanController = new AbortController() - - const runGitLsFiles = async (args: string[]): Promise => { - // Why: git ls-files outputs paths relative to cwd, so we set cwd to - // rootPath and use the output directly — no prefix stripping needed. - const child = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], { - cwd: rootPath, - admissionTier: 'interactive', - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - signal: scanController.signal, - stdio: ['ignore', 'pipe', 'pipe'] - }) - return new Promise((resolve, reject) => { - let buf = '' - let done = false - - const processPath = (path: string): boolean => { - if (!path) { - return false - } - if (path.endsWith('/')) { - directoryPaths.add(path) - } else { - gitPaths.add(path) - if (maxResults !== undefined) { - // Why: this duplicate classification exists only to stop bounded - // scans; unbounded scans must not retain a second repo-sized set. - const parsed = parseQuickOpenGitLsFilesEntry(path) - const relPath = parsed.path.replace(/\/+$/, '') - if ( - !parsed.isGitlink && - !parsed.isUntrackedDir && - shouldIncludeQuickOpenPath(relPath) && - !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) - ) { - directFileCandidates.add(relPath) - } - } - } - // Why: collapsed directories and gitlinks may be discarded during the - // later filesystem classification, so they cannot consume the stop cap. - return maxResults !== undefined && directFileCandidates.size >= maxResults - } - - let timer: ReturnType - const cleanup = (): void => { - clearTimeout(timer) - // Why: child.kill() is advisory. If git ignores it, detach our - // closures so repeated Quick Open attempts do not retain old scans. - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - } - const rejectPass = (err: Error): void => { - if (done) { - return - } - done = true - buf = '' - cleanup() - reject(err) - } - const resolvePass = (): void => { - if (done) { - return - } - done = true - cleanup() - resolve() - } - children.push({ - child, - isDone: () => done, - reject: rejectPass, - resolve: resolvePass - }) - const handleStdoutData = (chunk: string): void => { - buf += chunk - let start = 0 - let nulIdx = buf.indexOf('\0', start) - while (nulIdx !== -1) { - if (processPath(buf.substring(start, nulIdx))) { - buf = '' - finishAtLimit() - return - } - start = nulIdx + 1 - nulIdx = buf.indexOf('\0', start) - } - buf = start < buf.length ? buf.substring(start) : '' - } - const handleStderrData = (): void => { - /* drain */ - } - const handleError = (err: Error): void => { - rejectPass(err) - } - const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if (done) { - return - } - if (signal) { - rejectPass(new Error(`git ls-files killed by ${signal}`)) - return - } - if (buf && processPath(buf)) { - buf = '' - finishAtLimit() - return - } - if (code === 0) { - resolvePass() - return - } - rejectPass(new Error(`git ls-files exited with code ${code}`)) - } - - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) - child.once('error', handleError) - child.once('close', handleClose) - timer = setTimeout(() => { - buf = '' - child.kill() - rejectPass(new Error('git ls-files timed out')) - }, 10000) - if (scanController.signal.aborted) { - child.kill() - rejectPass(fileListingCancellationError(scanController.signal)) - } - }) - } - - const killSurvivors = (reason = 'git ls-files canceled after sibling failure'): void => { - // Why: Promise.all rejects on the first failed pass; cancel the sibling so - // a stuck git process cannot keep scanning after Quick Open has failed. - for (const entry of children) { - if (entry.isDone()) { - continue - } - if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() - } - entry.reject(new Error(reason)) - } - } - - function finishAtLimit(): void { - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.resolve() - if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() - } - } - } - - const onAbort = (): void => { - scanController.abort(signal?.reason) - killSurvivors('git ls-files cancelled') - } - signal?.addEventListener('abort', onAbort, { once: true }) - try { - const runIgnoredPass = () => - // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs. - runGitLsFiles(ignoredPass).catch((err: Error) => { - if (!scanController.signal.aborted) { - console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) - } - }) - if (maxResults === undefined) { - await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) - } else { - // Why: give ordinary source files first claim on a bounded autocomplete - // inventory; a large ignored tree must not win a parallel-output race. - await runGitLsFiles(primary) - if (directFileCandidates.size < maxResults) { - await runIgnoredPass() - } - } - } catch (err) { - scanController.abort(err) - killSurvivors() - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - throw err - } finally { - signal?.removeEventListener('abort', onAbort) - } - - const files = await expandQuickOpenGitFileListing({ - rootPath, - gitPaths, - directoryPaths, - excludePathPrefixes, - signal, - maxResults - }) - // Why: directory placeholders are expanded after Git exits; restore Git's - // path order so empty queries and fuzzy-score ties remain stable. - return files.sort().slice(0, maxResults) -} diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts deleted file mode 100644 index f0221d8e24ee..000000000000 --- a/src/main/ipc/filesystem-list-files-install-rg.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' -import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type * as GitRunner from '../git/runner' - -const { - listFilesWithGitMock, - resolveAuthorizedPathMock, - checkRgAvailableMock, - getLocalGitOptionsForRegisteredWorktreeMock, - wslAwareSpawnMock -} = vi.hoisted(() => ({ - listFilesWithGitMock: vi.fn(), - resolveAuthorizedPathMock: vi.fn(), - checkRgAvailableMock: vi.fn(), - getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), - wslAwareSpawnMock: vi.fn() -})) - -vi.mock('./filesystem-list-files-git-fallback', () => ({ - listFilesWithGit: listFilesWithGitMock -})) - -vi.mock('./filesystem-auth', () => ({ - resolveAuthorizedPath: resolveAuthorizedPathMock -})) - -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock -})) - -vi.mock('./local-worktree-runtime-options', () => ({ - getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock -})) - -vi.mock('../git/runner', async (importOriginal) => ({ - ...(await importOriginal()), - wslAwareSpawn: wslAwareSpawnMock -})) - -import { listQuickOpenFiles } from './filesystem-list-files' - -function createStartedRipgrepProcess(): ChildProcess { - const child = new EventEmitter() as ChildProcess - ;(child as unknown as Record).stdout = new EventEmitter() - ;( - (child as unknown as Record).stdout as EventEmitter & { - setEncoding: () => void - } - ).setEncoding = vi.fn() - ;(child as unknown as Record).stderr = new EventEmitter() - ;(child as unknown as Record).kill = vi.fn() - ;(child as unknown as Record).exitCode = null - ;(child as unknown as Record).signalCode = null - Object.defineProperty(child, 'pid', { value: 1 }) - return child -} - -describe('filesystem-list-files ripgrep guidance', () => { - beforeEach(() => { - vi.clearAllMocks() - resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(true) - getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) - wslAwareSpawnMock.mockImplementation( - (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => - spawn('orca-definitely-missing-rg', [], { stdio: options.stdio }) - ) - }) - - it('turns only a readdir budget failure into install guidance', async () => { - listFilesWithGitMock.mockRejectedValue(new Error('File listing exceeded 10000 files')) - const rejection = listQuickOpenFiles('/workspace', {} as Store) - - await expect(rejection).rejects.toThrow( - 'Quick Open scan too large (File listing exceeded 10000 files).' - ) - await rejection.catch((error: Error) => - expect(error.message).toContain('Install ripgrep on the host running the Quick Open scan') - ) - }) - - it('keeps the WSL preflight and falls back before starting real rg', async () => { - getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - checkRgAvailableMock.mockResolvedValue(false) - listFilesWithGitMock.mockResolvedValue(['src/index.ts']) - - await expect(listQuickOpenFiles('C:\\repo', {} as Store)).resolves.toEqual(['src/index.ts']) - expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') - expect(wslAwareSpawnMock).not.toHaveBeenCalled() - }) - - it("falls back when a native launcher exits outside ripgrep's contract", async () => { - const primary = createStartedRipgrepProcess() - const ignored = createStartedRipgrepProcess() - let callIndex = 0 - wslAwareSpawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) - listFilesWithGitMock.mockResolvedValue(['src/index.ts']) - - const listing = listQuickOpenFiles('/workspace', {} as Store) - await Promise.resolve() - primary.emit('close', 127, null) - - await expect(listing).resolves.toEqual(['src/index.ts']) - expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) - }) - - it('keeps cancellation and Git errors unchanged', async () => { - const cancellation = new Error('File listing cancelled') - listFilesWithGitMock.mockRejectedValueOnce(cancellation) - await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(cancellation) - - const gitFailure = new Error('git ls-files exited with code 128') - listFilesWithGitMock.mockRejectedValueOnce(gitFailure) - await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(gitFailure) - }) - - it.skipIf(process.platform !== 'darwin')('shows the macOS install command', async () => { - listFilesWithGitMock.mockRejectedValue(new Error('File listing timed out')) - - await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toThrow( - 'brew install ripgrep' - ) - }) -}) diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts index 3b397eb4b047..b529dd34b397 100644 --- a/src/main/ipc/filesystem-list-files-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts @@ -1,21 +1,14 @@ -import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' import { EventEmitter } from 'node:events' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as GitRunner from '../git/runner' -import type * as GitFallback from './filesystem-list-files-git-fallback' import { pathMatchesFileNameFilterTokens, splitFileNameFilterTokens } from '../../shared/file-name-filter-tokens' -const { wslAwareSpawnMock, listFilesWithGitSpy } = vi.hoisted(() => ({ - wslAwareSpawnMock: vi.fn(), - listFilesWithGitSpy: vi.fn() +const { wslAwareSpawnMock } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn() })) vi.mock('../git/runner', async (importOriginal) => ({ @@ -23,16 +16,8 @@ vi.mock('../git/runner', async (importOriginal) => ({ wslAwareSpawn: wslAwareSpawnMock })) -vi.mock('./filesystem-list-files-git-fallback', async (importOriginal) => { - const actual = await importOriginal() - listFilesWithGitSpy.mockImplementation(actual.listFilesWithGit) - return { ...actual, listFilesWithGit: listFilesWithGitSpy } -}) - import { listQuickOpenFiles } from './filesystem-list-files' -const execFile = promisify(execFileCallback) - function makeStore(repoPath: string): Store { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listing only reads registered repos and settings. return { @@ -48,14 +33,11 @@ function nameFilter(query: string): (relativePath: string) => boolean { return (relativePath) => pathMatchesFileNameFilterTokens(relativePath, tokens) } -function spawnMissingRipgrep(): void { - wslAwareSpawnMock.mockImplementation( - (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => - spawn('orca-definitely-missing-rg', [], { cwd: options.cwd, stdio: options.stdio }) - ) -} - -function fakeRipgrep(output: string, killSignal: NodeJS.Signals | null = null): EventEmitter { +function fakeRipgrep( + output: string, + killSignal: NodeJS.Signals | null = null, + exitCode = 0 +): EventEmitter { const child = new EventEmitter() const stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) Object.assign(child, { @@ -68,19 +50,13 @@ function fakeRipgrep(output: string, killSignal: NodeJS.Signals | null = null): }) setTimeout(() => { stdout.emit('data', output) - child.emit('close', killSignal ? null : 0, killSignal) + child.emit('close', killSignal ? null : exitCode, killSignal) }, 0) return child } describe('listQuickOpenFiles name filter', () => { - let tempDir: string | null = null - - afterEach(async () => { - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }) - tempDir = null - } + afterEach(() => { vi.clearAllMocks() }) @@ -102,44 +78,22 @@ describe('listQuickOpenFiles name filter', () => { expect(files).toEqual(['ios/AppDelegate.swift']) }) - it('filters the whole git listing when ripgrep is missing', async () => { - spawnMissingRipgrep() - tempDir = await mkdtemp(join(tmpdir(), 'orca-name-filter-')) - const repoPath = join(tempDir, 'repo') - await execFile('git', ['init', '-q', repoPath]) - for (const relPath of ['a.ts', 'b.ts', 'zz/Notion Web Clipper/AppDelegate.swift']) { - await mkdir(dirname(join(repoPath, relPath)), { recursive: true }) - await writeFile(join(repoPath, relPath), 'x') - } - await execFile('git', ['add', '.'], { cwd: repoPath }) - const store = makeStore(repoPath) - - await expect(listQuickOpenFiles(repoPath, store, undefined, undefined, 2)).resolves.toEqual([ - 'a.ts', - 'b.ts' - ]) - await expect( - listQuickOpenFiles(repoPath, store, undefined, undefined, 2, undefined, nameFilter('appdel')) - ).resolves.toEqual(['zz/Notion Web Clipper/AppDelegate.swift']) - }) - - it('rejects an over-budget filtered walk so the renderer keeps its capped listing', async () => { - spawnMissingRipgrep() - listFilesWithGitSpy.mockRejectedValueOnce(new Error('File listing exceeded 20001 files')) + it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => { + wslAwareSpawnMock + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('', null, -2)) await expect( listQuickOpenFiles( - '/folder', - makeStore('/folder'), + '/repo', + makeStore('/repo'), undefined, undefined, 5, undefined, - nameFilter('target') + nameFilter('appdelegate') ) - ).rejects.toThrow() - expect(listFilesWithGitSpy).toHaveBeenCalledTimes(1) - expect(listFilesWithGitSpy.mock.calls[0][4]).toBeUndefined() + ).rejects.toThrow("Orca's bundled search tool (ripgrep) could not start") }) it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => { diff --git a/src/main/ipc/filesystem-list-files-without-ripgrep.ts b/src/main/ipc/filesystem-list-files-without-ripgrep.ts deleted file mode 100644 index b7e3cc1385ff..000000000000 --- a/src/main/ipc/filesystem-list-files-without-ripgrep.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' -import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' -import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' -import { listFilesWithGit } from './filesystem-list-files-git-fallback' - -/** Quick Open listing through git/readdir when ripgrep is unavailable. */ -export async function listFilesWithoutRipgrep(args: { - rootPath: string - excludePathPrefixes: readonly string[] - localGitOptions: { wslDistro?: string } - signal?: AbortSignal - maxResults?: number - maxSerializedBytes?: number - pathFilter?: (relativePath: string) => boolean -}): Promise { - const { rootPath, excludePathPrefixes, localGitOptions, signal, maxResults, pathFilter } = args - try { - // Why: these fallbacks cap scanned files, not matches, so a filtered listing scans everything. - // An over-budget readdir walk rejects, and the renderer falls back to its capped listing. - const files = pathFilter - ? (await listFilesWithGit(rootPath, excludePathPrefixes, localGitOptions, signal)) - .filter(pathFilter) - .slice(0, maxResults) - : await listFilesWithGit(rootPath, excludePathPrefixes, localGitOptions, signal, maxResults) - return args.maxSerializedBytes === undefined - ? files - : limitQuickOpenFilesBySerializedBytes(files, args.maxSerializedBytes) - } catch (err) { - if (!isQuickOpenReaddirBudgetError(err)) { - throw err - } - throw new Error(await buildInstallRgMessage(err)) - } -} diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index b1b06bd2d169..e9a4d406d7f9 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import type * as BundledRipgrepPath from '../ripgrep/bundled-ripgrep-path' const { spawnMock, resolveAuthorizedPathMock, - checkRgAvailableMock, + bundledRipgrepCommandMock, getLocalGitOptionsForRegisteredWorktreeMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), - checkRgAvailableMock: vi.fn(), + bundledRipgrepCommandMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn() })) @@ -24,8 +25,9 @@ vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock +vi.mock('../ripgrep/bundled-ripgrep-path', async (importOriginal) => ({ + ...(await importOriginal()), + bundledRipgrepCommand: bundledRipgrepCommandMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -37,13 +39,9 @@ import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' import { FileListingCancelledError } from '../../shared/file-listing-cancellation' -import { _gitAdmissionSnapshotForTests } from '../git/command-runner/git-subprocess-admission' -const SHA1 = '0123456789abcdef0123456789abcdef01234567' - -function staged(mode: string, path: string): string { - return `${mode} ${SHA1} 0\t${path}` -} +const BUNDLED_RG = '/bundled/rg' +const BUNDLED_ERROR = "Orca's bundled search tool (ripgrep) could not start" function createMockProcess(): ChildProcess { const p = new EventEmitter() as unknown as ChildProcess @@ -65,6 +63,7 @@ function createMockProcess(): ChildProcess { function createMissingRipgrepProcess(): ChildProcess { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: undefined }) + Object.defineProperties(child, { stdout: { value: undefined }, stderr: { value: undefined } }) void Promise.resolve().then(() => child.emit('close', -2, null)) return child } @@ -83,8 +82,31 @@ describe('filesystem-list-files', () => { beforeEach(() => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + bundledRipgrepCommandMock.mockImplementation((options?: { wsl?: boolean }) => + options?.wsl ? '/bundled/linux/rg' : BUNDLED_RG + ) + }) + + it('rejects a synchronous launch failure before cleanup has been initialized', async () => { + spawnMock.mockImplementationOnce(() => { + throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above. + await expect(listQuickOpenFiles('/repo', {} as Store)).rejects.toThrow('EMFILE') + }) + + // Why close(97) and not a spawn error: this is the WSL wrapper's "cd failed" code. It is above + // rg's own 0/1/2, so a handler that checks it after the unavailable branch reports a broken + // install instead -- a regression this file would otherwise not catch. + it('names the unreachable root when the WSL wrapper cannot enter it', async () => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + setTimeout(() => child.emit('close', 97, null), 0) + + await expect(promise).rejects.toThrow('Search root is not reachable: /mock/root') }) it('stops after the primary rg pass fills the result budget', async () => { @@ -109,7 +131,8 @@ describe('filesystem-list-files', () => { expect(p1.kill).toHaveBeenCalled() expect(p2.kill).not.toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledTimes(1) - expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(spawnMock.mock.calls[0]?.[0]).toBe(BUNDLED_RG) + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: false }) expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) @@ -158,7 +181,7 @@ describe('filesystem-list-files', () => { ]) }) - it('checks rg availability inside the registered WSL runtime for Windows-path worktrees', async () => { + it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => { const p1 = createMockProcess() const p2 = createMockProcess() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) @@ -185,7 +208,8 @@ describe('filesystem-list-files', () => { 'C:\\repo', 'C:\\repo' ) - expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: true }) + expect(spawnMock.mock.calls.every((call) => call[0] === '/bundled/linux/rg')).toBe(true) }) it('normalizes absolute WSL rg output for Windows-path worktrees', async () => { @@ -212,7 +236,7 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) - it('does not mistake a WSL-routed executable exit 127 for missing rg', async () => { + it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => { const p1 = createMockProcess() const p2 = createMockProcess() Object.defineProperty(p1, 'pid', { value: 1 }) @@ -226,10 +250,22 @@ describe('filesystem-list-files', () => { p2.emit('close', 0, null) }, 0) - await expect(promise).rejects.toThrow('rg exited with code 127') + await expect(promise).rejects.toThrow(BUNDLED_ERROR) expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) }) + it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + setTimeout(() => p1.emit('close', 127, null), 0) + + await expect(promise).rejects.toThrow(BUNDLED_ERROR) + expect(p2.kill).toHaveBeenCalled() + }) + it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -389,7 +425,7 @@ describe('filesystem-list-files', () => { expect(result).toEqual(['valid.ts']) }) - it('lets cancellation win a native-unavailable race before Git starts', async () => { + it('lets cancellation win a native-unavailable race', async () => { const first = createMockProcess() Object.defineProperty(first, 'pid', { value: undefined }) spawnMock.mockReturnValue(first) @@ -414,391 +450,54 @@ describe('filesystem-list-files', () => { expect(first.listenerCount('error')).toBe(0) }) - describe('git ls-files fallback', () => { + describe('when the bundled rg cannot start', () => { it('kills only the admitted pass when ignored rg fails before spawn', async () => { const primary = createMockProcess() const missingIgnored = createMockProcess() - const revParse = createMockProcess() - const gitPrimary = createMockProcess() - const gitIgnored = createMockProcess() Object.defineProperty(missingIgnored, 'pid', { value: undefined }) - let gitPassIndex = 0 - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return isIgnoredRgPass(args) ? missingIgnored : primary - } - if (args.includes('rev-parse')) { - return revParse - } - if (args.includes('ls-files')) { - return gitPassIndex++ === 0 ? gitPrimary : gitIgnored - } - return createMockProcess() - }) + spawnMock.mockImplementation((_cmd: string, args: string[]) => + isIgnoredRgPass(args) ? missingIgnored : primary + ) const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) await flushMicrotasks() - expect(spawnMock.mock.calls.filter((call) => call[0] === 'rg')).toHaveLength(2) + expect(spawnMock).toHaveBeenCalledTimes(2) missingIgnored.emit('close', -2, null) - await vi.waitFor(() => expect(primary.kill).toHaveBeenCalled()) + await expect(promise).rejects.toThrow(BUNDLED_ERROR) + expect(primary.kill).toHaveBeenCalled() expect(missingIgnored.kill).not.toHaveBeenCalled() const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) expect(() => missingIgnored.emit('error', error)).not.toThrow() - await vi.waitFor(() => - expect( - spawnMock.mock.calls.some((call) => (call[1] as string[]).includes('rev-parse')) - ).toBe(true) - ) - revParse.emit('close', 0, null) - await vi.waitFor(() => - expect( - spawnMock.mock.calls.filter((call) => (call[1] as string[]).includes('ls-files')) - ).toHaveLength(2) - ) - gitPrimary.emit('close', 0, null) - gitIgnored.emit('close', 0, null) - - await expect(promise).resolves.toEqual([]) expect(missingIgnored.listenerCount('error')).toBe(0) }) - it('falls back to git ls-files when rg is not available', async () => { - let callIndex = 0 - const revParseProc = createMockProcess() - const gitP1 = createMockProcess() - const gitP2 = createMockProcess() - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return createMissingRipgrepProcess() - } - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - if (cmd === 'git' && args.includes('ls-files')) { - callIndex++ - return callIndex === 1 ? gitP1 : gitP2 - } - return createMockProcess() - }) - - const storeMock = {} as unknown as Store - const promise = listQuickOpenFiles('/mock/root', storeMock) - - setTimeout(() => { - revParseProc.emit('close', 0, null) - }, 0) - setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'src/index.ts')}\0` - ) - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'package.json')}\0` - ) - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'node_modules/dep/index.js')}\0` - ) - gitP1.emit('close', 0, null) - - ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') - ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - gitP2.emit('close', 0, null) - }, 10) - - const result = await promise - - // The primary real command doubles as the availability check. - const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg') - expect(rgCalls.length).toBe(1) - expect(rgCalls.every((call) => !(call[1] as string[]).includes('--version'))).toBe(true) - - // Verify git ls-files was called - const gitCalls = spawnMock.mock.calls.filter( - (call) => call[0] === 'git' && (call[1] as string[]).includes('ls-files') - ) - expect(gitCalls.length).toBe(2) - expect(gitCalls[0][1]).toContain('ls-files') - expect(gitCalls[0][1]).toContain('-s') - expect(gitCalls[0][1]).toContain('--directory') - expect(gitCalls[1][1]).toContain('--directory') - expect(gitCalls[1][1]).toContain('--no-empty-directory') - - // Should include valid files and filter node_modules - expect(result).toContain('src/index.ts') - expect(result).toContain('package.json') - expect(result).toContain('.env.local') - expect(result).toContain('dist/generated.js') - expect(result).not.toContain('node_modules/dep/index.js') - }) - - it('stops after primary Git files fill the result budget', async () => { - const revParseProc = createMockProcess() - const gitP1 = createMockProcess() - const gitP2 = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return createMissingRipgrepProcess() - } - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - if (cmd === 'git' && args.includes('ls-files')) { - callIndex += 1 - return callIndex === 1 ? gitP1 : gitP2 - } - return createMockProcess() - }) + it('rejects with the bundled-ripgrep error instead of spawning git', async () => { + spawnMock.mockImplementation(() => createMissingRipgrepProcess()) - const promise = listQuickOpenFiles( - '/mock/root', - {} as unknown as Store, - undefined, - undefined, - 2 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked auth and runtime options never read the store. + await expect(listQuickOpenFiles('/mock/root', {} as unknown as Store)).rejects.toThrow( + BUNDLED_ERROR ) - setTimeout(() => revParseProc.emit('close', 0, null), 0) - setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'one.ts\0two.ts') - gitP1.emit('close', 0, null) - }, 10) - - await expect(promise).resolves.toEqual(['one.ts', 'two.ts']) - expect(gitP1.kill).toHaveBeenCalled() - expect(gitP2.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) }) - it('does not let a discarded Git directory placeholder consume the result budget', async () => { - const revParseProc = createMockProcess() - const primary = createMockProcess() - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return createMissingRipgrepProcess() - } - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - return primary - }) - - const promise = listQuickOpenFiles( - '/mock/root', - {} as unknown as Store, - undefined, - undefined, - 1 - ) - setTimeout(() => revParseProc.emit('close', 0, null), 0) - setTimeout(() => { - ;(primary.stdout as unknown as EventEmitter).emit( - 'data', - `discarded/\0${staged('100644', 'src/kept.ts')}\0` - ) - }, 10) - - await expect(promise).resolves.toEqual(['src/kept.ts']) - expect(primary.kill).toHaveBeenCalled() - primary.emit('close', null, 'SIGTERM') - }) - - it('git fallback applies hidden dir blocklist', async () => { - const revParseProc = createMockProcess() - const gitP1 = createMockProcess() - const gitP2 = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return createMissingRipgrepProcess() - } - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - if (cmd === 'git' && args.includes('ls-files')) { - callIndex++ - return callIndex === 1 ? gitP1 : gitP2 - } - return createMockProcess() - }) - - const storeMock = {} as unknown as Store - const promise = listQuickOpenFiles('/mock/root', storeMock) - - setTimeout(() => { - revParseProc.emit('close', 0, null) - }, 0) - setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', '.next/cache/1.js')}\0` - ) - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', '.vscode/settings.json')}\0` - ) - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', '.github/workflows/ci.yml')}\0` - ) - ;(gitP1.stdout as unknown as EventEmitter).emit('data', `${staged('100644', 'valid.ts')}\0`) - gitP1.emit('close', 0, null) - - gitP2.emit('close', 0, null) - }, 10) - - const result = await promise - - expect(result).toEqual(['.github/workflows/ci.yml', 'valid.ts']) - }) - - it('settles and detaches git fallback scans that ignore timeout kills', async () => { - vi.useFakeTimers() - - try { - const revParseProc = createMockProcess() - const gitP1 = createMockProcess() - const gitP2 = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return createMissingRipgrepProcess() - } - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - if (cmd === 'git' && args.includes('ls-files')) { - callIndex++ - return callIndex === 1 ? gitP1 : gitP2 - } - return createMockProcess() - }) - - const storeMock = {} as unknown as Store - const promise = listQuickOpenFiles('/mock/root', storeMock) - - await flushMicrotasks() - revParseProc.emit('close', 0, null) - await flushMicrotasks() - - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') - - const rejection = expect(promise).rejects.toThrow('git ls-files timed out') - await vi.advanceTimersByTimeAsync(10000) - - await rejection - expect(gitP1.kill).toHaveBeenCalled() - expect(gitP2.kill).toHaveBeenCalled() - expect((gitP1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((gitP1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - // Admission retains live children after the listing listeners detach. - const admittedBeforeClose = _gitAdmissionSnapshotForTests().budgets.general?.baseUsed - expect(gitP1.listenerCount('error')).toBe(1) - expect(gitP1.listenerCount('close')).toBe(1) - gitP1.emit('close', null, 'SIGKILL') - gitP2.emit('close', null, 'SIGKILL') - - expect(admittedBeforeClose).toBe(2) - expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) - expect(gitP1.listenerCount('error')).toBe(0) - expect(gitP1.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('keeps primary results when only the ignored pass times out', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.useFakeTimers() - - try { - const revParseProc = createMockProcess() - const gitP1 = createMockProcess() - const gitP2 = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'rg') { - return createMissingRipgrepProcess() - } - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - if (cmd === 'git' && args.includes('ls-files')) { - callIndex++ - return callIndex === 1 ? gitP1 : gitP2 - } - return createMockProcess() - }) - - const storeMock = {} as unknown as Store - const promise = listQuickOpenFiles('/mock/root', storeMock) - - await flushMicrotasks() - revParseProc.emit('close', 0, null) - await flushMicrotasks() - - ;(gitP1.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'src/index.ts')}\0` + it('reports fd pressure as a transient launch failure, not a broken install', async () => { + spawnMock.mockImplementation(() => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + setTimeout( + () => child.emit('error', Object.assign(new Error('EMFILE'), { code: 'EMFILE' })), + 0 ) - gitP1.emit('close', 0, null) - // Ignored entries streamed before the timeout are kept. - ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - - await vi.advanceTimersByTimeAsync(10000) - - await expect(promise).resolves.toEqual( - expect.arrayContaining(['src/index.ts', 'dist/generated.js']) - ) - expect(gitP2.kill).toHaveBeenCalled() - expect(warnSpy).toHaveBeenCalled() - const admittedBeforeClose = _gitAdmissionSnapshotForTests().budgets.general?.baseUsed - gitP2.emit('close', null, 'SIGKILL') - expect(admittedBeforeClose).toBe(1) - expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) - } finally { - vi.useRealTimers() - warnSpy.mockRestore() - } - }) - - it('does not fall back to git when rg is available', async () => { - checkRgAvailableMock.mockResolvedValue(true) - - const p1 = createMockProcess() - const p2 = createMockProcess() - - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 + return child }) - const storeMock = {} as unknown as Store - const promise = listQuickOpenFiles('/mock/root', storeMock) - - setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'file.ts\n') - p1.emit('close', 0, null) - p2.emit('close', 0, null) - }, 10) - - const result = await promise + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked auth and runtime options never read the store. + const listing = listQuickOpenFiles('/mock/root', {} as unknown as Store) - expect(result).toEqual(['file.ts']) - const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg') - expect(rgCalls.every((call) => call[1].at(-1) === '.')).toBe(true) - // git should never have been called - const gitCalls = spawnMock.mock.calls.filter((call) => call[0] === 'git') - expect(gitCalls.length).toBe(0) + await expect(listing).rejects.toThrow('rg could not start (EMFILE); try again') }) }) }) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index cbddb5c9c572..f1e8fb56334e 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -2,8 +2,6 @@ import { sep } from 'node:path' import type { ChildProcess } from 'node:child_process' import type { Store } from '../persistence' import { resolveAuthorizedPath } from './filesystem-auth' -import { checkRgAvailable } from './rg-availability' -import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' import { @@ -18,11 +16,17 @@ import { limitQuickOpenFilesBySerializedBytes, serializedQuickOpenPathBytes } from '../../shared/quick-open-transport-budget' -import { listFilesWithoutRipgrep } from './filesystem-list-files-without-ripgrep' +import { bundledRipgrepUnavailableError } from '../ripgrep/bundled-ripgrep-path' +import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' import { absorbPendingRipgrepSpawnError, isRipgrepUnavailableExit, + classifySynchronousRipgrepSpawnFailure, + isRipgrepMissingCwdExit, + isRipgrepSpawnCwdUsable, + isTransientRipgrepSpawnError, killSpawnedRipgrepProcess, + ripgrepMissingCwdError, RipgrepUnavailableError } from '../../shared/ripgrep-process-availability' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -51,23 +55,6 @@ export async function listQuickOpenFiles( const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const listWithoutRipgrep = (): Promise => - listFilesWithoutRipgrep({ - rootPath: authorizedRootPath, - excludePathPrefixes, - localGitOptions, - signal, - maxResults, - maxSerializedBytes, - pathFilter - }) - if ( - wslDistroForOutput && - !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) - ) { - return listWithoutRipgrep() - } - const files = new Set() let serializedBytes = 2 // [] const children: { @@ -137,11 +124,19 @@ export async function listQuickOpenFiles( return maxResults !== undefined && files.size >= maxResults } - const child = wslAwareSpawn('rg', args, { - cwd: authorizedRootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) + // A synchronous spawn failure has no child to clean up. + let child: ReturnType + try { + child = spawnBundledRipgrep(args, { + cwd: authorizedRootPath, + wslDistro: localGitOptions.wslDistro, + wslDistroForOutput, + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (error) { + void classifySynchronousRipgrepSpawnFailure(error, authorizedRootPath).then(reject, reject) + return + } let timer: ReturnType const handleStdoutData = (chunk: string): void => { buf += chunk @@ -161,21 +156,46 @@ export async function listQuickOpenFiles( const handleStderrData = (): void => { /* drain */ } - const handleError = (): void => { + const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' + // Why: fd/process pressure is not a broken install; say so instead of blaming the bundled binary. + if (isTransientRipgrepSpawnError(error)) { + finish(new Error(`rg could not start (${error.code}); try again`)) + return + } if (isRipgrepUnavailableExit(child, null, null)) { - finish(new RipgrepUnavailableError()) + // Why the cwd check: spawn reports a missing cwd as ENOENT too, and blaming the binary + // for it tells the user to reinstall Orca over a workspace that simply moved. + // Why detach close first: a failed spawn emits error THEN close(code < 0), and close + // settles synchronously, so this probe would otherwise race it on a sub-millisecond + // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + child.off('close', handleClose) + // Why catch: a failed probe must not strand the search; fall back to the prior verdict. + void isRipgrepSpawnCwdUsable(authorizedRootPath) + .catch(() => true) + .then((usable) => { + finish( + usable ? new RipgrepUnavailableError() : ripgrepMissingCwdError(authorizedRootPath) + ) + }) return } finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise + // read an unreachable workspace as a broken install and tell the user to reinstall Orca. + if (isRipgrepMissingCwdExit(code)) { + buf = '' + finish(ripgrepMissingCwdError(authorizedRootPath)) + return + } if ( isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !wslDistroForOutput + classifyNativeLauncherExit: true }) ) { unavailableExitObserved = true @@ -214,8 +234,8 @@ export async function listQuickOpenFiles( clearTimeout(timer) // Why: child.kill() is advisory. If rg ignores it, detach our // closures so repeated Quick Open attempts do not retain old scans. - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) + child.stdout?.off('data', handleStdoutData) + child.stderr?.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) signal?.removeEventListener('abort', handleAbort) @@ -237,9 +257,9 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) + child.stdout?.setEncoding('utf-8') + child.stdout?.on('data', handleStdoutData) + child.stderr?.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) timer = setTimeout(() => { @@ -307,10 +327,7 @@ export async function listQuickOpenFiles( } } catch (err) { killSurvivors() - if (err instanceof RipgrepUnavailableError) { - return listWithoutRipgrep() - } - throw err + throw err instanceof RipgrepUnavailableError ? bundledRipgrepUnavailableError() : err } const result = Array.from(files).slice(0, maxResults) return maxSerializedBytes === undefined diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts index 47c45d527369..e9415d664314 100644 --- a/src/main/ipc/filesystem-search-file-paths.test.ts +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -3,19 +3,17 @@ import type { ChildProcess } from 'node:child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as GitRunner from '../git/runner' +import type * as BundledRipgrepPath from '../ripgrep/bundled-ripgrep-path' import { isFileListingCancellation } from '../../shared/file-listing-cancellation' -import { - RipgrepLaunchFailureError, - RipgrepUnavailableError -} from '../../shared/ripgrep-process-availability' +import { RipgrepUnavailableError } from '../../shared/ripgrep-process-availability' const { - checkRgAvailableMock, + bundledRipgrepCommandMock, getLocalGitOptionsForRegisteredWorktreeMock, resolveAuthorizedPathMock, wslAwareSpawnMock } = vi.hoisted(() => ({ - checkRgAvailableMock: vi.fn(), + bundledRipgrepCommandMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), wslAwareSpawnMock: vi.fn() @@ -30,8 +28,9 @@ vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock +vi.mock('../ripgrep/bundled-ripgrep-path', async (importOriginal) => ({ + ...(await importOriginal()), + bundledRipgrepCommand: bundledRipgrepCommandMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -56,6 +55,12 @@ function createMockProcess(spawned = true): ChildProcess { return child } +const BUNDLED_ERROR = "Orca's bundled search tool (ripgrep) could not start" + +// Why Object.create and not `{} as Store`: this path never reads the store, and the changed-code +// quality gate rejects type assertions. +const UNUSED_STORE: Store = Object.create(null) + function createSpawnError(code: string): NodeJS.ErrnoException { return Object.assign(new Error(`spawn rg ${code}`), { code }) } @@ -70,8 +75,10 @@ describe('searchQuickOpenFilePaths', () => { beforeEach(() => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + bundledRipgrepCommandMock.mockImplementation((options?: { wsl?: boolean }) => + options?.wsl ? '/bundled/linux/rg' : '/bundled/rg' + ) }) it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => { @@ -85,6 +92,7 @@ describe('searchQuickOpenFilePaths', () => { await flushMicrotasks() expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) + expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg') expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') ;(child.stdout as unknown as EventEmitter).emit( 'data', @@ -120,21 +128,8 @@ describe('searchQuickOpenFilePaths', () => { expect(child.kill).toHaveBeenCalledOnce() }) - it('requires ripgrep instead of retaining an unbounded fallback inventory', async () => { - getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - checkRgAvailableMock.mockResolvedValue(false) - - await expect( - searchQuickOpenFilePaths('C:\\repo', {} as Store, { query: 'target', limit: 32 }) - ).rejects.toThrow('Quick Open search requires ripgrep') - expect(wslAwareSpawnMock).not.toHaveBeenCalled() - }) - - it('retries transient WSL rg availability pressure before showing install guidance', async () => { + it('spawns the bundled Linux rg inside the registered WSL runtime', async () => { getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - checkRgAvailableMock - .mockRejectedValueOnce(new RipgrepLaunchFailureError('rg check failed (EAGAIN)')) - .mockResolvedValueOnce(true) const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) @@ -147,10 +142,15 @@ describe('searchQuickOpenFilePaths', () => { child.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) - expect(checkRgAvailableMock).toHaveBeenCalledTimes(2) + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: true }) + expect(wslAwareSpawnMock).toHaveBeenCalledWith( + '/bundled/linux/rg', + expect.any(Array), + expect.objectContaining({ cwd: 'C:\\repo', wslDistro: 'Ubuntu' }) + ) }) - it('retries a transient rg spawn failure instead of demanding a ripgrep install', async () => { + it('retries a transient rg spawn failure instead of reporting the bundled rg broken', async () => { const failed = createMockProcess(false) const succeeded = createMockProcess() wslAwareSpawnMock.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded) @@ -196,9 +196,14 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) }) - it('still reports a missing ripgrep binary when rg is genuinely absent', async () => { + // Why both events: a failed spawn emits 'error' and THEN 'close' with a negative code. Emitting + // only 'error' lets the async cwd check win a race it loses in production. + it('reports the bundled-ripgrep error when rg genuinely cannot start', async () => { const child = createMockProcess(false) wslAwareSpawnMock.mockReturnValue(child) + // Why a root that exists: an ENOENT spawn error is also what a vanished workspace looks like, + // so this stays about the binary only while the search root is actually reachable. + resolveAuthorizedPathMock.mockImplementation(async () => process.cwd()) const promise = searchQuickOpenFilePaths('/repo', {} as Store, { query: 'target', limit: 32 @@ -206,12 +211,45 @@ describe('searchQuickOpenFilePaths', () => { await flushMicrotasks() child.emit('error', createSpawnError('ENOENT')) + child.emit('close', -2, null) - await expect(promise).rejects.toThrow('Quick Open search requires ripgrep') + await expect(promise).rejects.toThrow(BUNDLED_ERROR) expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) }) - it('reports install guidance when ripgrep is unavailable on the retry', async () => { + // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler + // that checks it after the unavailable branch reports a broken install instead. + it('names the unreachable root when the WSL wrapper cannot enter it', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 32 + }) + await flushMicrotasks() + + child.emit('close', 97, null) + + await expect(promise).rejects.toThrow('Search root is not reachable: /repo') + }) + + it('names the unreachable root when the workspace is gone, not the bundled binary', async () => { + const child = createMockProcess(false) + wslAwareSpawnMock.mockReturnValue(child) + resolveAuthorizedPathMock.mockImplementation(async () => '/definitely/not/here') + const promise = searchQuickOpenFilePaths('/repo', {} as Store, { + query: 'target', + limit: 32 + }) + await flushMicrotasks() + + child.emit('error', createSpawnError('ENOENT')) + child.emit('close', -2, null) + + await expect(promise).rejects.toThrow('Search root is not reachable: /definitely/not/here') + }) + + it('reports the bundled-ripgrep error when rg is unavailable on the retry', async () => { const failed = createMockProcess(false) wslAwareSpawnMock.mockReturnValueOnce(failed).mockImplementationOnce(() => { throw new RipgrepUnavailableError() @@ -224,7 +262,7 @@ describe('searchQuickOpenFilePaths', () => { failed.emit('error', createSpawnError('EAGAIN')) - await expect(promise).rejects.toThrow('Quick Open search requires ripgrep') + await expect(promise).rejects.toThrow(BUNDLED_ERROR) expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) }) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index 2c7c09a4c5c5..4a003aa17c5e 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -13,18 +13,21 @@ import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quic import { absorbPendingRipgrepSpawnError, isRipgrepUnavailableExit, + classifySynchronousRipgrepSpawnFailure, + isRipgrepMissingCwdExit, + isRipgrepSpawnCwdUsable, isTransientRipgrepSpawnError, killSpawnedRipgrepProcess, + ripgrepMissingCwdError, RipgrepLaunchFailureError, RipgrepUnavailableError } from '../../shared/ripgrep-process-availability' -import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' -import { checkRgAvailable } from './rg-availability' import { resolveAuthorizedPath } from './filesystem-auth' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' import { QuickOpenSubprocessPathAccumulator } from '../../shared/quick-open-listing-limits' -import { buildRipgrepRequiredMessage } from '../../shared/quick-open-install-rg' +import { bundledRipgrepUnavailableError } from '../ripgrep/bundled-ripgrep-path' +import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' export type QuickOpenFilePathSearchResult = { paths: string[] @@ -53,9 +56,6 @@ export async function searchQuickOpenFilePaths( ) const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const fallback = async (): Promise => { - throw new Error(await buildRipgrepRequiredMessage()) - } const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, args.excludePaths) const { ignoredPass } = buildRgArgsForQuickOpen({ searchRoot: '.', @@ -79,18 +79,10 @@ export async function searchQuickOpenFilePaths( } for (let attempt = 0; attempt < 2; attempt++) { try { - if ( - wslDistroForOutput && - !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro, { - rejectTransientLaunchFailure: true - })) - ) { - return fallback() - } return await scanOnce() } catch (error) { if (error instanceof RipgrepUnavailableError) { - return fallback() + throw bundledRipgrepUnavailableError() } // Why: a supersede that lands after the scan rejected still owes the caller a cancellation. if (args.signal?.aborted) { @@ -123,19 +115,22 @@ function scanRipgrepPaths(args: { let parseablePathCount = 0 let processErrorObserved = false let unavailableExitObserved = false - let child: ReturnType + let child: ReturnType try { - child = wslAwareSpawn('rg', args.args, { + child = spawnBundledRipgrep(args.args, { cwd: args.authorizedRootPath, - ...(args.localGitOptions.wslDistro ? { wslDistro: args.localGitOptions.wslDistro } : {}), + wslDistro: args.localGitOptions.wslDistro, + wslDistroForOutput: args.wslDistroForOutput, stdio: ['ignore', 'pipe', 'pipe'] }) } catch (error) { - throw isTransientRipgrepSpawnError(error) - ? new RipgrepLaunchFailureError( - `rg failed to start (${(error as NodeJS.ErrnoException).code})` - ) - : error + // Why route through the classifier: a sync throw skips the 'error' handler, and ENOTDIR -- + // a search root that is a file -- should read the same here as it does there. + void classifySynchronousRipgrepSpawnFailure(error, args.authorizedRootPath).then( + reject, + reject + ) + return } let timer: ReturnType @@ -161,8 +156,8 @@ function scanRipgrepPaths(args: { } const cleanup = (): void => { clearTimeout(timer) - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) + child.stdout?.off('data', handleStdoutData) + child.stderr?.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) args.signal?.removeEventListener('abort', handleAbort) @@ -198,16 +193,36 @@ function scanRipgrepPaths(args: { finish(new RipgrepLaunchFailureError(`rg failed to start (${error.code})`)) return } - finish( - isRipgrepUnavailableExit(child, null, null) - ? new RipgrepUnavailableError() - : new Error(`rg failed to start${error.code ? ` (${error.code})` : ''}`) - ) + if (!isRipgrepUnavailableExit(child, null, null)) { + finish(new Error(`rg failed to start${error.code ? ` (${error.code})` : ''}`)) + return + } + // Why the cwd check: spawn reports a missing cwd as ENOENT too, and blaming the binary + // for it tells the user to reinstall Orca over a workspace that simply moved. + // Why detach close first: a failed spawn emits error THEN close(code < 0), and close settles + // synchronously, so this probe would otherwise race it on a sub-millisecond margin -- two + // measurements disagreed on which wins. Detaching makes the verdict deterministic. + child.off('close', handleClose) + // Why catch: a failed probe must not strand the search; fall back to the prior verdict. + void isRipgrepSpawnCwdUsable(args.authorizedRootPath) + .catch(() => true) + .then((usable) => { + finish( + usable ? new RipgrepUnavailableError() : ripgrepMissingCwdError(args.authorizedRootPath) + ) + }) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + // Why before the unavailable check: classifyNativeLauncherExit treats any code above 2 as a + // broken install, and this code is 97 -- so checking second makes this branch dead. + if (isRipgrepMissingCwdExit(code)) { + pathAccumulator.clear() + finish(ripgrepMissingCwdError(args.authorizedRootPath)) + return + } if ( isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !args.wslDistroForOutput + classifyNativeLauncherExit: true }) ) { unavailableExitObserved = true @@ -234,9 +249,9 @@ function scanRipgrepPaths(args: { finish(fileListingCancellationError(args.signal)) } - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) + child.stdout?.setEncoding('utf-8') + child.stdout?.on('data', handleStdoutData) + child.stderr?.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) args.signal?.addEventListener('abort', handleAbort, { once: true }) diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts deleted file mode 100644 index 7de098199f6d..000000000000 --- a/src/main/ipc/filesystem-search-git.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' - -const { gitSpawnMock } = vi.hoisted(() => ({ - gitSpawnMock: vi.fn() -})) - -vi.mock('../git/runner', () => ({ - gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock -})) - -import { searchWithGitGrep } from './filesystem-search-git' -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' - -function createMockProcess(): ChildProcess { - const p = new EventEmitter() as unknown as ChildProcess - ;(p as unknown as Record).stdout = new EventEmitter() - ;( - (p as unknown as Record).stdout as EventEmitter & { - setEncoding: () => void - } - ).setEncoding = vi.fn() - ;(p as unknown as Record).stderr = new EventEmitter() - ;(p as unknown as Record).kill = vi.fn() - return p -} - -describe('filesystem-search-git', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('parses git grep output and finds matches', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/mock/root', { query: 'hello', rootPath: '/mock/root' }, 100) - - setTimeout(() => { - ;(proc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/index.ts\x005\x00 console.log("hello world")\n' - ) - ;(proc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/main.ts\x0012\x00 return "hello"\n' - ) - proc.emit('close') - }, 10) - - const result = await promise - - expect(result.files).toHaveLength(2) - expect(result.totalMatches).toBe(2) - expect(result.truncated).toBe(false) - - expect(result.files[0].relativePath).toBe('src/index.ts') - expect(result.files[0].matchCount).toBe(1) - expect(result.files[0].matches[0]).toEqual({ - line: 5, - column: 16, - matchLength: 5, - lineContent: ' console.log("hello world")' - }) - - expect(result.files[1].relativePath).toBe('src/main.ts') - expect(result.files[1].matchCount).toBe(1) - expect(result.files[1].matches[0].line).toBe(12) - }) - - it('finds multiple matches per line', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/mock/root', { query: 'ab', rootPath: '/mock/root' }, 100) - - setTimeout(() => { - ;(proc.stdout as unknown as EventEmitter).emit('data', 'file.txt\x001\x00ab cd ab ef ab\n') - proc.emit('close') - }, 10) - - const result = await promise - - expect(result.files).toHaveLength(1) - expect(result.totalMatches).toBe(3) - expect(result.files[0].matchCount).toBe(3) - expect(result.files[0].matches).toEqual([ - { line: 1, column: 1, matchLength: 2, lineContent: 'ab cd ab ef ab' }, - { line: 1, column: 7, matchLength: 2, lineContent: 'ab cd ab ef ab' }, - { line: 1, column: 13, matchLength: 2, lineContent: 'ab cd ab ef ab' } - ]) - }) - - it('respects maxResults and sets truncated', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/mock/root', { query: 'x', rootPath: '/mock/root' }, 2) - - setTimeout(() => { - ;(proc.stdout as unknown as EventEmitter).emit( - 'data', - 'a.ts\x001\x00x\n' + 'b.ts\x001\x00x\n' + 'c.ts\x001\x00x\n' - ) - proc.emit('close') - }, 10) - - const result = await promise - - expect(result.totalMatches).toBe(2) - expect(result.truncated).toBe(true) - expect(result.files.map((file) => file.matchCount)).toEqual([1, 1]) - }) - - it('passes correct flags for case-insensitive fixed-string search', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep( - '/mock/root', - { query: 'test', rootPath: '/mock/root', caseSensitive: false, useRegex: false }, - 100, - { wslDistro: 'Ubuntu' } - ) - - setTimeout(() => proc.emit('close'), 10) - await promise - - const gitArgs = gitSpawnMock.mock.calls[0][0] as string[] - expect(gitArgs).toContain('-i') - expect(gitArgs).toContain('--fixed-strings') - expect(gitArgs).not.toContain('--extended-regexp') - expect(gitSpawnMock.mock.calls[0][1]).toEqual( - expect.objectContaining({ admissionTier: 'interactive', wslDistro: 'Ubuntu' }) - ) - }) - - it('passes correct flags for regex whole-word search', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep( - '/mock/root', - { - query: 'foo', - rootPath: '/mock/root', - caseSensitive: true, - useRegex: true, - wholeWord: true - }, - 100 - ) - - setTimeout(() => proc.emit('close'), 10) - await promise - - const gitArgs = gitSpawnMock.mock.calls[0][0] as string[] - expect(gitArgs).toContain('-w') - expect(gitArgs).toContain('--extended-regexp') - expect(gitArgs).not.toContain('-i') - expect(gitArgs).not.toContain('--fixed-strings') - }) - - it('returns empty result when git grep spawn fails', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/mock/root', { query: 'test', rootPath: '/mock/root' }, 100) - - setTimeout(() => proc.emit('error', new Error('spawn git ENOENT')), 10) - - const result = await promise - - expect(result.files).toHaveLength(0) - expect(result.totalMatches).toBe(0) - }) - - it('settles and detaches when git grep ignores the timeout kill', async () => { - vi.useFakeTimers() - - try { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) - - await vi.waitFor(() => - expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBeGreaterThan(0) - ) - ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001\x00ok\npartial') - - await vi.runOnlyPendingTimersAsync() - - const result = await promise - expect(result.truncated).toBe(true) - expect(result.files).toHaveLength(1) - expect(result.files[0].matchCount).toBe(1) - expect(proc.kill).toHaveBeenCalled() - expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((proc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(proc.listenerCount('error')).toBe(0) - expect(proc.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('skips lines without null separator', async () => { - const proc = createMockProcess() - gitSpawnMock.mockReturnValue(proc) - - const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) - - setTimeout(() => { - // A line without \0 should be skipped (e.g. git header output) - ;(proc.stdout as unknown as EventEmitter).emit('data', 'no-null-here:1:ok\n') - ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x003\x00ok\n') - proc.emit('close') - }, 10) - - const result = await promise - - expect(result.files).toHaveLength(1) - expect(result.files[0].relativePath).toBe('valid.ts') - }) -}) diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts deleted file mode 100644 index 8e930dbf8b18..000000000000 --- a/src/main/ipc/filesystem-search-git.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import { - buildGitGrepArgs, - buildSubmatchRegex, - createAccumulator, - finalize, - ingestGitGrepLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' -import { - isWslLinkedWorktreeGitRoutingCandidate, - prepareWslLinkedWorktreeGitRouting -} from '../git/wsl-linked-worktree-git-routing' - -/** - * Fallback text search using git grep. Used when rg is not available. - * - * Why: On Linux, rg may not be installed or may not be in PATH when the app - * is launched from a desktop entry (which inherits a minimal system PATH). - * git grep is always available since this is a git-focused app. - */ -export async function searchWithGitGrep( - rootPath: string, - args: SearchOptions, - maxResults: number, - localGitOptions: { wslDistro?: string } = {} -): Promise { - if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro) - } - const gitArgs = buildGitGrepArgs(args.query, args) - const child = await gitSpawnAfterWindowsEnvironmentReady(gitArgs, { - cwd: rootPath, - admissionTier: 'interactive', - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - return new Promise((resolve) => { - const matchRegex = buildSubmatchRegex(args.query, args) - const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) - let done = false - - let killTimeout: ReturnType - - function resolveOnce(): void { - if (done) { - return - } - done = true - lines.clear() - clearTimeout(killTimeout) - // Why: child.kill() is advisory. If git ignores it, detach our - // closures so repeated fallback searches do not retain old scans. - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - resolve(finalize(acc)) - } - - function processLine(line: string): void { - const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, maxResults) - if (verdict === 'stop') { - child.kill() - } - } - - function handleStdoutData(chunk: string): void { - lines.push(chunk, processLine) - } - - function handleStderrData(): void { - /* drain */ - } - - function handleError(): void { - resolveOnce() - } - - function handleClose(): void { - const tail = lines.finish() - if (tail !== null) { - processLine(tail) - } - resolveOnce() - } - - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) - child.once('error', handleError) - child.once('close', handleClose) - - killTimeout = setTimeout(() => { - acc.truncated = true - child.kill() - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) -} diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 8860c4c55b6e..9c37bacb20a7 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -1,22 +1,23 @@ import { EventEmitter } from 'node:events' import type { ChildProcess } from 'node:child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BundledRipgrepPath from '../ripgrep/bundled-ripgrep-path' const { handleMock, resolveAuthorizedPathMock, - checkRgAvailableMock, + bundledRipgrepCommandMock, getLocalGitOptionsForRegisteredWorktreeMock, - searchWithGitGrepMock, wslAwareSpawnMock, + parseWslPathMock, toWindowsWslPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), - checkRgAvailableMock: vi.fn(), + bundledRipgrepCommandMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), - searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), + parseWslPathMock: vi.fn((_value: string): { distro: string } | null => null), toWindowsWslPathMock: vi.fn((value: string) => value) })) @@ -37,7 +38,7 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../wsl', () => ({ - parseWslPath: vi.fn(() => null), + parseWslPath: parseWslPathMock, toWindowsWslPath: toWindowsWslPathMock })) @@ -63,8 +64,9 @@ vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() })) -vi.mock('./filesystem-search-git', () => ({ - searchWithGitGrep: searchWithGitGrepMock +vi.mock('../ripgrep/bundled-ripgrep-path', async (importOriginal) => ({ + ...(await importOriginal()), + bundledRipgrepCommand: bundledRipgrepCommandMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -76,10 +78,6 @@ vi.mock('./markdown-documents', () => ({ markdownDocumentsFromRelativePaths: vi.fn() })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock -})) - import { registerFilesystemHandlers } from './filesystem' function createMockProcess(): ChildProcess { @@ -95,6 +93,8 @@ function createMockProcess(): ChildProcess { return p } +const BUNDLED_ERROR = "Orca's bundled search tool (ripgrep) could not start" + async function flushMicrotasks(): Promise { for (let index = 0; index < 8; index++) { await Promise.resolve() @@ -109,8 +109,28 @@ describe('filesystem rg search timeout', () => { handlers.set(channel, handler) }) resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) - checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + parseWslPathMock.mockReturnValue(null) + bundledRipgrepCommandMock.mockImplementation((options?: { wsl?: boolean }) => + options?.wsl ? '/bundled/linux/rg' : '/bundled/rg' + ) + }) + + it('rejects a synchronous launch failure without invoking child cleanup', async () => { + wslAwareSpawnMock.mockImplementationOnce(() => { + throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler. + registerFilesystemHandlers({} as never) + await expect( + handlers.get('fs:search')!( + { sender: { id: 7 } }, + { + rootPath: '/repo', + query: 'needle' + } + ) + ).rejects.toThrow('EMFILE') }) it('settles and detaches when rg ignores the timeout kill', async () => { @@ -134,7 +154,8 @@ describe('filesystem rg search timeout', () => { const result = await promise expect(result.truncated).toBe(true) - expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/rg') + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: false }) expect(child.kill).toHaveBeenCalled() expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) @@ -146,15 +167,17 @@ describe('filesystem rg search timeout', () => { }) it.each(['error-first', 'close-first'] as const)( - 'falls back once when a native launch failure is %s', + 'rejects with the bundled-ripgrep error when a native launch failure is %s', async (order) => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: undefined }) + Object.defineProperties(child, { stdout: { value: undefined }, stderr: { value: undefined } }) wslAwareSpawnMock.mockReturnValue(child) - const fallback = { files: [], totalMatches: 0, truncated: false } - searchWithGitGrepMock.mockResolvedValue(fallback) registerFilesystemHandlers({} as never) + // Why a root that exists: an ENOENT spawn failure is also what a vanished workspace looks + // like, so this stays about the binary only while the search root is reachable. + resolveAuthorizedPathMock.mockImplementation(async () => process.cwd()) const promise = handlers.get('fs:search')!( { sender: { id: 7 } }, { rootPath: '/repo', query: 'ok' } @@ -169,9 +192,8 @@ describe('filesystem rg search timeout', () => { expect(() => child.emit('error', error)).not.toThrow() } - await expect(promise).resolves.toBe(fallback) - expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) - expect(checkRgAvailableMock).not.toHaveBeenCalled() + await expect(promise).rejects.toThrow(BUNDLED_ERROR) + expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) } @@ -191,15 +213,30 @@ describe('filesystem rg search timeout', () => { child.emit('error', new Error('post-spawn failure')) await expect(promise).resolves.toMatchObject({ files: [] }) - expect(searchWithGitGrepMock).not.toHaveBeenCalled() }) - it("falls back when a native launcher exits outside ripgrep's contract", async () => { + // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler + // that checks it after the unavailable branch reports a broken install instead. + it('names the unreachable root when the WSL wrapper cannot enter it', async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + child.emit('close', 97, null) + + await expect(promise).rejects.toThrow('Search root is not reachable: /repo') + }) + + it("rejects when a native launcher exits outside ripgrep's contract", async () => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) - const fallback = { files: [], totalMatches: 0, truncated: false } - searchWithGitGrepMock.mockResolvedValue(fallback) registerFilesystemHandlers({} as never) const promise = handlers.get('fs:search')!( @@ -209,8 +246,7 @@ describe('filesystem rg search timeout', () => { await flushMicrotasks() child.emit('close', 127, null) - await expect(promise).resolves.toBe(fallback) - expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + await expect(promise).rejects.toThrow(BUNDLED_ERROR) }) it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => { @@ -226,15 +262,14 @@ describe('filesystem rg search timeout', () => { ) as Promise setTimeout(() => { - child.emit('close', 127, null) + child.emit('close', 0, null) }, 10) await promise - expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') - expect(searchWithGitGrepMock).not.toHaveBeenCalled() + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: true }) expect(wslAwareSpawnMock).toHaveBeenCalledWith( - 'rg', + '/bundled/linux/rg', expect.any(Array), expect.objectContaining({ cwd: 'C:\\repo', @@ -243,21 +278,24 @@ describe('filesystem rg search timeout', () => { ) }) - it('keeps the WSL search preflight and falls back before starting real rg', async () => { - const fallback = { files: [], totalMatches: 0, truncated: false } - getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - checkRgAvailableMock.mockResolvedValue(false) - searchWithGitGrepMock.mockResolvedValue(fallback) + it('spawns the bundled Linux rg for WSL UNC roots', async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + parseWslPathMock.mockReturnValue({ distro: 'Ubuntu' }) registerFilesystemHandlers({} as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fs:search handlers return Promise. const promise = handlers.get('fs:search')!( { sender: { id: 7 } }, - { rootPath: 'C:\\repo', query: 'ok' } + { rootPath: '\\\\wsl.localhost\\Ubuntu\\repo', query: 'ok' } ) as Promise + await flushMicrotasks() + child.emit('close', 1, null) - await expect(promise).resolves.toBe(fallback) - expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') - expect(wslAwareSpawnMock).not.toHaveBeenCalled() + await expect(promise).resolves.toMatchObject({ files: [] }) + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: true }) + expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg') }) it('translates WSL rg output for Windows-path project search results', async () => { diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 2799ff767971..9212200e151f 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -12,16 +12,19 @@ import { } from '../../../shared/text-search' import { absorbPendingRipgrepSpawnError, + classifySynchronousRipgrepSpawnFailure, + isRipgrepMissingCwdExit, + isRipgrepSpawnCwdUsable, isRipgrepUnavailableExit, - killSpawnedRipgrepProcess + isTransientRipgrepSpawnError, + killSpawnedRipgrepProcess, + ripgrepMissingCwdError } from '../../../shared/ripgrep-process-availability' import { toWindowsWslPath, parseWslPath } from '../../wsl' -import { wslAwareSpawn } from '../../git/runner' import { getSshFilesystemProvider, requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { checkRgAvailable } from '../rg-availability' import { resolveAuthorizedPath } from '../filesystem-auth' import { listQuickOpenFiles } from '../filesystem-list-files' import { @@ -29,7 +32,8 @@ import { pathMatchesFileNameFilterTokens, splitFileNameFilterTokens } from '../../../shared/file-name-filter-tokens' -import { searchWithGitGrep } from '../filesystem-search-git' +import { bundledRipgrepUnavailableError } from '../../ripgrep/bundled-ripgrep-path' +import { spawnBundledRipgrep } from '../../ripgrep/bundled-ripgrep-spawn' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -58,14 +62,9 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) const searchKey = `${event.sender.id}:${rootPath}` - // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { - return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { + return new Promise((resolvePromise, rejectPromise) => { const rgArgs = buildRgArgs(args.query, rootPath, args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. const previousChild = activeTextSearches.get(searchKey) @@ -110,8 +109,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte resolvePromise(result) } const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) + const rejectUnavailable = (): void => + finish(Promise.reject(bundledRipgrepUnavailableError())) const processLine = (line: string): void => { const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) if (verdict === 'stop' && child) { @@ -119,11 +118,22 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } } - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) + // A synchronous spawn failure has no child to clean up. + let nextChild: ReturnType + try { + nextChild = spawnBundledRipgrep(rgArgs, { + cwd: rootPath, + wslDistro: localGitOptions.wslDistro, + wslDistroForOutput, + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (error) { + void classifySynchronousRipgrepSpawnFailure(error, rootPath).then( + rejectPromise, + rejectPromise + ) + return + } child = nextChild activeTextSearches.set(searchKey, nextChild) @@ -133,23 +143,54 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const handleStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } - const handleError = (): void => { + const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true + // Why: fd/process pressure is not a broken install; say so instead of blaming the bundled binary. + if (isTransientRipgrepSpawnError(error)) { + finish(Promise.reject(new Error(`rg could not start (${error.code}); try again`))) + return + } if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() + // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the + // binary for it tells the user to reinstall Orca over a workspace that simply moved. + // Why detach close first: a failed spawn emits error THEN close(code < 0), and + // close settles synchronously, so this probe would otherwise race it on a sub-ms + // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + child.off('close', handleClose) + // Why catch: a failed probe must not strand the search; fall back to the prior verdict. + void isRipgrepSpawnCwdUsable(rootPath) + .catch(() => true) + .then((usable) => { + // Why re-check: finish() drops its argument once settled, so a rejected promise + // built after the close handler already won would go unhandled. + if (resolved) { + return + } + finish( + Promise.reject( + usable ? bundledRipgrepUnavailableError() : ripgrepMissingCwdError(rootPath) + ) + ) + }) return } resolveOnce() } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise + // read an unreachable workspace as a broken install and tell the user to reinstall Orca. + if (isRipgrepMissingCwdExit(code)) { + finish(Promise.reject(ripgrepMissingCwdError(rootPath))) + return + } if ( child && isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !wslDistroForOutput + classifyNativeLauncherExit: true }) ) { unavailableExitObserved = true - resolveWithoutRipgrep() + rejectUnavailable() return } const tail = lines.finish() @@ -159,9 +200,9 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte resolveOnce() } - nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', handleStdoutData) - nextChild.stderr!.on('data', handleStderrData) + nextChild.stdout?.setEncoding('utf-8') + nextChild.stdout?.on('data', handleStdoutData) + nextChild.stderr?.on('data', handleStderrData) nextChild.once('error', handleError) nextChild.once('close', handleClose) diff --git a/src/main/ipc/rg-availability.test.ts b/src/main/ipc/rg-availability.test.ts deleted file mode 100644 index 45e40ecb99c5..000000000000 --- a/src/main/ipc/rg-availability.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { wslAwareSpawnMock } = vi.hoisted(() => ({ - wslAwareSpawnMock: vi.fn() -})) - -vi.mock('../git/runner', () => ({ - wslAwareSpawn: wslAwareSpawnMock -})) - -import { checkRgAvailable } from './rg-availability' -import { RipgrepLaunchFailureError } from '../../shared/ripgrep-process-availability' - -function createMockProcess(): ChildProcess { - const child = new EventEmitter() as unknown as ChildProcess - ;(child as unknown as { kill: () => boolean }).kill = vi.fn(() => true) - return child -} - -describe('checkRgAvailable', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns true when rg exits successfully', async () => { - const child = createMockProcess() - wslAwareSpawnMock.mockReturnValue(child) - - const promise = checkRgAvailable('/repo') - child.emit('close', 0) - - await expect(promise).resolves.toBe(true) - expect(wslAwareSpawnMock).toHaveBeenCalledWith('rg', ['--version'], { - cwd: '/repo', - stdio: 'ignore' - }) - }) - - it('rejects transient synchronous launch pressure when requested', async () => { - wslAwareSpawnMock.mockImplementationOnce(() => { - throw Object.assign(new Error('spawn rg ENOMEM'), { code: 'ENOMEM' }) - }) - - await expect( - checkRgAvailable('/repo', 'Ubuntu', { rejectTransientLaunchFailure: true }) - ).rejects.toBeInstanceOf(RipgrepLaunchFailureError) - }) - - it('rejects transient asynchronous launch pressure when requested', async () => { - const child = createMockProcess() - wslAwareSpawnMock.mockReturnValue(child) - const promise = checkRgAvailable('/repo', 'Ubuntu', { - rejectTransientLaunchFailure: true - }) - - child.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) - - await expect(promise).rejects.toBeInstanceOf(RipgrepLaunchFailureError) - }) - - it('settles and detaches when rg availability check ignores timeout kills', async () => { - vi.useFakeTimers() - - try { - const child = createMockProcess() - wslAwareSpawnMock.mockReturnValue(child) - - const promise = checkRgAvailable('/repo') - await Promise.resolve() - - await vi.advanceTimersByTimeAsync(5000) - - await expect(Promise.race([promise, Promise.resolve('pending')])).resolves.toBe(false) - expect(child.kill).toHaveBeenCalled() - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/main/ipc/rg-availability.ts b/src/main/ipc/rg-availability.ts deleted file mode 100644 index 3fa18ade2474..000000000000 --- a/src/main/ipc/rg-availability.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { wslAwareSpawn } from '../git/runner' -import { - isTransientRipgrepSpawnError, - RipgrepLaunchFailureError -} from '../../shared/ripgrep-process-availability' - -const RG_AVAILABILITY_TIMEOUT_MS = 5000 - -// Why the `settled` flag: when rg is not installed, spawn emits both 'error' -// and 'close' with non-deterministic ordering across Node versions/platforms. -// Without guarding, a late 'error' after 'close' would double-resolve (or a -// late 'close' after 'error' would resolve true after we already resolved -// false). `settled` makes whichever fires first authoritative. -// -// Why no cache: `rg --version` is a sub-10ms spawn, so the cost of checking -// per call is negligible. Caching had a footgun in both directions — a -// negative cache persisted across rg installs (forcing an app restart), -// while a positive cache could mask an rg that was uninstalled or broken -// mid-session. - -export function checkRgAvailable( - searchPath?: string, - wslDistro?: string, - options: { rejectTransientLaunchFailure?: boolean } = {} -): Promise { - return new Promise((resolve, reject) => { - let settled = false - // Why: pass cwd plus project-runtime distro so WSL projects are checked - // inside their distro even when the search root is a Windows path. - let child: ReturnType - try { - child = wslAwareSpawn('rg', ['--version'], { - ...(searchPath ? { cwd: searchPath } : {}), - ...(wslDistro ? { wslDistro } : {}), - stdio: 'ignore' - }) - } catch (error) { - if (options.rejectTransientLaunchFailure && isTransientRipgrepSpawnError(error)) { - reject( - new RipgrepLaunchFailureError( - `rg availability check failed to start (${(error as NodeJS.ErrnoException).code})` - ) - ) - } else { - resolve(false) - } - return - } - let timeout: ReturnType - - const cleanup = (): void => { - clearTimeout(timeout) - child.off('error', onError) - child.off('close', onClose) - } - - const settle = (available: boolean, options?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - cleanup() - if (options?.kill) { - child.kill() - } - resolve(available) - } - - const rejectLaunchFailure = (error: NodeJS.ErrnoException): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(new RipgrepLaunchFailureError(`rg availability check failed to start (${error.code})`)) - } - - const onError = (error: NodeJS.ErrnoException): void => { - if (options.rejectTransientLaunchFailure && isTransientRipgrepSpawnError(error)) { - rejectLaunchFailure(error) - return - } - settle(false) - } - const onClose = (code: number | null): void => settle(code === 0) - - child.once('error', onError) - child.once('close', onClose) - timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} diff --git a/src/main/ripgrep/bare-ripgrep-spawn-boundary.test.ts b/src/main/ripgrep/bare-ripgrep-spawn-boundary.test.ts new file mode 100644 index 000000000000..543c4b54e140 --- /dev/null +++ b/src/main/ripgrep/bare-ripgrep-spawn-boundary.test.ts @@ -0,0 +1,112 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the ripgrep chokepoint at the tree level rather than per call site. + * + * Orca ships its own `rg` for every platform, so a spawn must name it by absolute path. A bare + * `'rg'` is not merely slower: on Windows the spawn cwd is the user's repo, and CreateProcessW + * looks there before PATH, so a planted `rg.exe` in a cloned repo would run instead. + * + * The allowlist only shrinks. Each entry is a deliberate PATH fallback that has to stay. + */ +// Empty on purpose: no production file names a bare `rg` AT a spawn site any more. +// +// What this guard cannot see, stated plainly so nobody reads the empty list as a stronger promise +// than it is: on POSIX `pathRipgrepCommand()` still RETURNS the bare name, and +// `probeRipgrepVersion` spawns it through a parameter. A textual guard cannot follow a value, and +// that case is safe regardless -- execvp never consults the cwd. The hazard is Windows-only, and +// the Windows branch of that same function resolves an absolute rg.exe instead. +const ALLOWED_BARE_RIPGREP_SPAWNS: readonly string[] = [] + +// A spawn/exec whose command argument is the literal 'rg', or the constant that holds it. Why the +// constant too: moving the bare name behind `PATH_RIPGREP_COMMAND` would otherwise hide it from +// this guard, and `spawn(PATH_RIPGREP_COMMAND, args, { cwd: userRepo })` is exactly the hijack +// this file exists to catch. +const BARE_SPAWN_PATTERN = + /\b(?:wslAwareSpawn|runProcess|spawn|spawnSync|exec|execFile|execFileSync|execSync)\w*\(\s*(?:['"]rg['"]|PATH_RIPGREP_COMMAND)/ + +const SCANNED_EXTENSIONS = ['.ts', '.tsx'] +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__' +]) + +function isTestFile(path: string): boolean { + return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/') +} + +function collectSourceFiles(root: string): string[] { + let found: string[] = [] + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found = found.concat(collectSourceFiles(full)) + continue + } + if (SCANNED_EXTENSIONS.some((extension) => full.endsWith(extension))) { + found.push(full) + } + } + return found +} + +/** Drop comment-only lines so prose about the old idiom is not an offender. */ +function codeText(contents: string): string { + return contents + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) + .join('\n') +} + +describe('bare ripgrep spawn boundary', () => { + const repoRoot = resolve(__dirname, '..', '..', '..') + const files = collectSourceFiles(join(repoRoot, 'src')) + const offenders = files + .map((file) => relative(repoRoot, file).split('\\').join('/')) + .filter((path) => !isTestFile(path)) + .filter((path) => BARE_SPAWN_PATTERN.test(codeText(readFileSync(join(repoRoot, path), 'utf8')))) + + it.each(['wslAwareSpawn', 'spawnProcess', 'runProcess', 'spawn', 'execFile'])( + 'rejects a bare ripgrep command through %s', + (spawnName) => { + expect(BARE_SPAWN_PATTERN.test(`${spawnName}('rg', args, { cwd })`)).toBe(true) + expect(BARE_SPAWN_PATTERN.test(`${spawnName}(PATH_RIPGREP_COMMAND, args)`)).toBe(true) + expect(BARE_SPAWN_PATTERN.test(`${spawnName}(bundledCommand, args)`)).toBe(false) + } + ) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(files.length).toBeGreaterThan(500) + }) + + it('has no bare rg spawn outside the allowlist', () => { + expect( + offenders.filter((path) => !ALLOWED_BARE_RIPGREP_SPAWNS.includes(path)), + "New bare 'rg' spawn. Use spawnBundledRipgrep (main) or resolveRelayRipgrepCommand (relay)." + ).toEqual([]) + }) + + it('has no stale allowlist entry', () => { + // Why this direction too: a migrated file that stays listed hides the next regression there. + expect( + ALLOWED_BARE_RIPGREP_SPAWNS.filter((path) => !offenders.includes(path)), + "Allowlist entry no longer spawns a bare 'rg' -- delete the line." + ).toEqual([]) + }) +}) diff --git a/src/main/ripgrep/bundled-ripgrep-path.test.ts b/src/main/ripgrep/bundled-ripgrep-path.test.ts new file mode 100644 index 000000000000..e05c1ba706df --- /dev/null +++ b/src/main/ripgrep/bundled-ripgrep-path.test.ts @@ -0,0 +1,109 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { toBundledRipgrepPlatform } from '../../shared/bundled-ripgrep' +import { + bundledRipgrepCommand, + bundledRipgrepContentKey, + bundledRipgrepWslSpawnOptions, + resetBundledRipgrepPathCacheForTests, + resolveBundledRipgrepPath +} from './bundled-ripgrep-path' + +const originalResourcesPath = process.resourcesPath + +function setResourcesPath(value: string | undefined): void { + Object.defineProperty(process, 'resourcesPath', { configurable: true, value }) +} + +describe('bundled ripgrep path', () => { + afterEach(() => { + setResourcesPath(originalResourcesPath) + resetBundledRipgrepPathCacheForTests() + }) + + it('resolves the checkout binary for this host in development', () => { + setResourcesPath(undefined) + const command = bundledRipgrepCommand() + + expect(command).toContain(join('@vscode', 'ripgrep-universal', 'bin')) + expect(existsSync(command)).toBe(true) + }) + + it('resolves the Linux build for WSL-routed spawns', () => { + setResourcesPath(undefined) + const linuxPlatform = toBundledRipgrepPlatform('linux', process.arch) + + expect(bundledRipgrepCommand({ wsl: true })).toContain(join('bin', `${linuxPlatform}`, 'rg')) + }) + + it('prefers the packaged resources copy', () => { + const resourcesDir = mkdtempSync(join(tmpdir(), 'orca-rg-resources-')) + try { + const packaged = join(resourcesDir, 'ripgrep', 'linux-x64', 'rg') + mkdirSync(join(resourcesDir, 'ripgrep', 'linux-x64'), { recursive: true }) + writeFileSync(packaged, '') + chmodSync(packaged, 0o755) + setResourcesPath(resourcesDir) + + expect(resolveBundledRipgrepPath('linux-x64')).toBe(realpathSync(packaged)) + } finally { + rmSync(resourcesDir, { recursive: true, force: true }) + } + }) + + it('names no bundled build for platforms outside the relay set', () => { + expect(toBundledRipgrepPlatform('freebsd', 'x64')).toBeNull() + expect(toBundledRipgrepPlatform('win32', 'ia32')).toBeNull() + }) + + it('keys the remote cache on the shipped bytes', () => { + setResourcesPath(undefined) + const platform = toBundledRipgrepPlatform(process.platform, process.arch)! + const key = bundledRipgrepContentKey(platform) + + expect(key).toMatch(/^[0-9a-f]{16}$/) + expect(bundledRipgrepContentKey('win32-x64')).not.toBe(key) + }) + + it('picks the distro-arch Linux build and fails closed when its drive is unavailable', () => { + const { wslShellCommand } = bundledRipgrepWslSpawnOptions( + 'C:\\Program Files\\Orca\\resources\\ripgrep\\linux-x64\\rg' + ) + + expect(wslShellCommand).toContain(`wslpath -u 'C:\\Program Files\\Orca\\resources\\ripgrep'`) + expect(wslShellCommand).toContain('aarch64|arm64) a=linux-arm64') + expect(wslShellCommand).toContain('printf %s "$d/$a/rg"') + expect(wslShellCommand).toContain('else printf /dev/null/orca-ripgrep-unavailable') + expect(wslShellCommand).not.toContain('else printf rg') + expect(bundledRipgrepWslSpawnOptions('rg')).toEqual({}) + }) + + it.skipIf(process.platform === 'win32')( + 'never executes a PATH ripgrep when WSL cannot translate the install drive', + async () => { + const { wslShellCommand } = bundledRipgrepWslSpawnOptions( + 'C:\\Orca\\resources\\ripgrep\\linux-x64\\rg' + ) + const result = await runProcess({ + program: '/bin/bash', + args: [ + '-c', + `wslpath() { return 1; }; rg() { echo WRONG_RIPGREP; }; ${wslShellCommand} --version` + ] + }) + expect(result.code).toBeGreaterThan(2) + expect(result.stdout).not.toContain('WRONG_RIPGREP') + } + ) +}) diff --git a/src/main/ripgrep/bundled-ripgrep-path.ts b/src/main/ripgrep/bundled-ripgrep-path.ts new file mode 100644 index 000000000000..e52a83d90764 --- /dev/null +++ b/src/main/ripgrep/bundled-ripgrep-path.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { + BUNDLED_RIPGREP_PACKAGE_BIN_DIR, + BUNDLED_RIPGREP_RESOURCE_DIR, + bundledRipgrepBinaryName, + toBundledRipgrepPlatform, + type BundledRipgrepPlatform +} from '../../shared/bundled-ripgrep' + +/** Only unpackaged dev/test hosts may fall back to this; see bundledRipgrepCommand. */ +const PATH_RIPGREP_COMMAND = 'rg' + +const resolvedPaths = new Map() +const contentKeys = new Map() + +function isPackagedApp(): boolean { + return hasAppEnvironment() && getAppEnvironment().isPackaged() +} + +/** `platform` is a `-` label; an unbundled one still names where rg would live. */ +function candidatePaths(platform: string): string[] { + const binaryName = bundledRipgrepBinaryName(platform) + const candidates: string[] = [] + if (process.resourcesPath) { + candidates.push(join(process.resourcesPath, BUNDLED_RIPGREP_RESOURCE_DIR, platform, binaryName)) + } + // Why nothing else: a packaged app must never run a binary from whatever checkout it was launched in. + if (isPackagedApp()) { + // Why: plain-Node orcad has no resourcesPath; its build copies rg into its own install root. + candidates.push( + join(getAppEnvironment().getAppPath(), BUNDLED_RIPGREP_RESOURCE_DIR, platform, binaryName) + ) + return candidates + } + // Why: development and test hosts run from a checkout where only node_modules holds the binaries. + const roots = [hasAppEnvironment() ? getAppEnvironment().getAppPath() : null, process.cwd()] + for (const root of roots) { + if (root) { + candidates.push(join(root, BUNDLED_RIPGREP_PACKAGE_BIN_DIR, platform, binaryName)) + } + } + return candidates +} + +function realpathOrSelf(path: string): string { + try { + return realpathSync(path) + } catch { + return path + } +} + +// Why never throw: the key only gates the SSH upload, which must not break relay deploy. +function hashBinary(binaryPath: string | null): string | null { + if (!binaryPath) { + return null + } + try { + return createHash('sha256').update(readFileSync(binaryPath)).digest('hex').slice(0, 16) + } catch { + return null + } +} + +/** Absolute path to Orca's own ripgrep for `platform`, or null when this install lacks it. */ +export function resolveBundledRipgrepPath(platform: BundledRipgrepPlatform): string | null { + if (!resolvedPaths.has(platform)) { + const found = candidatePaths(platform).find((path) => existsSync(path)) + // Why realpath: dev checkouts reach the package through pnpm symlinks, which SSH uploads reject. + resolvedPaths.set(platform, found ? realpathOrSelf(found) : null) + } + return resolvedPaths.get(platform) ?? null +} + +/** + * The rg command for a local spawn: this host's bundled binary, or its Linux build when the + * spawn is routed into WSL. Falls back to PATH `rg` only in unpackaged dev/test hosts. + */ +export function bundledRipgrepCommand(options: { wsl?: boolean } = {}): string { + const os = options.wsl ? 'linux' : process.platform + const platform = toBundledRipgrepPlatform(os, process.arch) + const resolved = platform ? resolveBundledRipgrepPath(platform) : null + if (resolved) { + return resolved + } + if (!isPackagedApp()) { + return PATH_RIPGREP_COMMAND + } + // Why the expected path, not bare 'rg': Windows resolves a bare name in the spawn cwd (the repo) + // first, so a damaged packaged install must fail with ENOENT instead of running a planted rg.exe. + // Why this covers an arch we do not bundle too: it has no candidate of its own, but naming where + // rg would have lived keeps a packaged app from ever handing a bare name to spawn. + return candidatePaths(platform ?? `${os}-${process.arch}`)[0] ?? PATH_RIPGREP_COMMAND +} + +/** + * Spawn options for a WSL-routed rg: inside the distro, pick the Linux build matching its own + * architecture (Windows-on-ARM runs x64 Orca beside arm64 distros) from the Windows install via + * wslpath (custom automount roots). An inaccessible install fails through the missing-tool path. + */ +export function bundledRipgrepWslSpawnOptions(command: string): { wslShellCommand?: string } { + const ripgrepRoot = command.replace(/[\\/][^\\/]+[\\/][^\\/]+$/, '') + if (command === PATH_RIPGREP_COMMAND || ripgrepRoot === command) { + return {} + } + return { + wslShellCommand: `"$(d=$(wslpath -u ${quotePosixShell(ripgrepRoot)} 2>/dev/null); case "$(uname -m)" in aarch64|arm64) a=linux-arm64;; *) a=linux-x64;; esac; if [ -n "$d" ]; then printf %s "$d/$a/rg"; else printf /dev/null/orca-ripgrep-unavailable; fi)"` + } +} + +/** + * Short content hash of this install's binary for `platform`. Keys the SSH remote cache so any + * change to the shipped bytes (a package bump, a rebuild, re-signing) re-uploads without a manual step. + */ +export function bundledRipgrepContentKey(platform: BundledRipgrepPlatform): string | null { + if (!contentKeys.has(platform)) { + contentKeys.set(platform, hashBinary(resolveBundledRipgrepPath(platform))) + } + return contentKeys.get(platform) ?? null +} + +export function resetBundledRipgrepPathCacheForTests(): void { + resolvedPaths.clear() + contentKeys.clear() +} + +// Why no git/readdir fallback: VS Code ships the same contract — a bundled rg that cannot start +// means a damaged install or security-software block, which a slower partial listing would hide. +export function bundledRipgrepUnavailableError(): Error { + return new Error( + "Orca's bundled search tool (ripgrep) could not start. Reinstall Orca, or allow it in your security software." + ) +} diff --git a/src/main/ripgrep/bundled-ripgrep-real.test.ts b/src/main/ripgrep/bundled-ripgrep-real.test.ts new file mode 100644 index 000000000000..b24f5ff55884 --- /dev/null +++ b/src/main/ripgrep/bundled-ripgrep-real.test.ts @@ -0,0 +1,78 @@ +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment' +import { bundledRipgrepBinaryName } from '../../shared/bundled-ripgrep' +import { bundledRipgrepCommand, resetBundledRipgrepPathCacheForTests } from './bundled-ripgrep-path' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' + +describe('packaged ripgrep execution', () => { + let fixture: string + let binary: string + let workspace: string + const resourcesPath = process.resourcesPath + + beforeEach(() => { + resetBundledRipgrepPathCacheForTests() + const source = bundledRipgrepCommand() + fixture = mkdtempSync(join(tmpdir(), 'orca bundled rg ')) + const resources = join(fixture, 'resources') + const platform = `${process.platform}-${process.arch}` + binary = join(resources, 'ripgrep', platform, bundledRipgrepBinaryName(platform)) + mkdirSync(dirname(binary), { recursive: true }) + copyFileSync(source, binary) + workspace = join(fixture, 'folder workspace') + mkdirSync(workspace) + writeFileSync(join(workspace, '日本語 file.txt'), 'needle\n') + // A checkout executable must never replace a missing packaged binary. + writeFileSync(join(workspace, 'rg'), '#!/bin/sh\necho planted\n', { mode: 0o755 }) + writeFileSync(join(workspace, 'rg.exe'), 'planted') + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resources }) + setAppEnvironment({ + ...getAppEnvironment(), + isPackaged: () => true, + getAppPath: () => fixture + }) + resetBundledRipgrepPathCacheForTests() + }) + + afterEach(() => { + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath }) + resetBundledRipgrepPathCacheForTests() + rmSync(fixture, { recursive: true, force: true }) + }) + + function search(): Promise { + return new Promise((resolve, reject) => { + const child = spawnBundledRipgrep(['--no-config', '--files-with-matches', 'needle', '.'], { + cwd: workspace, + env: { ...process.env, PATH: '' }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + child.once('error', reject) + child.once('close', (code) => { + if (code === 0) { + resolve(output) + } else { + reject(new Error(`ripgrep exited ${code}`)) + } + }) + }) + } + + it('searches a folder with no PATH tools and spaces in the install and workspace paths', async () => { + expect(await search()).toContain('日本語 file.txt') + }) + + it('fails when its binary is missing instead of running the workspace executable', async () => { + rmSync(binary) + resetBundledRipgrepPathCacheForTests() + await expect(search()).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/src/main/ripgrep/bundled-ripgrep-spawn.test.ts b/src/main/ripgrep/bundled-ripgrep-spawn.test.ts new file mode 100644 index 000000000000..60dd69bce60a --- /dev/null +++ b/src/main/ripgrep/bundled-ripgrep-spawn.test.ts @@ -0,0 +1,56 @@ +import type { ChildProcess } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' + +const wslAwareSpawnMock = + vi.fn<(command: string, args: string[], options: Record) => ChildProcess>() + +vi.mock('../git/runner', () => ({ wslAwareSpawn: wslAwareSpawnMock })) +vi.mock('./bundled-ripgrep-path', () => ({ + bundledRipgrepCommand: (options?: { wsl?: boolean }) => + options?.wsl ? '/install/ripgrep/linux-x64/rg' : '/install/ripgrep/darwin-arm64/rg', + bundledRipgrepWslSpawnOptions: () => ({ wslShellCommand: '' }) +})) + +const { spawnBundledRipgrep } = await import('./bundled-ripgrep-spawn') + +function spawnOnce(options: Parameters[1]): { + command: string + options: Record +} { + wslAwareSpawnMock.mockClear() + spawnBundledRipgrep(['--files'], options) + const [command, , spawnOptions] = wslAwareSpawnMock.mock.calls[0] + return { command, options: spawnOptions } +} + +describe('spawnBundledRipgrep', () => { + it("spawns this host's bundled binary with no WSL routing for a local workspace", () => { + const { command, options } = spawnOnce({ cwd: '/repo' }) + + expect(command).toBe('/install/ripgrep/darwin-arm64/rg') + expect(options).not.toHaveProperty('wslDistro') + expect(options).not.toHaveProperty('wslShellCommand') + expect(options.cwd).toBe('/repo') + }) + + it('routes through the distro and picks the Linux build for a WSL workspace', () => { + const { command, options } = spawnOnce({ + cwd: '\\\\wsl.localhost\\Ubuntu\\repo', + wslDistro: 'Ubuntu', + wslDistroForOutput: 'Ubuntu' + }) + + expect(command).toBe('/install/ripgrep/linux-x64/rg') + expect(options.wslDistro).toBe('Ubuntu') + expect(options.wslShellCommand).toBe('') + }) + + // Why: only rg running inside the distro emits Linux paths, so only that case rewrites the command. + it('keeps the host build when a distro routes the spawn but output stays Windows-side', () => { + const { command, options } = spawnOnce({ cwd: 'C:\\repo', wslDistro: 'Ubuntu' }) + + expect(command).toBe('/install/ripgrep/darwin-arm64/rg') + expect(options.wslDistro).toBe('Ubuntu') + expect(options).not.toHaveProperty('wslShellCommand') + }) +}) diff --git a/src/main/ripgrep/bundled-ripgrep-spawn.ts b/src/main/ripgrep/bundled-ripgrep-spawn.ts new file mode 100644 index 000000000000..5d3132fcc118 --- /dev/null +++ b/src/main/ripgrep/bundled-ripgrep-spawn.ts @@ -0,0 +1,41 @@ +import { wslAwareSpawn } from '../git/runner' +import { bundledRipgrepCommand, bundledRipgrepWslSpawnOptions } from './bundled-ripgrep-path' +import { RIPGREP_MISSING_CWD_EXIT_CODE } from '../../shared/ripgrep-process-availability' + +// Why derived rather than imported from node:child_process: a direct import would land this file +// on the child_process ratchet's allowlist, which only ever shrinks. +type WslAwareSpawnOptions = Parameters[2] + +export type BundledRipgrepSpawnOptions = Omit< + WslAwareSpawnOptions, + 'wslDistro' | 'wslShellCommand' | 'useWslLoginShell' +> & { + cwd: string + /** Distro whose git options own this workspace; routes the spawn through wsl.exe. */ + wslDistro?: string + /** Set when rg runs inside WSL, so it emits Linux paths the caller translates back. */ + wslDistroForOutput?: string +} + +/** + * The single way Orca spawns ripgrep: its own binary, routed into WSL when the workspace lives + * there. Why one entry point: a bare `rg` must never reach spawn, because Windows resolves a bare + * name in the spawn cwd — the repo — before PATH. + */ +export function spawnBundledRipgrep( + args: string[], + options: BundledRipgrepSpawnOptions +): ReturnType { + const { wslDistro, wslDistroForOutput, ...spawnOptions } = options + const command = bundledRipgrepCommand({ wsl: Boolean(wslDistroForOutput) }) + return wslAwareSpawn(command, args, { + ...spawnOptions, + ...(wslDistro ? { wslDistro } : {}), + // Why only when routed into WSL: a native spawn reports an unusable cwd as a spawn error, + // but inside `bash -c` a failed `cd` is just exit 1 -- the same code rg uses for "no matches". + ...(wslDistro || wslDistroForOutput + ? { cwdFailureExitCode: RIPGREP_MISSING_CWD_EXIT_CODE } + : {}), + ...(wslDistroForOutput ? bundledRipgrepWslSpawnOptions(command) : {}) + }) +} diff --git a/src/main/ripgrep/fd-exhausted-spawn.test.ts b/src/main/ripgrep/fd-exhausted-spawn.test.ts new file mode 100644 index 000000000000..7509dc5f1893 --- /dev/null +++ b/src/main/ripgrep/fd-exhausted-spawn.test.ts @@ -0,0 +1,36 @@ +import { execFileSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' + +// Exhaust descriptors in a bounded subprocess so the test runner keeps its own handles. +describe.runIf(process.platform !== 'win32')('descriptor-exhausted ripgrep spawn', () => { + it('can attach the error listener when a failed spawn has no stdout', () => { + const script = ` + const { spawn } = require('node:child_process'); + const { openSync, closeSync } = require('node:fs'); + const descriptors = []; + try { while (true) descriptors.push(openSync('/dev/null', 'r')); } catch {} + let result; + try { + const child = spawn(process.execPath, ['-e', ''], { stdio: ['ignore', 'pipe', 'pipe'] }); + result = { hasStdout: Boolean(child.stdout), hasPid: Boolean(child.pid) }; + child.stdout?.setEncoding('utf8'); + child.stderr?.resume(); + child.once('error', (error) => console.log(JSON.stringify({ ...result, code: error.code }))); + } catch (error) { + result = { code: error.code }; + process.nextTick(() => console.log(JSON.stringify(result))); + } finally { + for (const descriptor of descriptors) closeSync(descriptor); + } + ` + const output = execFileSync( + '/bin/sh', + ['-c', 'ulimit -n 128; exec "$@"', 'sh', process.execPath, '-e', script], + { + encoding: 'utf8', + timeout: 10_000 + } + ) + expect(JSON.parse(output)).toMatchObject({ code: 'EMFILE' }) + }) +}) diff --git a/src/main/ripgrep/synchronous-spawn-failure.test.ts b/src/main/ripgrep/synchronous-spawn-failure.test.ts new file mode 100644 index 000000000000..9f6a20af1cee --- /dev/null +++ b/src/main/ripgrep/synchronous-spawn-failure.test.ts @@ -0,0 +1,78 @@ +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + classifySynchronousRipgrepSpawnFailure, + RipgrepLaunchFailureError, + RipgrepUnavailableError +} from '../../shared/ripgrep-process-availability' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function fileAsSearchRoot(): string { + const dir = mkdtempSync(join(tmpdir(), 'rg-sync-')) + dirs.push(dir) + const path = join(dir, 'not-a-directory') + writeFileSync(path, '') + return path +} + +describe('synchronous ripgrep spawn failures', () => { + // Why a real spawn: the whole reason this path exists is that Node throws inline for errnos + // outside its async set. Asserting against a hand-made error would not prove that happens. + it.skipIf(process.platform === 'win32')( + 'throws inline rather than emitting error when the root is a file', + () => { + const root = fileAsSearchRoot() + + expect(() => spawn(process.execPath, ['-e', ''], { cwd: root, stdio: 'ignore' })).toThrow( + expect.objectContaining({ code: 'ENOTDIR' }) + ) + } + ) + + it.skipIf(process.platform === 'win32')( + 'names the unreachable root instead of surfacing a raw spawn errno', + async () => { + const root = fileAsSearchRoot() + let thrown: unknown + try { + spawn(process.execPath, ['-e', ''], { cwd: root, stdio: 'ignore' }) + } catch (error) { + thrown = error + } + + const verdict = await classifySynchronousRipgrepSpawnFailure(thrown, root) + + expect(verdict.message).toBe(`Search root is not reachable: ${root}`) + } + ) + + it('reports fd/process pressure as retryable, not as a broken install', async () => { + const verdict = await classifySynchronousRipgrepSpawnFailure( + Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }), + process.cwd() + ) + + expect(verdict).toBeInstanceOf(RipgrepLaunchFailureError) + expect(verdict.message).toContain('EMFILE') + }) + + // Why: a caller that already reached a verdict has more context than a cwd probe does, and + // re-diagnosing it would turn "the binary is broken" into "your workspace moved". + it('passes an already-classified error straight through', async () => { + const original = new RipgrepUnavailableError() + + expect(await classifySynchronousRipgrepSpawnFailure(original, '/definitely/not/here')).toBe( + original + ) + }) +}) diff --git a/src/main/ripgrep/wsl-missing-cwd-exit.test.ts b/src/main/ripgrep/wsl-missing-cwd-exit.test.ts new file mode 100644 index 000000000000..6dc030bdf8a6 --- /dev/null +++ b/src/main/ripgrep/wsl-missing-cwd-exit.test.ts @@ -0,0 +1,62 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { describe, expect, it, onTestFinished } from 'vitest' +import { resolveCommand } from '../git/command-runner/wsl-command-resolution' +import { + isRipgrepMissingCwdExit, + RIPGREP_MISSING_CWD_EXIT_CODE +} from '../../shared/ripgrep-process-availability' + +const execFileAsync = promisify(execFile) + +async function shellExitCode(script: string): Promise { + try { + await execFileAsync('bash', ['-c', script]) + return 0 + } catch (error) { + if (error instanceof Error && 'code' in error && typeof error.code === 'number') { + return error.code + } + return -1 + } +} + +describe('WSL missing-cwd exit code', () => { + // Why a real shell: the whole point is that `cd x && cmd` collapses into the shell's exit 1, + // which is also ripgrep's "no matches". Asserting on a mock would not prove the distinction. + it('is indistinguishable from "no matches" without the guard, and distinct with it', async () => { + expect(await shellExitCode('cd /definitely/not/here && echo hi')).toBe(1) + expect( + await shellExitCode( + `cd /definitely/not/here || exit ${RIPGREP_MISSING_CWD_EXIT_CODE}; echo hi` + ) + ).toBe(RIPGREP_MISSING_CWD_EXIT_CODE) + }) + + it('is above the exit codes ripgrep itself uses', () => { + for (const ripgrepCode of [0, 1, 2]) { + expect(isRipgrepMissingCwdExit(ripgrepCode)).toBe(false) + } + expect(isRipgrepMissingCwdExit(RIPGREP_MISSING_CWD_EXIT_CODE)).toBe(true) + expect(isRipgrepMissingCwdExit(null)).toBe(false) + }) + + // Why stub the platform: WSL routing is win32-only, so on any other host resolveCommand would + // return early and the assertions below would silently test nothing. + it('emits the guard only when asked', () => { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + onTestFinished(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + }) + const cwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + const plain = resolveCommand('rg', ['--files'], cwd, 'Ubuntu') + const guarded = resolveCommand('rg', ['--files'], cwd, 'Ubuntu', { + cwdFailureExitCode: RIPGREP_MISSING_CWD_EXIT_CODE + }) + + expect(plain.args.at(-1)).toContain("&& 'rg'") + expect(guarded.args.at(-1)).toContain(`|| exit ${RIPGREP_MISSING_CWD_EXIT_CODE};`) + expect(guarded.args.at(-1)).not.toContain("&& 'rg'") + }) +}) diff --git a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts index f64e9a37f83a..fb136c783e74 100644 --- a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts @@ -25,18 +25,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-mock-registry.ts b/src/main/runtime/orca-runtime-files-mock-registry.ts index 62c1acef4b36..d66f7caa8003 100644 --- a/src/main/runtime/orca-runtime-files-mock-registry.ts +++ b/src/main/runtime/orca-runtime-files-mock-registry.ts @@ -6,6 +6,7 @@ import type * as Fs from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' import type * as GitRunner from '../git/runner' +import type * as BundledRipgrepPath from '../ripgrep/bundled-ripgrep-path' // Mock signatures stay loose on payloads: suites assert call arguments and stub // resolved values, so real fs/git result types would only fight the fixtures. @@ -30,13 +31,10 @@ export const watchInWatcherProcessMock: Mock< > = vi.fn() export const closeWatcherInWatcherProcessMock: Mock<(rootPath: string) => Promise> = vi.fn() -export const checkRgAvailableMock: Mock< - (searchPath?: string, wslDistro?: string) => Promise -> = vi.fn() export const getLocalGitOptionsForRegisteredWorktreeMock: Mock< (store: unknown, worktreePath: string, repoPath?: string) => unknown > = vi.fn() -export const searchWithGitGrepMock: Mock<(...args: unknown[]) => Promise> = vi.fn() +export const bundledRipgrepCommandMock: Mock<(options?: { wsl?: boolean }) => string> = vi.fn() export const wslAwareSpawnMock: Mock< (command: string, args: string[], options: unknown) => unknown > = vi.fn() @@ -93,18 +91,18 @@ export async function gitRunnerModuleMock() { } } -export const rgAvailabilityMock = { - checkRgAvailable: checkRgAvailableMock +export async function bundledRipgrepPathModuleMock() { + const actual = await vi.importActual('../ripgrep/bundled-ripgrep-path') + return { + ...actual, + bundledRipgrepCommand: bundledRipgrepCommandMock + } } export const localWorktreeRuntimeOptionsMock = { getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock } -export const filesystemSearchGitMock = { - searchWithGitGrep: searchWithGitGrepMock -} - const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' @@ -131,12 +129,14 @@ export function resetRuntimeFileMocks(): void { watchInWatcherProcessMock.mockReset() closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() - checkRgAvailableMock.mockReset() - searchWithGitGrepMock.mockReset() + bundledRipgrepCommandMock.mockReset() getSshFilesystemProviderMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + bundledRipgrepCommandMock.mockImplementation((options) => + options?.wsl ? '/bundled/linux/rg' : '/bundled/rg' + ) readdirMock.mockResolvedValue([]) lstatMock.mockRejectedValue(enoent()) renameMock.mockResolvedValue(undefined) diff --git a/src/main/runtime/orca-runtime-files-preview-budget.test.ts b/src/main/runtime/orca-runtime-files-preview-budget.test.ts index d8c1086cbe68..9b0511db2811 100644 --- a/src/main/runtime/orca-runtime-files-preview-budget.test.ts +++ b/src/main/runtime/orca-runtime-files-preview-budget.test.ts @@ -26,18 +26,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-rename-authority.test.ts b/src/main/runtime/orca-runtime-files-rename-authority.test.ts index 489a0981db2b..0f8c8f6cca77 100644 --- a/src/main/runtime/orca-runtime-files-rename-authority.test.ts +++ b/src/main/runtime/orca-runtime-files-rename-authority.test.ts @@ -26,18 +26,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-search.test.ts b/src/main/runtime/orca-runtime-files-search.test.ts index 09cb185ea287..fb6a8d969004 100644 --- a/src/main/runtime/orca-runtime-files-search.test.ts +++ b/src/main/runtime/orca-runtime-files-search.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' import { - checkRgAvailableMock, + bundledRipgrepCommandMock, getLocalGitOptionsForRegisteredWorktreeMock, getSshFilesystemProviderMock, resolveAuthorizedPathMock, - searchWithGitGrepMock, wslAwareSpawnMock } from './orca-runtime-files-mock-registry' import { @@ -28,17 +27,12 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock +vi.mock('../ripgrep/bundled-ripgrep-path', async () => + (await import('./orca-runtime-files-mock-registry')).bundledRipgrepPathModuleMock() ) vi.mock( '../providers/ssh-filesystem-dispatch', @@ -69,6 +63,22 @@ async function flushRuntimeSearchMicrotasks(): Promise { describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() + it('rejects a synchronous launch failure without invoking child cleanup', async () => { + const { commands } = createRuntimeFileCommands({ + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + executionHostId: 'local' + })) + }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockImplementationOnce(() => { + throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) + }) + await expect(commands.searchRuntimeFiles('id:wt-1', { query: 'needle' })).rejects.toThrow( + 'EMFILE' + ) + }) + it('keeps byte-budgeted legacy listings count-bounded across an SSH hop', async () => { const listFiles = vi.fn().mockResolvedValue(['src/index.ts']) getSshFilesystemProviderMock.mockReturnValue({ listFiles }) @@ -101,7 +111,6 @@ describe('RuntimeFileCommands', () => { const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() resolveAuthorizedPathMock.mockResolvedValue('/repo') - checkRgAvailableMock.mockResolvedValue(true) wslAwareSpawnMock.mockReturnValue(child) const resultPromise = commands.searchRuntimeFiles('id:wt-1', { @@ -120,11 +129,16 @@ describe('RuntimeFileCommands', () => { expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) - expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: false }) + expect(wslAwareSpawnMock).toHaveBeenCalledWith( + '/bundled/rg', + expect.any(Array), + expect.objectContaining({ cwd: '/repo' }) + ) }) it.each(['error-first', 'close-first'] as const)( - 'falls back once when runtime rg native launch failure is %s', + 'rejects with the bundled-ripgrep error when runtime rg native launch failure is %s', async (order) => { const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, @@ -133,10 +147,11 @@ describe('RuntimeFileCommands', () => { const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() Object.defineProperty(child, 'pid', { value: undefined }) - resolveAuthorizedPathMock.mockResolvedValue('/repo') + Object.defineProperties(child, { stdout: { value: undefined }, stderr: { value: undefined } }) + // Why a root that exists: an ENOENT spawn failure is also what a vanished workspace looks + // like, so this stays about the binary only while the search root is reachable. + resolveAuthorizedPathMock.mockResolvedValue(process.cwd()) wslAwareSpawnMock.mockReturnValue(child) - const fallback = { files: [], totalMatches: 0, truncated: false } - searchWithGitGrepMock.mockResolvedValue(fallback) const resultPromise = commands.searchRuntimeFiles('id:wt-1', { query: 'needle', @@ -152,15 +167,37 @@ describe('RuntimeFileCommands', () => { expect(() => child.emit('error', error)).not.toThrow() } - await expect(resultPromise).resolves.toBe(fallback) - expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) - expect(checkRgAvailableMock).not.toHaveBeenCalled() + await expect(resultPromise).rejects.toThrow("Orca's bundled search tool (ripgrep)") + expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) } ) - it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => { + // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler + // that checks it after the unavailable branch reports a broken install instead. + it('names the unreachable root when the WSL wrapper cannot enter it', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + executionHostId: 'local' + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + child.emit('close', 97, null) + + await expect(resultPromise).rejects.toThrow('Search root is not reachable: /repo') + }) + + it("rejects when a runtime native launcher exits outside ripgrep's contract", async () => { const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, executionHostId: 'local' @@ -170,8 +207,6 @@ describe('RuntimeFileCommands', () => { Object.defineProperty(child, 'pid', { value: 1 }) resolveAuthorizedPathMock.mockResolvedValue('/repo') wslAwareSpawnMock.mockReturnValue(child) - const fallback = { files: [], totalMatches: 0, truncated: false } - searchWithGitGrepMock.mockResolvedValue(fallback) const resultPromise = commands.searchRuntimeFiles('id:wt-1', { query: 'needle', @@ -180,8 +215,7 @@ describe('RuntimeFileCommands', () => { await flushRuntimeSearchMicrotasks() child.emit('close', 127, null) - await expect(resultPromise).resolves.toBe(fallback) - expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + await expect(resultPromise).rejects.toThrow("Orca's bundled search tool (ripgrep)") }) it('routes runtime rg searches through the registered WSL project runtime', async () => { @@ -197,7 +231,6 @@ describe('RuntimeFileCommands', () => { const child = createRuntimeSearchChild() Object.defineProperty(child, 'pid', { value: 1 }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') - checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) wslAwareSpawnMock.mockReturnValue(child) @@ -208,7 +241,7 @@ describe('RuntimeFileCommands', () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() - child.emit('close', 127, null) + child.emit('close', 0, null) await expect(resultPromise).resolves.toMatchObject({ files: [] }) expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( @@ -216,10 +249,9 @@ describe('RuntimeFileCommands', () => { 'C:\\repo', 'C:\\repo' ) - expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') - expect(searchWithGitGrepMock).not.toHaveBeenCalled() + expect(bundledRipgrepCommandMock).toHaveBeenCalledWith({ wsl: true }) expect(wslAwareSpawnMock).toHaveBeenCalledWith( - 'rg', + '/bundled/linux/rg', expect.any(Array), expect.objectContaining({ cwd: 'C:\\repo', @@ -228,25 +260,6 @@ describe('RuntimeFileCommands', () => { ) }) - it('keeps the runtime WSL preflight and falls back before starting real rg', async () => { - const resolveRuntimeFileTarget = vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' }, - executionHostId: 'local' - })) - const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) - const fallback = { files: [], totalMatches: 0, truncated: false } - resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') - getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - checkRgAvailableMock.mockResolvedValue(false) - searchWithGitGrepMock.mockResolvedValue(fallback) - - await expect( - commands.searchRuntimeFiles('id:wt-1', { query: 'needle', maxResults: 10 }) - ).resolves.toBe(fallback) - expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') - expect(wslAwareSpawnMock).not.toHaveBeenCalled() - }) - it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => { const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, diff --git a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts index 8f09e269f7fb..13e5bbb8682a 100644 --- a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts @@ -30,18 +30,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts index 4b9bad8a9c08..7a90429cfd1c 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts @@ -28,18 +28,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts index 7ecc58b12654..27a667ebfc66 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts @@ -29,18 +29,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts index 95c6121a51a5..f2266ae85265 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts @@ -26,18 +26,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts b/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts index d4f2f4e25ae5..f0bd96d9001f 100644 --- a/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts @@ -26,18 +26,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts b/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts index 23c599801b8f..2d0766ffc505 100644 --- a/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts @@ -24,18 +24,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts b/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts index 4a18df05b842..0cf0f19acbbb 100644 --- a/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts +++ b/src/main/runtime/orca-runtime-files-watch-host-scope.test.ts @@ -27,18 +27,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index e7f3172f7537..8ed435fddc70 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -13,15 +13,19 @@ import { ingestRgJsonLine } from '../../shared/text-search' import { parseWslPath, toWindowsWslPath } from '../wsl' -import { checkRgAvailable } from '../ipc/rg-availability' -import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { bundledRipgrepUnavailableError } from '../ripgrep/bundled-ripgrep-path' +import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' import { absorbPendingRipgrepSpawnError, + classifySynchronousRipgrepSpawnFailure, + isRipgrepMissingCwdExit, + isRipgrepSpawnCwdUsable, isRipgrepUnavailableExit, - killSpawnedRipgrepProcess + isTransientRipgrepSpawnError, + killSpawnedRipgrepProcess, + ripgrepMissingCwdError } from '../../shared/ripgrep-process-availability' import type { ChildProcessHandle } from '../../shared/child-process/process-spec' -import { wslAwareSpawn } from '../git/runner' import type { RuntimeFileExplorerPath } from './runtime-file-command-target' import type { IFilesystemProvider } from '../providers/types' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' @@ -42,15 +46,9 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC 1, Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) - const wslInfo = parseWslPath(authorizedRootPath) - if ( - (wslInfo || localGitOptions.wslDistro) && - !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) - ) { - return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) - } + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - return new Promise((resolvePromise) => { + return new Promise((resolvePromise, rejectPromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) const previousChild = this.activeRuntimeTextSearches.get(searchKey) @@ -64,8 +62,8 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC let processErrorObserved = false let unavailableExitObserved = false let child: ChildProcessHandle | null = null - const transformAbsPath = wslInfo - ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) + const transformAbsPath = wslDistroForOutput + ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -80,8 +78,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC resolvePromise(result) } const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) + const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) let killTimeout: ReturnType | null = null const cleanupListeners = (): void => { @@ -115,38 +112,82 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC } } - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: authorizedRootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) + // A synchronous spawn failure has no child to clean up. + let nextChild: ReturnType + try { + nextChild = spawnBundledRipgrep(rgArgs, { + cwd: authorizedRootPath, + wslDistro: localGitOptions.wslDistro, + wslDistroForOutput, + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (error) { + void classifySynchronousRipgrepSpawnFailure(error, authorizedRootPath).then( + rejectPromise, + rejectPromise + ) + return + } child = nextChild this.activeRuntimeTextSearches.set(searchKey, nextChild) - nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout?.setEncoding('utf-8') const onStdoutData = (chunk: string): void => { lines.push(chunk, processLine) } const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } - const onError = (): void => { + const onError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true + // Why: fd/process pressure is not a broken install; say so instead of blaming the bundled binary. + if (isTransientRipgrepSpawnError(error)) { + finish(Promise.reject(new Error(`rg could not start (${error.code}); try again`))) + return + } if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() + // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the + // binary for it tells the user to reinstall Orca over a workspace that simply moved. + // Why detach close first: a failed spawn emits error THEN close(code < 0), and close + // settles synchronously, so this probe would otherwise race it on a sub-millisecond + // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + child.off('close', onClose) + // Why catch: a failed probe must not strand the search; fall back to the prior verdict. + void isRipgrepSpawnCwdUsable(authorizedRootPath) + .catch(() => true) + .then((usable) => { + // Why re-check: finish() drops its argument once settled, so a rejected promise + // built after the close handler already won would go unhandled. + if (resolved) { + return + } + finish( + Promise.reject( + usable + ? bundledRipgrepUnavailableError() + : ripgrepMissingCwdError(authorizedRootPath) + ) + ) + }) return } resolveOnce() } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise + // read an unreachable workspace as a broken install and tell the user to reinstall Orca. + if (isRipgrepMissingCwdExit(code)) { + finish(Promise.reject(ripgrepMissingCwdError(authorizedRootPath))) + return + } if ( child && isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) + classifyNativeLauncherExit: true }) ) { unavailableExitObserved = true - resolveWithoutRipgrep() + rejectUnavailable() return } const tail = lines.finish() @@ -156,8 +197,8 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC resolveOnce() } - nextChild.stdout!.on('data', onStdoutData) - nextChild.stderr!.on('data', onStderrData) + nextChild.stdout?.on('data', onStdoutData) + nextChild.stderr?.on('data', onStderrData) nextChild.once('error', onError) nextChild.once('close', onClose) diff --git a/src/main/runtime/runtime-search-line-fragments.test.ts b/src/main/runtime/runtime-search-line-fragments.test.ts index 701abd1327e0..67308d8667f0 100644 --- a/src/main/runtime/runtime-search-line-fragments.test.ts +++ b/src/main/runtime/runtime-search-line-fragments.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' -import { - checkRgAvailableMock, - resolveAuthorizedPathMock, - wslAwareSpawnMock -} from './orca-runtime-files-mock-registry' +import { resolveAuthorizedPathMock, wslAwareSpawnMock } from './orca-runtime-files-mock-registry' import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle @@ -24,18 +20,10 @@ vi.mock('../ipc/filesystem-auth', async () => vi.mock('../git/runner', async () => (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() ) -vi.mock( - '../ipc/rg-availability', - async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock -) vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../ipc/filesystem-search-git', - async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock -) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock @@ -74,7 +62,6 @@ describe('RuntimeFileCommands', () => { }) const child = createRuntimeSearchChild() resolveAuthorizedPathMock.mockResolvedValue('/repo') - checkRgAvailableMock.mockResolvedValue(true) wslAwareSpawnMock.mockReturnValue(child) const resultPromise = commands.searchRuntimeFiles('id:wt-1', { query: 'needle', diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 6cab2dd00347..1d9f87b009d0 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -23,6 +23,7 @@ import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transf import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { finalizeInstall } from './ssh-relay-versioned-install' import type { SshConnection } from './ssh-connection' const mockExec = vi.mocked(execCommand) @@ -150,6 +151,55 @@ describe('deployOrcad', () => { expect(vi.mocked(uploadRelayDirectory).mock.calls[0][2]).toContain(`orcad-${NEW_VERSION}`) }) + it.each(['linux-arm64', 'darwin-x64'] as const)( + 'marks the %s search binary executable before completing the install', + async (platform) => { + scriptHost({ activationRecord: '', readiness: {}, log: [] }) + await deployOrcad( + options({ + host: getRemoteHostPlatform(platform), + census: { liveSessions: 1, startedSinceActivation: 0 } + }) + ) + const chmod = mockExec.mock.calls.findIndex(([, command]) => + String(command).startsWith('chmod 755 ') + ) + expect(chmod).toBeGreaterThanOrEqual(0) + expect(mockExec.mock.calls[chmod]?.[1]).toContain(`/ripgrep/${platform}/rg'`) + expect(vi.mocked(uploadRelayDirectory).mock.invocationCallOrder[0]).toBeLessThan( + mockExec.mock.invocationCallOrder[chmod] + ) + expect(mockExec.mock.invocationCallOrder[chmod]).toBeLessThan( + vi.mocked(finalizeInstall).mock.invocationCallOrder[0] + ) + } + ) + + it('does not run chmod on a Windows remote', async () => { + scriptHost({ activationRecord: '', readiness: {}, log: [] }) + await deployOrcad( + options({ + host: getRemoteHostPlatform('win32-x64'), + remoteHome: 'C:/Users/u', + census: { liveSessions: 1, startedSinceActivation: 0 } + }) + ) + expect(mockExec.mock.calls.some(([, command]) => String(command).startsWith('chmod '))).toBe( + false + ) + }) + + it('leaves an upload incomplete when the remote cannot make search executable', async () => { + mockExec.mockImplementation(async (_conn, command) => { + if (String(command).startsWith('chmod 755 ')) { + throw new Error('chmod failed') + } + return '' + }) + await expect(deployOrcad(options())).rejects.toThrow('chmod failed') + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + it('activates a healthy candidate and records the outgoing version as the rollback target', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 878e327c4527..88a4d923c847 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -14,6 +14,7 @@ */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { acquireInstallLock } from './ssh-relay-install-lock' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' @@ -128,6 +129,12 @@ async function installOrcadBundle( await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { signal: options.signal }) + const { host } = options + if (host.os !== 'win32') { + // SFTP creates uploaded files with 0644 even when the source binary is executable. + const binaryPath = joinRemotePath(host, remoteDir, 'ripgrep', host.relayPlatform, 'rg') + await exec(options, `chmod 755 ${shellEscape(binaryPath)}`) + } await writeRelayFile( options.conn, options.host, diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index 7334ff9d9148..ce878765e8dc 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) diff --git a/src/main/ssh/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts index 5a80df8ff4f6..a12b2e8342f6 100644 --- a/src/main/ssh/remote-install-model.test.ts +++ b/src/main/ssh/remote-install-model.test.ts @@ -1,4 +1,11 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' +import { ORCAD_RIPGREP_ARTIFACTS } from '../../shared/orcad-artifacts' +import { probeRemoteInstallCompleteCommand } from './ssh-remote-commands' +import { getRemoteHostPlatform } from './ssh-remote-platform' import { inventoryRemoteInstallDirs, @@ -20,6 +27,34 @@ describe('remote install namespace', () => { expect(remoteInstallDirName(ORCAD_INSTALL_MODEL, '0.1.0+aa')).toBe('orcad-0.1.0+aa') }) + it('requires every shipped search binary in a completed standalone runtime install', () => { + const required = ORCAD_INSTALL_MODEL.requiredArtifacts(false) + expect(required).toEqual(expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS])) + expect(ORCAD_INSTALL_MODEL.requiredArtifacts(true)).toEqual(required) + }) + + it.skipIf(process.platform === 'win32')('rejects an install missing its search binary', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-remote-probe-')) + try { + const required = [...ORCAD_INSTALL_MODEL.requiredArtifacts(false), '.install-complete'] + for (const filename of required) { + const path = join(dir, filename) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '') + } + const command = probeRemoteInstallCompleteCommand( + getRemoteHostPlatform('linux-x64'), + dir, + required + ) + expect(execFileSync('sh', ['-c', command], { encoding: 'utf8' }).trim()).toBe('OK') + rmSync(join(dir, ORCAD_RIPGREP_ARTIFACTS[0])) + expect(execFileSync('sh', ['-c', command], { encoding: 'utf8' }).trim()).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('keeps the relay listing pattern byte-identical to the one it shipped with', () => { // The literal that was hardcoded in `listRelayBaseDirsCommand` before it was // parameterized. A drift here changes what an existing host's GC can see. diff --git a/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts b/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts new file mode 100644 index 000000000000..b94d2102865d --- /dev/null +++ b/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts @@ -0,0 +1,197 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { appendFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { SshConnection } from './ssh-connection' +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { bundledRipgrepContentKey } from '../ripgrep/bundled-ripgrep-path' +import type { SshTarget } from '../../shared/ssh-types' + +// Why opt-in: needs Docker plus an sshd+node+git image WITHOUT rg (ORCA_REVIEW_SSH_NORG_IMAGE). +const RUN = process.env.ORCA_REVIEW_SSH_BUNDLED_RG === '1' +const REMOTE_REPO = '/tmp/orca-bundled-rg-repo' +const TARGET = 'ios/Notion Web Clipper/AppDelegate.swift' +const REPORT = process.env.ORCA_REVIEW_REPORT_FILE + +type Fixture = { containerName: string; identityFile: string; port: number; tempDir: string } + +function run(command: string, args: string[], timeout = 60_000): string { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout + }) + .toString() + .trim() +} + +function dockerExec(fixture: Fixture, command: string): string { + return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 120_000) +} + +function report(line: string): void { + if (REPORT) { + appendFileSync(REPORT, `${line}\n`) + } +} + +function startTarget(): Fixture { + const image = process.env.ORCA_REVIEW_SSH_NORG_IMAGE ?? 'orca-ssh-norg:latest' + const tempDir = mkdtempSync(join(tmpdir(), 'orca-bundled-rg-ssh-')) + const identityFile = join(tempDir, 'id_ed25519') + run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) + const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() + const containerName = `orca-bundled-rg-${randomUUID().slice(0, 12)}` + run('docker', [ + 'run', + '-d', + '--name', + containerName, + '-p', + '127.0.0.1::22', + '-e', + `AUTHORIZED_KEY=${publicKey}`, + image, + 'bash', + '-lc', + 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' + ]) + const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) + const fixture = { containerName, identityFile, port, tempDir } + // Why 25k files: past the 20,001-path listing cap, so only a real rg search finds the target. + dockerExec( + fixture, + [ + `mkdir -p '${REMOTE_REPO}' && cd '${REMOTE_REPO}' && git init -q`, + `for d in $(seq 0 249); do mkdir -p a$d && (cd a$d && for i in $(seq 0 99); do : > f$i.txt; done); done`, + `mkdir -p 'ios/Notion Web Clipper' && echo 'class AppDelegate {}' > '${TARGET}'`, + 'git add -A >/dev/null' + ].join(' && ') + ) + return fixture +} + +function stopTarget(fixture: Fixture | null): void { + if (!fixture) { + return + } + spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) + rmSync(fixture.tempDir, { recursive: true, force: true }) +} + +function createConnection(fixture: Fixture): SshConnection { + const target: SshTarget = { + id: `bundled-rg-${randomUUID()}`, + label: 'Bundled ripgrep Docker SSH target', + source: 'manual', + host: '127.0.0.1', + port: fixture.port, + username: 'root', + identityFile: fixture.identityFile, + identitiesOnly: true + } + return new SshConnection(target, { onStateChange: vi.fn() }) +} + +async function waitFor(check: () => boolean, timeoutMs: number): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (check()) { + return Date.now() - startedAt + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error(`condition not met within ${timeoutMs}ms`) +} + +describe.skipIf(!RUN)('SSH relay bundled ripgrep', () => { + let fixture: Fixture | null = null + + beforeAll(() => { + fixture = startTarget() + }, 300_000) + + afterAll(() => { + stopTarget(fixture) + }) + + it('installs Orca ripgrep on a host without rg and serves Quick Open search with it', async () => { + const active = fixture! + expect(dockerExec(active, 'command -v rg || echo NO_RG')).toBe('NO_RG') + const platform = /^(aarch64|arm64)$/.test(dockerExec(active, 'uname -m')) + ? 'linux-arm64' + : 'linux-x64' + const entry = `${bundledRipgrepContentKey(platform)}-${platform}` + const remoteBinary = `/root/.orca-remote/ripgrep/${entry}/rg` + const connection = createConnection(active) + await connection.connect() + try { + const deployStartedAt = Date.now() + const deployed = await deployAndLaunchRelay(connection, undefined, 60) + const deployMs = Date.now() - deployStartedAt + const installMs = await waitFor( + () => dockerExec(active, `test -x '${remoteBinary}' && echo yes || echo no`) === 'yes', + 60_000 + ) + report( + `SSH deploy_ms=${deployMs} rg_installed_after_launch_ms=${installMs} platform=${platform}` + ) + expect(dockerExec(active, `'${remoteBinary}' --version | head -1`)).toContain('ripgrep') + expect(dockerExec(active, "ps -eo args | grep '[r]elay.js --detached'")).toContain( + `--ripgrep-path ${remoteBinary}` + ) + expect(dockerExec(active, `ls /root/.orca-remote/ripgrep/${entry}`)).toBe('rg') + + const mux = new SshChannelMultiplexer(deployed.transport) + try { + await mux.request('session.registerRoot', { rootPath: REMOTE_REPO }) + const searchStartedAt = Date.now() + const files = await mux.request('fs.listFiles', { + rootPath: REMOTE_REPO, + searchQuery: 'AppDelegate', + maxResults: 33 + }) + report( + `SSH quick_open_search_ms=${Date.now() - searchStartedAt} result=${JSON.stringify(files)}` + ) + expect(files).toEqual([TARGET]) + + const textStartedAt = Date.now() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: relay fs.search resolves a SearchResult. + const text = (await mux.request('fs.search', { + query: 'class AppDelegate', + rootPath: REMOTE_REPO + })) as { files: { relativePath: string }[] } + report(`SSH text_search_ms=${Date.now() - textStartedAt} files=${text.files.length}`) + expect(text.files.map((file) => file.relativePath)).toEqual([TARGET]) + } finally { + mux.dispose() + } + } finally { + await connection.disconnect() + } + }, 300_000) + + it('reuses the installed binary on the next deploy without re-uploading', async () => { + const active = fixture! + const before = dockerExec(active, 'stat -c %Y /root/.orca-remote/ripgrep/*/rg') + const connection = createConnection(active) + await connection.connect() + try { + await deployAndLaunchRelay(connection, undefined, 60) + await new Promise((resolve) => setTimeout(resolve, 3_000)) + expect(dockerExec(active, 'stat -c %Y /root/.orca-remote/ripgrep/*/rg')).toBe(before) + expect(dockerExec(active, 'ls -A /root/.orca-remote/ripgrep | grep -c upload || true')).toBe( + '0' + ) + } finally { + await connection.disconnect() + } + }, 300_000) +}) diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts index aca050275b1a..ef90e829f7b7 100644 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts index 7fad908a59a9..467ddc5f7888 100644 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -48,6 +49,15 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +// Why: the post-launch ripgrep install is fire-and-forget and would drain the queued exec mocks. +// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. +vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ + ...(await importOriginal()), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) +})) + vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, @@ -79,6 +89,8 @@ import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' +import { ensureRemoteBundledRipgrep } from './ssh-relay-ripgrep-install' +import { RELAY_UPLOAD_STAGE_POOL_NAME } from './ssh-relay-upload-stage-contract' import { RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, RELAY_DEPLOY_TIMEOUT_MS @@ -563,4 +575,31 @@ describe('deployAndLaunchRelay staged uploads', () => { await deployAndLaunchRelay(conn) expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) }) + // Why: a cold host's rg upload is a multi-MB transfer. While the cleanup sweep was chained + // behind it, stale upload stages and superseded version dirs sat on the remote for that whole + // duration. A never-settling install stands in for that transfer. + it('sweeps stale upload stages without waiting for the ripgrep upload', async () => { + const conn = makeMockConnection() + vi.mocked(ensureRemoteBundledRipgrep).mockReturnValueOnce(new Promise(() => {})) + let socketProbe = 0 + vi.mocked(execCommand).mockImplementation((_conn, command) => { + if (command.includes('uname')) { + return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + } + if (command === 'echo $HOME') { + return Promise.resolve('/home/user') + } + if (command.includes('test -S')) { + return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') + } + return Promise.resolve('') + }) + + await deployAndLaunchRelay(conn) + + await vi.waitFor(() => { + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes(RELAY_UPLOAD_STAGE_POOL_NAME))).toBe(true) + }) + }) }) diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts new file mode 100644 index 000000000000..4c9132341175 --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -0,0 +1,336 @@ +// Windows named-pipe endpoint deploys, split out of ssh-relay-deploy.test.ts: that file sits at +// the max-lines cap, and these four share only the deploy harness with the rest of it. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront +// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report +// the local relay package as existing AND return a content-hashed version +// string so readLocalFullVersion succeeds. +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { + const normalizedOs = os.toLowerCase() + const normalizedArch = arch.toLowerCase() + const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' + if (normalizedOs === 'windows' || normalizedOs === 'win32') { + return `win32-${relayArch}` + } + if (normalizedOs === 'darwin') { + return `darwin-${relayArch}` + } + if (normalizedOs === 'linux') { + return `linux-${relayArch}` + } + return null + }), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +// Why: this file mocks fs, so the real content hash cannot read a binary. +vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ + resolveBundledRipgrepPath: () => null, + bundledRipgrepContentKey: () => 'c0ffee0123456789' +})) + +// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. +// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. +vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ + ...(await importOriginal()), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) +})) + +// Why: the versioned-install modules shell out for install state, locking, +// and GC. Stub them so deploy tests need no real SSH connection. +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' +import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import type { SshConnection } from './ssh-connection' + +function decodePowerShellCommand(command: string): string | null { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null +} + +const extractWindowsSockPath = (script: string): string => + /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? '' + +const extractWindowsMarkerPath = (script: string): string => + /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' + +function makeMockConnection(): SshConnection { + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }), + writeFile: vi.fn().mockResolvedValue(undefined), + sftp: vi.fn().mockResolvedValue({ + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn((_event: string, cb: () => void) => { + if (_event === 'close') { + setTimeout(cb, 0) + } + }), + end: vi.fn() + }), + end: vi.fn() + }) + } as unknown as SshConnection +} + +describe('deployAndLaunchRelay on Windows remotes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') + vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) + vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) + }) + + it('launches Windows remotes via a named pipe endpoint', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe + .mockResolvedValueOnce('C:\\Users\\me user') // remote home + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + .mockResolvedValueOnce('') // no persisted active pipe + .mockResolvedValueOnce('WAITING') // named pipe probe + .mockResolvedValueOnce('') // WMI relay launch + .mockResolvedValueOnce('READY') // named pipe poll + .mockResolvedValueOnce('') // persist active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + + expect(result.platform).toBe('win32-x64') + expect(result.remoteHome).toBe('C:/Users/me user') + expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + expect(execCommands).toHaveLength(1) + expect(execCommands[0]).toContain('powershell.exe') + const decodedScripts = mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .filter((script): script is string => script !== null) + const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' + expect(launchScript).toContain( + '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' + ) + expect(launchScript).toContain( + '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' + ) + expect(launchScript).toContain('--endpoint-dir') + expect(launchScript).toContain( + '--ripgrep-path "C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe"' + ) + expect(launchScript).not.toContain('--pty-source-credit-v1') + expect(launchScript).not.toContain('.pty-source-credit-policy') + expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') + const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' + expect(waitScript).toContain('setTimeout(attempt,intervalMs)') + const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { + const script = decodePowerShellCommand(command) + return ( + script?.includes('.windows-active-pipe') || + script?.includes('Invoke-CimMethod') || + script?.includes('deadline=Date.now()') + ) + }) + expect(windowsLaunchCalls.length).toBeGreaterThan(0) + expect( + windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) + ).toBe(true) + expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) + }) + + it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + vi.mocked(waitForSentinel) + .mockRejectedValueOnce(new Error('stale daemon handshake failed')) + .mockResolvedValueOnce({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe + .mockResolvedValueOnce('C:\\Users\\me user') // remote home + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + .mockResolvedValueOnce('') // no persisted active pipe yet + .mockResolvedValueOnce('READY') // existing named pipe probe + .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running + .mockResolvedValueOnce('') // WMI relay launch on fallback pipe + .mockResolvedValueOnce('READY') // fallback pipe poll + .mockResolvedValueOnce('') // persist fallback active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + + const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + expect(execCommands).toHaveLength(2) + const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? '' + const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? '' + const primaryPipe = extractWindowsSockPath(firstConnectScript) + const fallbackPipe = extractWindowsSockPath(secondConnectScript) + expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + expect(fallbackPipe).not.toBe(primaryPipe) + expect(result.sockPath).toBe(fallbackPipe) + + const launchScript = + mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .find((script) => script?.includes('Invoke-CimMethod')) ?? '' + expect(launchScript).toContain(fallbackPipe) + expect(launchScript).not.toContain(primaryPipe) + + const markerWriteScript = + mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .find( + (script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe') + ) ?? '' + expect(markerWriteScript).toContain(fallbackPipe) + expect(markerWriteScript).not.toContain(primaryPipe) + }) + + it('prefers a persisted Windows fallback pipe on later reconnects', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe + .mockResolvedValueOnce('C:\\Users\\me user') // remote home + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker + .mockResolvedValueOnce('READY') // persisted named pipe probe + .mockResolvedValueOnce('') // refresh active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + + const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + expect(execCommands).toHaveLength(1) + const connectScript = decodePowerShellCommand(execCommands[0]) ?? '' + expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe) + expect(result.sockPath).toBe(persistedPipe) + + const decodedExecScripts = mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .filter((script): script is string => script !== null) + expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) + }) + + it('scopes persisted Windows active pipe markers by relay target', async () => { + const connA = makeMockConnection() + const connB = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // no persisted active pipe A + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('READY') + .mockResolvedValueOnce('') // persist active pipe A + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // no persisted active pipe B + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('READY') + .mockResolvedValueOnce('') // persist active pipe B + + await deployAndLaunchRelay(connA, undefined, 300, 'target-a') + await deployAndLaunchRelay(connB, undefined, 300, 'target-b') + + const markerPaths = mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .filter((script): script is string => Boolean(script?.includes('Get-Content'))) + .map(extractWindowsMarkerPath) + + expect(markerPaths).toHaveLength(2) + expect(markerPaths[0]).toContain('.windows-active-pipe-relay-') + expect(markerPaths[1]).toContain('.windows-active-pipe-relay-') + expect(markerPaths[0]).not.toBe(markerPaths[1]) + }) +}) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 895f2d4b5794..f2fdaee4ed2b 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -52,6 +53,21 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +// Why: this file mocks fs, so the real content hash cannot read a binary. +vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ + resolveBundledRipgrepPath: () => null, + bundledRipgrepContentKey: () => 'c0ffee0123456789' +})) + +// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. +// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. +vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ + ...(await importOriginal()), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) +})) + // Why: the versioned-install modules shell out for install state, locking, // and GC. Stub them so deploy tests need no real SSH connection. vi.mock('./ssh-relay-versioned-install', () => ({ @@ -83,8 +99,12 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' +import { + ensureRemoteBundledRipgrep, + recordRemoteRipgrepReference +} from './ssh-relay-ripgrep-install' import * as DeployTiming from './ssh-relay-deploy-timing' import type { SshConnection } from './ssh-connection' import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' @@ -93,17 +113,6 @@ import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' -function decodePowerShellCommand(command: string): string | null { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null -} - -const extractWindowsSockPath = (script: string): string => - /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? '' - -const extractWindowsMarkerPath = (script: string): string => - /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' - function makeMockConnection(): SshConnection { return { canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), @@ -134,6 +143,22 @@ function queueLaunchNamespaceAndDeadSocketProbe(): void { vi.mocked(execCommand).mockResolvedValueOnce('').mockResolvedValueOnce('DEAD') } +function queueFreshLinuxDeploy(): void { + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + queueLaunchNamespaceAndDeadSocketProbe() + vi.mocked(execCommand).mockResolvedValueOnce('READY') +} + +function detachedLaunchCommand(conn: SshConnection): string | undefined { + return vi + .mocked(conn.exec) + .mock.calls.map(([cmd]) => cmd as string) + .find((cmd) => cmd.includes('--detached')) +} + describe('deployAndLaunchRelay', () => { beforeEach(() => { vi.clearAllMocks() @@ -207,12 +232,7 @@ describe('deployAndLaunchRelay', () => { it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') + queueFreshLinuxDeploy() await deployAndLaunchRelay(conn) @@ -483,40 +503,68 @@ describe('deployAndLaunchRelay', () => { it('defaults fresh relays to keep-alive-until-reset without rollout artifacts', async () => { const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') + queueFreshLinuxDeploy() await deployAndLaunchRelay(conn) - const launchCommand = vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => cmd as string) - .find((cmd) => cmd.includes('--detached')) + const launchCommand = detachedLaunchCommand(conn) expect(launchCommand).toContain(`--grace-time ${DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS}`) + expect(launchCommand).toContain( + "--ripgrep-path '/home/user/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'" + ) + await vi.waitFor(() => + expect(ensureRemoteBundledRipgrep).toHaveBeenCalledWith( + conn, + expect.anything(), + '/home/user', + expect.objectContaining({ signal: expect.anything() }) + ) + ) expect(launchCommand).not.toContain('--pty-source-credit-v1') expect(launchCommand).not.toContain('.pty-source-credit-policy') }) + it.each([false, true])( + 'waits for the ripgrep upload before cleanup (upload failure: %s)', + async (fails) => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) + queueFreshLinuxDeploy() + let finishUpload = (): void => {} + vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finishUpload = () => (fails ? reject(new Error('upload failed')) : resolve('present')) + }) + ) + await deployAndLaunchRelay(conn) + expect(ensureRemoteBundledRipgrep).toHaveBeenCalledOnce() + const execCount = vi.mocked(execCommand).mock.calls.length + await new Promise((resolve) => setImmediate(resolve)) + expect(execCommand).toHaveBeenCalledTimes(execCount) + expect(gcOldRelayVersions).not.toHaveBeenCalled() + finishUpload() + await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) + } + ) + + it('does not launch or upload an unprotected binary when recording its reference fails', async () => { + const conn = makeMockConnection() + queueFreshLinuxDeploy() + vi.mocked(recordRemoteRipgrepReference).mockResolvedValueOnce(false) + await deployAndLaunchRelay(conn) + expect(detachedLaunchCommand(conn)).not.toContain('--ripgrep-path') + expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() + }) + it('allows an unlimited SSH disconnect grace window', async () => { const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') + queueFreshLinuxDeploy() await deployAndLaunchRelay(conn, undefined, 0, 'target-a') - const launchCommand = vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => cmd as string) - .find((cmd) => cmd.includes('--detached')) + const launchCommand = detachedLaunchCommand(conn) expect(launchCommand).toContain('--grace-time 0') expect(launchCommand).not.toContain('--pty-source-credit-v1') @@ -525,19 +573,11 @@ describe('deployAndLaunchRelay', () => { it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') + queueFreshLinuxDeploy() await deployAndLaunchRelay(conn, undefined, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + 1, 'target-a') - const launchCommand = vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => cmd as string) - .find((cmd) => cmd.includes('--detached')) + const launchCommand = detachedLaunchCommand(conn) expect(launchCommand).toContain(`--grace-time ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}`) }) @@ -545,11 +585,7 @@ describe('deployAndLaunchRelay', () => { it('uses a content-hashed versioned remote install directory', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') + queueFreshLinuxDeploy() await deployAndLaunchRelay(conn) @@ -742,179 +778,4 @@ describe('deployAndLaunchRelay', () => { expect(launchB).toContain('--sock-path') expect(launchA).not.toEqual(launchB) }) - - it('launches Windows remotes via a named pipe endpoint', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - expect(result.platform).toBe('win32-x64') - expect(result.remoteHome).toBe('C:/Users/me user') - expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - expect(execCommands[0]).toContain('powershell.exe') - const decodedScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' - ) - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' - ) - expect(launchScript).toContain('--endpoint-dir') - expect(launchScript).not.toContain('--pty-source-credit-v1') - expect(launchScript).not.toContain('.pty-source-credit-policy') - expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') - const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' - expect(waitScript).toContain('setTimeout(attempt,intervalMs)') - const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { - const script = decodePowerShellCommand(command) - return ( - script?.includes('.windows-active-pipe') || - script?.includes('Invoke-CimMethod') || - script?.includes('deadline=Date.now()') - ) - }) - expect(windowsLaunchCalls.length).toBeGreaterThan(0) - expect( - windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) - ).toBe(true) - expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) - expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) - }) - - it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - vi.mocked(waitForSentinel) - .mockRejectedValueOnce(new Error('stale daemon handshake failed')) - .mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe yet - .mockResolvedValueOnce('READY') // existing named pipe probe - .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running - .mockResolvedValueOnce('') // WMI relay launch on fallback pipe - .mockResolvedValueOnce('READY') // fallback pipe poll - .mockResolvedValueOnce('') // persist fallback active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(2) - const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? '' - const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? '' - const primaryPipe = extractWindowsSockPath(firstConnectScript) - const fallbackPipe = extractWindowsSockPath(secondConnectScript) - expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).not.toBe(primaryPipe) - expect(result.sockPath).toBe(fallbackPipe) - - const launchScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find((script) => script?.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain(fallbackPipe) - expect(launchScript).not.toContain(primaryPipe) - - const markerWriteScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find( - (script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe') - ) ?? '' - expect(markerWriteScript).toContain(fallbackPipe) - expect(markerWriteScript).not.toContain(primaryPipe) - }) - - it('prefers a persisted Windows fallback pipe on later reconnects', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker - .mockResolvedValueOnce('READY') // persisted named pipe probe - .mockResolvedValueOnce('') // refresh active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - const connectScript = decodePowerShellCommand(execCommands[0]) ?? '' - expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe) - expect(result.sockPath).toBe(persistedPipe) - - const decodedExecScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) - }) - - it('scopes persisted Windows active pipe markers by relay target', async () => { - const connA = makeMockConnection() - const connB = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe A - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe A - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe B - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe B - - await deployAndLaunchRelay(connA, undefined, 300, 'target-a') - await deployAndLaunchRelay(connB, undefined, 300, 'target-b') - - const markerPaths = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => Boolean(script?.includes('Get-Content'))) - .map(extractWindowsMarkerPath) - - expect(markerPaths).toHaveLength(2) - expect(markerPaths[0]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[1]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[0]).not.toBe(markerPaths[1]) - }) }) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 903d318e8723..aab5e6c39d30 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -24,6 +24,12 @@ import { } from './ssh-relay-install-namespace' import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { + ensureRemoteBundledRipgrep, + remoteRipgrepLayout, + recordRemoteRipgrepReference +} from './ssh-relay-ripgrep-install' +import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' import { readLocalFullVersion, computeRemoteRelayDir, @@ -573,6 +579,15 @@ async function deployAndLaunchRelayAttempt( } } + const ripgrepLayout = remoteRipgrepLayout(hostPlatform, remoteHome) + const ripgrepReferenced = + ripgrepLayout && + (await recordRemoteRipgrepReference( + conn, + hostPlatform, + remoteRelayDir, + ripgrepLayout.entryName + )) let launched: Awaited> let launchLivenessObserved = false try { @@ -586,7 +601,8 @@ async function deployAndLaunchRelayAttempt( nodePath, graceTimeSeconds, relayInstanceId, - deploySignal + deploySignal, + ripgrepReferenced ? ripgrepLayout.binaryPath : undefined ) launchLivenessObserved = true } finally { @@ -601,11 +617,23 @@ async function deployAndLaunchRelayAttempt( } console.log('[ssh-relay] Relay started successfully') - void execHostCommand( - conn, - hostPlatform, - recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) - ) + // Keep background commands serial for SSH transports that allow only one exec at a time. + const ripgrepEntry = ripgrepLayout?.entryName + const ripgrepInstall = ( + ripgrepReferenced + ? ensureRemoteBundledRipgrep(conn, hostPlatform, remoteHome, { signal: deploySignal }) + : Promise.resolve() + ).catch(() => {}) + const cleanupReady = conn.canRunConcurrentExecCommands() ? Promise.resolve() : ripgrepInstall + + void cleanupReady + .then(() => + execHostCommand( + conn, + hostPlatform, + recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) + ) + ) .catch(() => {}) // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the // sweep has to settle first or GC keeps every orphan's tree forever. @@ -644,6 +672,10 @@ async function deployAndLaunchRelayAttempt( ].filter((key): key is string => key !== null) }) ) + // Why after the version GC and not beside it: that pass is what removes the relay directories + // holding the references, so running second is what lets a superseded build become collectable + // in the same connect rather than the next one. + .then(() => gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry })) .catch(() => {}) return { @@ -1680,7 +1712,8 @@ async function launchRelay( nodePath: string, graceTimeSeconds?: number, relayInstanceId?: string, - signal?: AbortSignal + signal?: AbortSignal, + ripgrepPath?: string ): Promise<{ transport: MultiplexerTransport nodePath: string @@ -1733,7 +1766,8 @@ async function launchRelay( graceTime, activePipeMarkerPath, reconnectFallback: fallbackEndpoint, - credentialFile + credentialFile, + ripgrepPath }, signal ) @@ -1799,7 +1833,8 @@ async function launchRelay( // Why: the relay derives its hook endpoint dir from the socket path; pin it back under the relay dir when the socket moved to /tmp. const endpointDirArg = sockFile === defaultSockFile ? '' : ` --endpoint-dir ${shellEscape(endpointDir)}` - const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)}${endpointDirArg} --credential-file ${shellEscape(credentialFile)} --log-file ${shellEscape(logFile)} > ${shellEscape(logFile)} 2>&1 ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) @@ -1991,6 +2026,7 @@ type WindowsRelayLaunchOptions = { graceTime: number activePipeMarkerPath: string credentialFile: string + ripgrepPath?: string } & WindowsRelayEndpoint & { reconnectFallback?: WindowsRelayEndpoint } @@ -2073,7 +2109,8 @@ async function launchWindowsRelay( launchOpts.graceTime, logFile, errFile, - launchOpts.credentialFile + launchOpts.credentialFile, + launchOpts.ripgrepPath ), { signal } ) @@ -2165,7 +2202,8 @@ function windowsRelayLaunchCommand( graceTime: number, logFile: string, errFile: string, - credentialFile: string + credentialFile: string, + ripgrepPath?: string ): string { const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') // Why: Windows sshd kills the exec channel's process tree on close; WMI re-parents the detached relay to survive. @@ -2185,6 +2223,7 @@ function windowsRelayLaunchCommand( // Why: --log-file owns rotation; shell redirects still capture pre-JS boot/crash output. '--log-file', quoted(logFile), + ...(ripgrepPath ? ['--ripgrep-path', quoted(ripgrepPath)] : []), `1>${quoted(logFile)}`, `2>${quoted(errFile)}` ].join(' ') diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts index 90f81fcbf06a..ffc859df2232 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -35,6 +35,12 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index ec6d404cadaa..df15135963a2 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,5 +1,6 @@ // Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. +import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' @@ -42,6 +43,15 @@ vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' })) +// Why: the post-launch ripgrep install would consume this file's queued exec mocks. +// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. +vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ + ...(await importOriginal()), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) +})) + vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, @@ -78,6 +88,7 @@ import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { abandonInstall, finalizeInstall, + gcOldRelayVersions, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' @@ -619,6 +630,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const conn1 = makeMockConnection(sftpCapture) feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) await deployAndLaunchRelay(conn1) + await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalled()) const firstPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string const first = sftpCapture.contents[firstPath] diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index b4b3a22d8174..395aed06f951 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -35,6 +35,12 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts index 5b045d69227f..1f8fd69b8dd8 100644 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -6,6 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index 1fa97df78127..5b2a42d87c7b 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts new file mode 100644 index 000000000000..509ba9a0ebd9 --- /dev/null +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts @@ -0,0 +1,113 @@ +import { shellEscape } from './ssh-connection-utils' +import { + REMOTE_RIPGREP_CACHE_DIR_NAME, + REMOTE_RIPGREP_REF_PREFIX +} from './ssh-relay-ripgrep-install' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export const LIST_OK = '__ORCA_RG_CACHE__LIST_OK' +export const REFS_OK = '__ORCA_RG_CACHE__REFS_OK' +export const REFS_ERR = '__ORCA_RG_CACHE__REFS_ERR' +export const TOMBSTONE_PREFIX = '.rg-gc-' +export const MAX_LISTING_ENTRIES = 64 + +export function cacheDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, REMOTE_RIPGREP_CACHE_DIR_NAME) +} + +export function listEntriesCommand(host: RemoteHostPlatform, remoteHome: string): string { + if (isWindowsRemoteHost(host)) { + const dir = powerShellLiteral(cacheDir(host, remoteHome)) + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + `if (-not (Test-Path -LiteralPath ${dir})) { '${LIST_OK}'; exit 0 }`, + + // Why the ENTRY prefix and not bare names: PowerShell writes every uncaptured value to + // stdout, so the token is what separates this listing from anything else a cmdlet emits. + `Get-ChildItem -LiteralPath ${dir} -Directory -Force -ErrorAction Stop | ForEach-Object { 'ENTRY ' + $_.Name }`, + `'${LIST_OK}'` + ].join('\n') + ) + } + const dir = shellEscape(cacheDir(host, remoteHome)) + return [ + `d=${dir}`, + `[ -d "$d" ] || { printf '%s\\n' ${LIST_OK}; exit 0; }`, + `for e in "$d"/* "$d"/${TOMBSTONE_PREFIX}*; do`, + ' [ -d "$e" ] || continue', + ` printf 'ENTRY %s\\n' "$(basename "$e")"`, + 'done', + `printf '%s\\n' ${LIST_OK}` + ].join('\n') +} + +/** + * Every relay directory's recorded ripgrep entry. + * + * A relay directory with no readable marker answers `REFS_ERR`: it may be an older Orca's relay, + * running right now against a binary it never recorded. + */ +export function listReferencesCommand(host: RemoteHostPlatform, remoteHome: string): string { + if (isWindowsRemoteHost(host)) { + const root = powerShellLiteral(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + `if (-not (Test-Path -LiteralPath ${root})) { '${REFS_OK}'; exit 0 }`, + `$dirs = @(Get-ChildItem -LiteralPath ${root} -Directory -Force -Filter 'relay-*' -ErrorAction Stop)`, + `if ($dirs.Count -ge ${MAX_LISTING_ENTRIES}) { '${REFS_ERR}'; exit 0 }`, + 'foreach ($d in $dirs) {', + // Why PSIsContainer and not `-File`: the remote-payload invariant test greps for that switch, + // because `powershell.exe -File` is execution-policy gated on the host. This is + // Get-ChildItem's unrelated filter, but the guard is worth more than the shorter spelling. + ` $refs = @(Get-ChildItem -LiteralPath $d.FullName -Force -Filter '${REMOTE_RIPGREP_REF_PREFIX}*' -ErrorAction Stop | Where-Object { -not $_.PSIsContainer })`, + `$legacy = Join-Path $d.FullName '.ripgrep-ref'`, + `if (Test-Path -LiteralPath $legacy -PathType Leaf) { 'REF ' + (Get-Content -LiteralPath $legacy -Raw -ErrorAction Stop).Trim() } elseif ($refs.Count -eq 0) { '${REFS_ERR}'; exit 0 }`, + ` foreach ($r in $refs) { 'REF ' + $r.Name.Substring(${REMOTE_RIPGREP_REF_PREFIX.length}) }`, + '}', + `'${REFS_OK}'` + ].join('\n') + ) + } + const root = shellEscape(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) + return [ + `root=${root}`, + `[ -d "$root" ] || { printf '%s\\n' ${REFS_OK}; exit 0; }`, + `[ -r "$root" ] && [ -x "$root" ] && ls -A "$root" >/dev/null 2>&1 || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + 'n=0', + 'for d in "$root"/relay-*; do', + ' [ -d "$d" ] || continue', + ` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + ' found=0', + ` if [ -e "$d/.ripgrep-ref" ]; then t=$(cat "$d/.ripgrep-ref") || { printf '%s\\n' ${REFS_ERR}; exit 0; }; printf 'REF %s\\n' "$t"; found=1; fi`, + ` for f in "$d"/${REMOTE_RIPGREP_REF_PREFIX}*; do`, + ' [ -f "$f" ] || continue', + ' found=1', + // Why the filename and not the contents: the name is what this client minted, and reading it + // costs nothing extra. The entry is re-validated against ENTRY_NAME before any deletion. + ` printf 'REF %s\\n' "\${f##*/${REMOTE_RIPGREP_REF_PREFIX}}"`, + ' done', + ` [ "$found" = 1 ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + ' n=$((n+1))', + ` if [ "$n" -ge ${MAX_LISTING_ENTRIES} ]; then printf '%s\\n' ${REFS_ERR}; exit 0; fi`, + 'done', + `printf '%s\\n' ${REFS_OK}` + ].join('\n') +} + +// A concurrent installer may already have recreated the original directory. +export function restoreEntryCommand( + host: RemoteHostPlatform, + source: string, + destination: string +): string { + if (isWindowsRemoteHost(host)) { + return powerShellCommand( + `if (-not (Test-Path -LiteralPath ${powerShellLiteral(destination)})) { Move-Item -LiteralPath ${powerShellLiteral(source)} -Destination ${powerShellLiteral(destination)} -ErrorAction Stop; 'MOVED' } else { 'BUSY' }` + ) + } + return `if [ ! -e ${shellEscape(destination)} ]; then mv ${shellEscape(source)} ${shellEscape(destination)} && echo MOVED; else echo BUSY; fi` +} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts new file mode 100644 index 000000000000..8b3e657e523e --- /dev/null +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts @@ -0,0 +1,137 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) +import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' +import { recordRemoteRipgrepReference } from './ssh-relay-ripgrep-install' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +const platform = process.platform === 'win32' ? 'win32-x64' : 'linux-x64' +const host = getRemoteHostPlatform(platform) +const FIRST = `1111111111111111-${platform}` +const SECOND = `2222222222222222-${platform}` +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only execCommand touches the connection, and it is replaced with a local shell. +const conn = {} as SshConnection + +function runShell(command: string): string { + return process.platform === 'win32' + ? execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], + { encoding: 'utf8', timeout: 15_000 } + ) + : execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8', timeout: 15_000 }) +} + +describe('ripgrep cache shell transactions', () => { + let home: string + let root: string + let cache: string + let relay: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca rg gc ')) + root = join(home, '.orca-remote') + cache = join(root, 'ripgrep') + relay = join(root, 'relay-0.1.0+abc') + mkdirSync(relay, { recursive: true }) + for (const entry of [FIRST, SECOND]) { + mkdirSync(join(cache, entry), { recursive: true }) + writeFileSync(join(cache, entry, 'rg'), entry) + } + execMock + .mockReset() + .mockImplementation(async (_conn: unknown, command: string) => runShell(command)) + }) + + afterEach(() => { + chmodSync(root, 0o755) + rmSync(home, { recursive: true, force: true }) + }) + + it('keeps both binaries recorded in one relay directory and collects them after that directory goes', async () => { + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + await recordRemoteRipgrepReference(conn, host, relay, SECOND) + await gcRemoteRipgrepCache(conn, host, home) + expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) + rmSync(relay, { recursive: true }) + await gcRemoteRipgrepCache(conn, host, home) + expect(readdirSync(cache)).toEqual([]) + }) + + it('preserves a legacy reference beside newer per-entry markers', async () => { + writeFileSync(join(relay, '.ripgrep-ref'), FIRST) + await recordRemoteRipgrepReference(conn, host, relay, SECOND) + await gcRemoteRipgrepCache(conn, host, home) + expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) + }) + + it.each(['', ' \t\r\n'])( + 'blocks collection when a legacy marker is empty or whitespace: %j', + async (contents) => { + writeFileSync(join(relay, '.ripgrep-ref'), contents) + await recordRemoteRipgrepReference(conn, host, relay, SECOND) + await gcRemoteRipgrepCache(conn, host, home) + expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) + } + ) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'preserves binaries when the relay directory listing is unreadable', + async () => { + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + chmodSync(root, 0o111) + await gcRemoteRipgrepCache(conn, host, home) + expect(existsSync(join(cache, FIRST, 'rg'))).toBe(true) + expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) + } + ) + + it('keeps abandoned tombstones while their references are unknown, then collects unreferenced ones', async () => { + const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` + mkdirSync(join(cache, tombstone)) + writeFileSync(join(cache, tombstone, 'rg'), SECOND) + await gcRemoteRipgrepCache(conn, host, home) + expect(existsSync(join(cache, tombstone, 'rg'))).toBe(true) + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + await gcRemoteRipgrepCache(conn, host, home) + expect(readdirSync(cache)).toEqual([FIRST]) + }) + + it('does not nest a tombstone inside a directory recreated by a concurrent installer', async () => { + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + let raced = false + execMock.mockImplementation(async (_conn: unknown, command: string) => { + const output = runShell(command) + const script = process.platform === 'win32' ? decodeRemotePowerShellScript(command) : command + if ( + !raced && + (script.startsWith('mv ') || script.includes('Move-Item')) && + script.includes(SECOND) + ) { + raced = true + await recordRemoteRipgrepReference(conn, host, relay, SECOND) + mkdirSync(join(cache, SECOND)) + writeFileSync(join(cache, SECOND, 'rg'), SECOND) + } + return output + }) + await gcRemoteRipgrepCache(conn, host, home) + expect(raced).toBe(true) + expect(readdirSync(join(cache, SECOND))).toEqual(['rg']) + }) +}) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts new file mode 100644 index 000000000000..d69bdb497f1a --- /dev/null +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest' + +const { execCommandMock } = vi.hoisted(() => ({ execCommandMock: vi.fn() })) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) + +import type { SshConnection } from './ssh-connection' +import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' +import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' + +const LINUX = getRemoteHostPlatform('linux-x64') +const WINDOWS = getRemoteHostPlatform('win32-x64') +const CURRENT = 'c0ffee0123456789-linux-x64' +const SUPERSEDED = 'dead000000000000-linux-x64' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the GC only issues exec commands; nothing else on the connection is reached. +const conn = {} as unknown as SshConnection + +function scripts(): string[] { + return execCommandMock.mock.calls.map(([, command]) => String(command)) +} + +// Why exclude `find`: the entry listing carries its own `rm -rf` sweep for stale tombstones, and +// counting that as a collection would make every assertion here pass for the wrong reason. +function removedTrees(): string[] { + return scripts().filter( + (s) => s.includes('rm -rf') && s.includes('.rg-gc-') && !s.includes('find ') + ) +} + +function reply(entries: readonly string[], refs: readonly string[] | 'unreadable'): void { + execCommandMock.mockReset() + execCommandMock.mockImplementation((_conn: unknown, command: string) => { + const text = String(command) + if (text.includes('ENTRY %s')) { + return Promise.resolve( + `${entries.map((e) => `ENTRY ${e}`).join('\n')}\n__ORCA_RG_CACHE__LIST_OK` + ) + } + if (text.includes('REF %s')) { + return Promise.resolve( + refs === 'unreadable' + ? '__ORCA_RG_CACHE__REFS_ERR' + : `${refs.map((r) => `REF ${r}`).join('\n')}\n__ORCA_RG_CACHE__REFS_OK` + ) + } + if (text.includes('MOVED')) { + return Promise.resolve('MOVED') + } + return Promise.resolve('') + }) +} + +describe('remote ripgrep cache GC', () => { + it('collects a build no relay installation references', async () => { + reply([CURRENT, SUPERSEDED], [CURRENT]) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) + + expect(removedTrees()).toHaveLength(1) + expect(removedTrees()[0]).toContain(SUPERSEDED) + }) + + it.each([LINUX, WINDOWS])( + 'rejects an empty reference on %j before any mutation', + async (host) => { + execCommandMock.mockReset() + execCommandMock + .mockResolvedValueOnce(`ENTRY ${SUPERSEDED}\r\n__ORCA_RG_CACHE__LIST_OK`) + .mockResolvedValueOnce('REF \t\r\n__ORCA_RG_CACHE__REFS_OK') + await gcRemoteRipgrepCache(conn, host, '/home/me') + expect(execCommandMock).toHaveBeenCalledTimes(2) + } + ) + + // Why: the reference is the whole safety argument. A build a live relay was launched against + // must survive, however old its directory is. + it('keeps a referenced build', async () => { + reply([CURRENT, SUPERSEDED], [CURRENT, SUPERSEDED]) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) + + expect(removedTrees()).toEqual([]) + }) + + // Why this case exists: the relay directory is named from a hash of the relay bytes, and + // ripgrep is not among them, so a release that bumps only the ripgrep package -- the monthly + // Dependabot PR -- shares a relay directory with its predecessor while minting a new entry. + // With a single marker slot the second client overwrote the first's reference and this pass + // then collected the binary the first client's relay was still running against. + it('keeps both builds when two clients share one relay directory', async () => { + reply([CURRENT, SUPERSEDED], [CURRENT, SUPERSEDED]) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) + + expect(removedTrees()).toEqual([]) + }) + + // Why assert the shell and not just the parse: the marker is per entry, so a scan that read a + // single fixed filename would silently see only one of the two references above. + it('scans every marker in a relay directory, not one fixed name', async () => { + execCommandMock.mockReset() + execCommandMock.mockResolvedValue('__ORCA_RG_CACHE__LIST_OK') + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) + execCommandMock.mockReset() + execCommandMock.mockImplementation((_c: unknown, command: string) => { + const text = String(command) + if (text.includes('ENTRY %s')) { + return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) + } + return Promise.resolve('__ORCA_RG_CACHE__REFS_OK') + }) + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) + + const refScan = scripts().find((s) => s.includes('REF %s')) ?? '' + expect(refScan).toContain('.ripgrep-ref-*') + expect(refScan).toContain('${f##*/.ripgrep-ref-}') + }) + + // Why the whole pass and not just that directory: a relay deployed by an older Orca records no + // reference, so its binary cannot be identified -- and guessing is what breaks a live search. + it('collects nothing when any relay directory cannot be accounted for', async () => { + reply([CURRENT, SUPERSEDED], 'unreadable') + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) + + expect(removedTrees()).toEqual([]) + }) + + // Why a readable RECHECK behind an unreadable first scan: with both unreadable, the restore in + // removeUnreferencedEntry hides a missing up-front guard, and the test passes either way. This + // shape is the only one that fails if the pass stops treating an unaccountable scan as fatal. + it('does not even tombstone when the first reference scan is unreadable', async () => { + execCommandMock.mockReset() + let refCalls = 0 + execCommandMock.mockImplementation((_conn: unknown, command: string) => { + const text = String(command) + if (text.includes('ENTRY %s')) { + return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) + } + if (text.includes('REF %s')) { + refCalls += 1 + return Promise.resolve( + refCalls === 1 ? '__ORCA_RG_CACHE__REFS_ERR' : '__ORCA_RG_CACHE__REFS_OK' + ) + } + if (text.includes('MOVED')) { + return Promise.resolve('MOVED') + } + return Promise.resolve('') + }) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) + + expect(removedTrees()).toEqual([]) + expect(scripts().filter((s) => s.includes('MOVED'))).toEqual([]) + }) + + it('keeps the pinned build even when nothing references it yet', async () => { + reply([CURRENT], []) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) + + expect(removedTrees()).toEqual([]) + }) + + // Why restore rather than proceed: a deploy that read the entry as present can still be writing + // its marker, and the recheck is the only place that race becomes visible. + it('restores a tombstoned build when the recheck finds a new reference', async () => { + execCommandMock.mockReset() + let refCalls = 0 + execCommandMock.mockImplementation((_conn: unknown, command: string) => { + const text = String(command) + if (text.includes('ENTRY %s')) { + return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) + } + if (text.includes('REF %s')) { + refCalls += 1 + return Promise.resolve( + refCalls === 1 + ? '__ORCA_RG_CACHE__REFS_OK' + : `REF ${SUPERSEDED}\n__ORCA_RG_CACHE__REFS_OK` + ) + } + if (text.includes('MOVED')) { + return Promise.resolve('MOVED') + } + return Promise.resolve('') + }) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) + + expect(removedTrees()).toEqual([]) + // Moved out, then moved back. + expect(scripts().filter((s) => s.includes('MOVED'))).toHaveLength(2) + }) + + // Why tie this to the platform list: a platform the entry pattern did not know would make the + // reference scan read as unreadable, and the pass then collects nothing at all -- every + // superseded build on that host leaks, silently, with no other test failing. + it('accepts an entry for every bundled platform', async () => { + for (const platform of BUNDLED_RIPGREP_PLATFORMS) { + const entry = `c0ffee0123456789-${platform}` + reply([entry], [entry]) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) + + // Referenced, so not collected -- but it had to parse as an entry to be considered at all. + expect(removedTrees()).toEqual([]) + expect(scripts().some((s) => s.includes('REF %s'))).toBe(true) + } + }) + + it('only mints entry names it could have written', async () => { + reply([CURRENT, '../../etc', 'not-an-entry'], [CURRENT]) + + await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) + + expect(removedTrees()).toEqual([]) + }) + + // Why Windows gets the same coverage and not a gate: leaving one dialect uncollected means the + // leak simply moves to Windows remotes, where a `.exe` is the larger of the two builds. + it('collects an unreferenced build on Windows remotes too', async () => { + const WIN_CURRENT = 'c0ffee0123456789-win32-x64' + const WIN_OLD = 'dead000000000000-win32-x64' + execCommandMock.mockReset() + execCommandMock.mockImplementation((_conn: unknown, command: string) => { + const text = decodeRemotePowerShellScript(String(command)) ?? String(command) + if (text.includes("'ENTRY '")) { + return Promise.resolve(`ENTRY ${WIN_CURRENT}\nENTRY ${WIN_OLD}\n__ORCA_RG_CACHE__LIST_OK`) + } + if (text.includes("'REF '")) { + return Promise.resolve(`REF ${WIN_CURRENT}\n__ORCA_RG_CACHE__REFS_OK`) + } + if (text.includes('MOVED')) { + return Promise.resolve('MOVED') + } + return Promise.resolve('') + }) + + await gcRemoteRipgrepCache(conn, WINDOWS, 'C:/Users/me', { pinnedEntry: WIN_CURRENT }) + + const removals = scripts() + .map((s) => decodeRemotePowerShellScript(s) ?? s) + .filter( + (s) => s.includes('Remove-Item') && s.includes('.rg-gc-') && !s.includes('AddMinutes') + ) + expect(removals).toHaveLength(1) + expect(removals[0]).toContain(WIN_OLD) + }) + + // Why: PowerShell writes every uncaptured value to stdout, so a listing that forgot its token + // prefix would mix cmdlet output into the entry list and feed `Remove-Item` a foreign name. + it('prefixes every Windows listing line with its token', async () => { + execCommandMock.mockReset() + execCommandMock.mockResolvedValue('__ORCA_RG_CACHE__LIST_OK') + + await gcRemoteRipgrepCache(conn, WINDOWS, 'C:/Users/me', {}) + + const listing = decodeRemotePowerShellScript(scripts()[0]) ?? '' + expect(listing).toContain("'ENTRY ' + $_.Name") + expect(listing).toContain('__ORCA_RG_CACHE__LIST_OK') + }) +}) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts new file mode 100644 index 000000000000..18cca2c4f5f8 --- /dev/null +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts @@ -0,0 +1,183 @@ +import { + LIST_OK, + REFS_OK, + REFS_ERR, + TOMBSTONE_PREFIX, + MAX_LISTING_ENTRIES, + cacheDir, + listEntriesCommand, + listReferencesCommand, + restoreEntryCommand +} from './ssh-relay-ripgrep-cache-gc-commands' +// Relay installation references protect binaries until version GC removes their owners. +// Unknown references block deletion; tombstones are rechecked before removal. +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' +import { moveRemoteTreeCommand, removeRemoteTreeCommand } from './ssh-remote-commands' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +function entryNamePattern(): RegExp { + const platforms = BUNDLED_RIPGREP_PLATFORMS.map((platform) => + platform.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ).join('|') + return new RegExp(`^[0-9a-f]{16}-(?:${platforms})$`) +} + +const ENTRY_NAME = entryNamePattern() + +// Recover abandoned deletions only after the owning pass has had time to finish. +function staleTombstoneEntry(name: string): string | null { + if (!name.startsWith(TOMBSTONE_PREFIX)) { + return null + } + const match = /^(.*)\.(\d+)\.(\d+)$/.exec(name.slice(TOMBSTONE_PREFIX.length)) + if (!match || !ENTRY_NAME.test(match[1]) || Date.now() - Number(match[3]) < 30 * 60_000) { + return null + } + return match[1] +} + +function exec(conn: SshConnection, host: RemoteHostPlatform, command: string): Promise { + return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host) }) +} + +type ReferenceScan = { readable: true; referenced: Set } | { readable: false } + +function parseEntries(output: string): string[] { + const lines = output.split(/\r?\n/).map((line) => line.trim()) + if (!lines.includes(LIST_OK)) { + return [] + } + const entries: string[] = [] + for (const line of lines) { + if (!line.startsWith('ENTRY ')) { + continue + } + const name = line.slice('ENTRY '.length) + // Why re-validate a name the host produced: it is about to be interpolated into `mv` and + // `rm -rf`. Only names this client could itself have minted are eligible. + if ( + (ENTRY_NAME.test(name) || staleTombstoneEntry(name)) && + entries.length < MAX_LISTING_ENTRIES + ) { + entries.push(name) + } + } + return entries +} + +async function scanReferences( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string +): Promise { + let output: string + try { + output = await exec(conn, host, listReferencesCommand(host, remoteHome)) + } catch { + return { readable: false } + } + const lines = output.split(/\r?\n/).map((line) => line.trim()) + // An empty legacy marker is unknown ownership, not an empty reference set. + if (lines.includes(REFS_ERR) || lines.includes('REF') || !lines.includes(REFS_OK)) { + return { readable: false } + } + const referenced = new Set() + for (const line of lines) { + if (!line.startsWith('REF ')) { + continue + } + const name = line.slice('REF '.length).trim() + // An unrecognised marker is a reference this client cannot attribute, so it blocks the pass + // rather than being ignored. + if (!ENTRY_NAME.test(name)) { + return { readable: false } + } + referenced.add(name) + } + return { readable: true, referenced } +} + +/** Collect ripgrep builds that no relay installation references. Never throws. */ +export async function gcRemoteRipgrepCache( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options: { pinnedEntry?: string | undefined } = {} +): Promise { + try { + const entries = parseEntries(await exec(conn, host, listEntriesCommand(host, remoteHome))) + if (entries.length === 0) { + return + } + const scan = await scanReferences(conn, host, remoteHome) + if (!scan.readable) { + return + } + const removed: string[] = [] + for (const name of entries) { + const entry = staleTombstoneEntry(name) ?? name + if (scan.referenced.has(entry) || entry === options.pinnedEntry) { + continue + } + if ( + await removeUnreferencedEntry( + conn, + host, + remoteHome, + entry, + name === entry ? undefined : name + ) + ) { + removed.push(entry) + } + } + if (removed.length > 0) { + console.log(`[ssh-relay] ripgrep cache GC: removed ${removed.length}: ${removed.join(', ')}`) + } + } catch { + /* Never fails a deploy; the next connect tries again. */ + } +} + +async function removeUnreferencedEntry( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + entry: string, + abandonedTombstone?: string +): Promise { + const base = cacheDir(host, remoteHome) + const entryDir = joinRemotePath(host, base, entry) + const tombstone = joinRemotePath( + host, + base, + abandonedTombstone ?? `${TOMBSTONE_PREFIX}${entry}.${process.pid}.${Date.now()}` + ) + try { + if ( + !abandonedTombstone && + (await exec(conn, host, moveRemoteTreeCommand(host, entryDir, tombstone))).trim() !== 'MOVED' + ) { + return false + } + } catch { + return false + } + // Why recheck under the rename: a deploy that read this entry as present can still be writing + // its marker. Its reference now names a path that no longer exists, so restoring the tree is + // the only outcome that leaves that relay with a working ripgrep. + const recheck = await scanReferences(conn, host, remoteHome) + if (!recheck.readable || recheck.referenced.has(entry)) { + await exec(conn, host, restoreEntryCommand(host, tombstone, entryDir)).catch(() => {}) + return false + } + try { + await exec(conn, host, removeRemoteTreeCommand(host, tombstone)) + return true + } catch { + // A later pass retries after verifying references again. + return false + } +} diff --git a/src/main/ssh/ssh-relay-ripgrep-install.test.ts b/src/main/ssh/ssh-relay-ripgrep-install.test.ts new file mode 100644 index 000000000000..dc41c281db2c --- /dev/null +++ b/src/main/ssh/ssh-relay-ripgrep-install.test.ts @@ -0,0 +1,325 @@ +import { execFileSync } from 'node:child_process' +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + truncateSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execCommandMock, uploadRelayDirectoryMock, resolveBundledRipgrepPathMock } = vi.hoisted( + () => ({ + execCommandMock: vi.fn(), + uploadRelayDirectoryMock: vi.fn(), + resolveBundledRipgrepPathMock: vi.fn() + }) +) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) +vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: uploadRelayDirectoryMock })) +vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ + resolveBundledRipgrepPath: resolveBundledRipgrepPathMock, + bundledRipgrepContentKey: () => 'c0ffee0123456789' +})) + +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' +import { ensureRemoteBundledRipgrep, remoteRipgrepLayout } from './ssh-relay-ripgrep-install' + +const LINUX = getRemoteHostPlatform('linux-x64') +const WINDOWS = getRemoteHostPlatform('win32-x64') + +function connection(usesSystemSsh = false): SshConnection { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the install only asks the connection which transport it uses; exec/upload are mocked. + return { usesSystemSshTransport: () => usesSystemSsh } as unknown as SshConnection +} + +function execScripts(): string[] { + return execCommandMock.mock.calls.map(([, command]) => { + const text = String(command) + return text.includes('-EncodedCommand') ? decodeRemotePowerShellScript(text) : text + }) +} + +describe('ensureRemoteBundledRipgrep', () => { + let localDir: string + let localBinary: string + + beforeEach(() => { + execCommandMock.mockReset() + uploadRelayDirectoryMock.mockReset().mockResolvedValue(undefined) + localDir = mkdtempSync(join(tmpdir(), 'orca-local-rg-')) + localBinary = join(localDir, 'linux-x64', 'rg') + mkdirSync(dirname(localBinary)) + writeFileSync(localBinary, 'x'.repeat(1234)) + resolveBundledRipgrepPathMock.mockReset().mockReturnValue(localBinary) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(localDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('keys the cache on the binary content hash and platform, not the relay version', () => { + expect(remoteRipgrepLayout(LINUX, '/home/me')?.binaryPath).toBe( + '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg' + ) + expect(remoteRipgrepLayout(WINDOWS, 'C:/Users/me user')?.binaryPath).toBe( + 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe' + ) + }) + + it('limits Windows cache cleanup to abandoned upload stages', async () => { + execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') + + await ensureRemoteBundledRipgrep(connection(), WINDOWS, 'C:/Users/me user') + + const script = execScripts()[0] + expect(script.match(/Get-ChildItem/g)).toHaveLength(1) + expect(script).toContain("-Filter '.upload-*'") + }) + + it('skips the upload in one round trip when the binary is already installed', async () => { + execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( + 'present' + ) + + expect(execCommandMock).toHaveBeenCalledTimes(1) + expect(execScripts()[0]).toContain( + "-x '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'" + ) + expect(uploadRelayDirectoryMock).not.toHaveBeenCalled() + }) + + // Why the marker matters: it is the only thing that tells the cache GC this build is in use. + // Without it the GC cannot distinguish "nobody uses this" from "nobody recorded it", and the + // safe answer to the second is to collect nothing at all. + it('records the reference before checking or installing its binary', async () => { + execCommandMock.mockResolvedValueOnce('').mockResolvedValueOnce('ORCA-RG-PRESENT\n') + + await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { + relayDir: '/home/me/.orca-remote/relay-1.2.3' + }) + + const ref = execScripts()[0] + expect(ref).toContain('c0ffee0123456789-linux-x64') + expect(ref).toContain('/home/me/.orca-remote/relay-1.2.3/.ripgrep-ref') + }) + + it('does not inspect or upload the binary after its reference write fails', async () => { + execCommandMock.mockRejectedValueOnce(new Error('read-only relay directory')) + await expect( + ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { + relayDir: '/home/me/.orca-remote/relay-1.2.3' + }) + ).resolves.toBe('failed') + expect(execCommandMock).toHaveBeenCalledTimes(1) + expect(uploadRelayDirectoryMock).not.toHaveBeenCalled() + }) + + it('records nothing when the host has no bundled build to reference', async () => { + resolveBundledRipgrepPathMock.mockReturnValue(null) + + await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { + relayDir: '/home/me/.orca-remote/relay-1.2.3' + }) + + expect(execScripts().some((script) => script.includes('.ripgrep-ref'))).toBe(false) + }) + + it('uploads into a private stage, then verifies, chmods and renames into place', async () => { + execCommandMock + .mockResolvedValueOnce('ORCA-RG-STAGED\n') + .mockResolvedValueOnce('ORCA-RG-INSTALLED\n') + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( + 'installed' + ) + + const [, source, payloadDir, host, options] = uploadRelayDirectoryMock.mock.calls[0] + expect(source).toBe(dirname(localBinary)) + expect(payloadDir).toMatch( + /^\/home\/me\/\.orca-remote\/ripgrep\/\.upload-[0-9a-f]{16}\/payload$/ + ) + expect(host).toBe(LINUX) + // Why: a chrooted SFTP subsystem must resolve the stage through its marker, like relay uploads. + expect(options.sftpNamespace?.homeRelativePath).toBe(payloadDir.slice('/home/me/'.length)) + const promote = execScripts()[1] + expect(promote).toContain('wc -c') + expect(promote).toContain('"1234"') + expect(promote).toContain('chmod 755') + expect(promote).toContain( + `mv -f '${payloadDir}/rg' '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'` + ) + }) + + it('reports an upload failure instead of throwing, and removes its stage', async () => { + execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n').mockResolvedValueOnce('') + uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('sftp: permission denied')) + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( + 'failed' + ) + + expect(execScripts()[1]).toMatch(/^rm -rf '\/home\/me\/\.orca-remote\/ripgrep\/\.upload-/) + }) + + it('reports a failed size verification', async () => { + execCommandMock + .mockResolvedValueOnce('ORCA-RG-STAGED\n') + .mockResolvedValueOnce('ORCA-RG-FAILED\n') + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( + 'failed' + ) + }) + + it('does nothing when this install has no binary for the host', async () => { + resolveBundledRipgrepPathMock.mockReturnValue(null) + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( + 'unavailable' + ) + expect(execCommandMock).not.toHaveBeenCalled() + }) + + it('installs rg.exe on Windows hosts with PowerShell and no SFTP namespace mapping', async () => { + resolveBundledRipgrepPathMock.mockReturnValue(localBinary) + execCommandMock + .mockResolvedValueOnce('ORCA-RG-STAGED\r\n') + .mockResolvedValueOnce('ORCA-RG-INSTALLED\r\n') + + await expect( + ensureRemoteBundledRipgrep(connection(), WINDOWS, 'C:/Users/me user') + ).resolves.toBe('installed') + + expect(execCommandMock.mock.calls.every(([, , opts]) => opts.wrapCommand === false)).toBe(true) + const [probe, promote] = execScripts() + expect(probe).toContain( + "Test-Path -LiteralPath 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe'" + ) + expect(promote).toContain('Move-Item -LiteralPath $src -Destination $bin') + expect(promote).toContain('.Length -eq 1234') + expect(uploadRelayDirectoryMock.mock.calls[0][4].sftpNamespace).toBeUndefined() + }) + + it('skips the SFTP namespace mapping on the system-ssh transport', async () => { + execCommandMock + .mockResolvedValueOnce('ORCA-RG-STAGED\n') + .mockResolvedValueOnce('ORCA-RG-INSTALLED\n') + + await ensureRemoteBundledRipgrep(connection(true), LINUX, '/home/me') + + expect(uploadRelayDirectoryMock.mock.calls[0][4].sftpNamespace).toBeUndefined() + }) +}) + +// Debian and Ubuntu point /bin/sh at dash; run both so a bashism cannot pass here and fail on a host. +const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) + +describe.runIf(process.platform !== 'win32').each(SHELLS)( + 'remote ripgrep install scripts (%s)', + (shell) => { + let home: string + let localDir: string + let localBinary: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-remote-home-')) + localDir = mkdtempSync(join(tmpdir(), 'orca-local-rg-')) + localBinary = join(localDir, 'linux-x64', 'rg') + mkdirSync(dirname(localBinary)) + writeFileSync(localBinary, '#!/bin/sh\necho ripgrep 15.0.0\n') + resolveBundledRipgrepPathMock.mockReset().mockReturnValue(localBinary) + execCommandMock + .mockReset() + .mockImplementation(async (_conn: unknown, command: string) => + execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) + ) + uploadRelayDirectoryMock + .mockReset() + .mockImplementation(async (_conn: unknown, source: string, target: string) => { + cpSync(source, target, { recursive: true }) + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(home, { recursive: true, force: true }) + rmSync(localDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + const installed = (): string => + join(home, '.orca-remote', 'ripgrep', 'c0ffee0123456789-linux-x64', 'rg') + const cacheEntries = (): string[] => readdirSync(join(home, '.orca-remote', 'ripgrep')) + + it('installs an executable binary once and leaves no stage behind', async () => { + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') + expect(statSync(installed()).mode & 0o111).not.toBe(0) + expect(execFileSync(installed(), { encoding: 'utf-8' })).toContain('ripgrep 15.0.0') + expect(cacheEntries()).toEqual(['c0ffee0123456789-linux-x64']) + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('present') + expect(uploadRelayDirectoryMock).toHaveBeenCalledTimes(1) + }) + + it('never publishes a truncated upload', async () => { + uploadRelayDirectoryMock.mockImplementation( + async (_conn: unknown, source: string, target: string) => { + cpSync(source, target, { recursive: true }) + truncateSync(join(target, 'rg'), 5) + } + ) + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('failed') + expect(existsSync(installed())).toBe(false) + expect(cacheEntries()).toEqual([]) + }) + + it('replaces a truncated binary left at the installed path', async () => { + mkdirSync(dirname(installed()), { recursive: true }) + writeFileSync(installed(), '#!/bin/sh\n', { mode: 0o755 }) + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') + expect(execFileSync(installed(), { encoding: 'utf-8' })).toContain('ripgrep 15.0.0') + }) + + it('keeps an old binary usable by a relay pinned to an earlier client build', async () => { + const previous = join(home, '.orca-remote', 'ripgrep', 'previous-linux-x64', 'rg') + mkdirSync(dirname(previous), { recursive: true }) + writeFileSync(previous, '#!/bin/sh\necho ripgrep previous\n', { mode: 0o755 }) + execFileSync('touch', ['-t', '200001010000', dirname(previous)]) + expect(execFileSync(previous, { encoding: 'utf-8' })).toContain('ripgrep previous') + + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') + await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('present') + + expect(execFileSync(previous, { encoding: 'utf-8' })).toContain('ripgrep previous') + }) + + it('sweeps an abandoned stage older than an hour but keeps a live one', async () => { + const cache = join(home, '.orca-remote', 'ripgrep') + mkdirSync(join(cache, '.upload-stale', 'payload'), { recursive: true }) + mkdirSync(join(cache, '.upload-live', 'payload'), { recursive: true }) + execFileSync('touch', ['-t', '200001010000', join(cache, '.upload-stale')]) + + await ensureRemoteBundledRipgrep(connection(), LINUX, home) + + expect(cacheEntries().sort()).toEqual(['.upload-live', 'c0ffee0123456789-linux-x64']) + }) + } +) diff --git a/src/main/ssh/ssh-relay-ripgrep-install.ts b/src/main/ssh/ssh-relay-ripgrep-install.ts new file mode 100644 index 000000000000..02992ac811b1 --- /dev/null +++ b/src/main/ssh/ssh-relay-ripgrep-install.ts @@ -0,0 +1,303 @@ +/** + * Installs Orca's own ripgrep on an SSH host so remote Quick Open and text search do not depend + * on the user having `rg`. The binary lives at + * `~/.orca-remote/ripgrep/-/rg[.exe]`, a sibling of the + * `relay-` dirs keyed on the binary's bytes alone, so a relay upgrade never re-uploads it. + * + * Uploads land in a private `.upload-` stage and are renamed into place only after a size + * check, so an interrupted or concurrent deploy never leaves a truncated binary at the final path. + * Every failure is reported, never thrown: the relay falls back to PATH `rg` and its git/readdir + * chain, which is exactly what it did before this binary existed. + */ +import { randomBytes } from 'node:crypto' +import { statSync } from 'node:fs' +import { dirname } from 'node:path' +import type { SshConnection } from './ssh-connection' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { execCommand } from './ssh-relay-deploy-helpers' +import { uploadRelayDirectory } from './ssh-relay-install-transfers' +import { + createRelayUploadStageNamespace, + makeRelayUploadStageDirectoryCommand, + relayUploadStageSftpNamespaceMapping, + type RelayUploadStageNamespace +} from './ssh-relay-install-namespace' +import { shellEscape } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + assertSafeRemotePathSegment, + isWindowsRemoteHost, + joinRemotePath, + remoteDirname, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { + bundledRipgrepContentKey, + resolveBundledRipgrepPath +} from '../ripgrep/bundled-ripgrep-path' +import { + bundledRipgrepBinaryName, + toBundledRipgrepPlatform, + type BundledRipgrepPlatform +} from '../../shared/bundled-ripgrep' + +/** Sibling of `relay-`, `orcad-` and `native/`. */ +export const REMOTE_RIPGREP_CACHE_DIR_NAME = 'ripgrep' + +// Ripgrep-only updates share relay bytes, so one relay directory can reference multiple builds. +export const REMOTE_RIPGREP_REF_PREFIX = '.ripgrep-ref-' + +export function remoteRipgrepRefFileName(entryName: string): string { + return `${REMOTE_RIPGREP_REF_PREFIX}${entryName}` +} + +/** Record which ripgrep build a relay directory runs against. Best-effort: never fails a deploy. */ +export async function recordRemoteRipgrepReference( + conn: SshConnection, + host: RemoteHostPlatform, + relayDir: string, + entryName: string +): Promise { + const refPath = joinRemotePath(host, relayDir, remoteRipgrepRefFileName(entryName)) + try { + assertSafeRemotePathSegment(entryName, host.pathFlavor) + await execCommand( + conn, + isWindowsRemoteHost(host) + ? powerShellCommand( + `Set-Content -LiteralPath ${powerShellLiteral(refPath)} -Value ${powerShellLiteral(entryName)} -NoNewline -Encoding ascii` + ) + : `printf %s ${shellEscape(entryName)} > ${shellEscape(refPath)}`, + { wrapCommand: !isWindowsRemoteHost(host) } + ) + return true + } catch (error) { + console.warn( + '[ssh-relay] Could not record the ripgrep reference; skipping the bundled binary:', + error instanceof Error ? error.message : String(error) + ) + return false + } +} +const UPLOAD_STAGE_PREFIX = '.upload-' +// Why an hour: long enough that no live upload of ~5 MB is still writing, short enough to drain crashes. +const STALE_UPLOAD_STAGE_MINUTES = 60 +const PRESENT = 'ORCA-RG-PRESENT' +const STAGED = 'ORCA-RG-STAGED' +const INSTALLED = 'ORCA-RG-INSTALLED' + +export type RemoteRipgrepLayout = { + platform: BundledRipgrepPlatform + cacheDir: string + /** `-`; the sibling directories are superseded builds. */ + entryName: string + binaryPath: string +} + +export type RemoteRipgrepInstallOutcome = 'present' | 'installed' | 'unavailable' | 'failed' + +export function remoteRipgrepLayout( + host: RemoteHostPlatform, + remoteHome: string +): RemoteRipgrepLayout | null { + const platform = toBundledRipgrepPlatform(host.os, host.arch) + const contentKey = platform ? bundledRipgrepContentKey(platform) : null + if (!platform || !contentKey) { + return null + } + const entryName = `${contentKey}-${platform}` + assertSafeRemotePathSegment(entryName, host.pathFlavor) + const cacheDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, REMOTE_RIPGREP_CACHE_DIR_NAME) + return { + platform, + cacheDir, + entryName, + binaryPath: joinRemotePath(host, cacheDir, entryName, bundledRipgrepBinaryName(platform)) + } +} + +/** Make sure the host has Orca's ripgrep at `remoteRipgrepLayout().binaryPath`; never throws. */ +export async function ensureRemoteBundledRipgrep( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options: { signal?: AbortSignal; relayDir?: string } = {} +): Promise { + const layout = remoteRipgrepLayout(host, remoteHome) + if (options.relayDir && layout && resolveBundledRipgrepPath(layout.platform)) { + if (!(await recordRemoteRipgrepReference(conn, host, options.relayDir, layout.entryName))) { + return 'failed' + } + } + return installOrReport(conn, host, remoteHome, options) +} + +async function installOrReport( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options: { signal?: AbortSignal } = {} +): Promise { + try { + const layout = remoteRipgrepLayout(host, remoteHome) + const localBinary = layout ? resolveBundledRipgrepPath(layout.platform) : null + if (!layout || !localBinary) { + console.warn( + `[ssh-relay] No bundled ripgrep for ${host.relayPlatform}; the relay will use rg from PATH` + ) + return 'unavailable' + } + return await installRemoteRipgrep(conn, host, layout, localBinary, options.signal) + } catch (error) { + console.warn( + '[ssh-relay] Bundled ripgrep install failed; the relay will use rg from PATH:', + error instanceof Error ? error.message : String(error) + ) + return 'failed' + } +} + +async function installRemoteRipgrep( + conn: SshConnection, + host: RemoteHostPlatform, + layout: RemoteRipgrepLayout, + localBinary: string, + signal: AbortSignal | undefined +): Promise { + const exec = (command: string): Promise => + execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) + const stageName = `${UPLOAD_STAGE_PREFIX}${randomBytes(8).toString('hex')}` + const stageDir = joinRemotePath(host, layout.cacheDir, stageName) + const stageNamespace = createRelayUploadStageNamespace( + `${RELAY_REMOTE_DIR}/${REMOTE_RIPGREP_CACHE_DIR_NAME}/${stageName}` + ) + + const size = statSync(localBinary).size + // Why one round trip: the warm path (already installed) must cost a single exec. + const probe = await exec(probeOrStageCommand(host, layout, stageDir, stageNamespace, size)) + if (probe.includes(PRESENT)) { + return 'present' + } + if (!probe.includes(STAGED)) { + throw new Error(`could not stage upload: ${probe.trim().slice(0, 200)}`) + } + + let promoted = false + try { + await uploadRelayDirectory( + conn, + dirname(localBinary), + joinRemotePath(host, stageDir, 'payload'), + host, + { + signal, + sftpNamespace: usesOrcaOwnedSftp(conn, host) + ? relayUploadStageSftpNamespaceMapping(stageNamespace, host, stageDir) + : undefined + } + ) + const result = await exec(promoteCommand(host, layout, stageDir, size)) + promoted = true + if (!result.includes(INSTALLED)) { + throw new Error(`upload did not verify: ${result.trim().slice(0, 200)}`) + } + console.log(`[ssh-relay] Installed bundled ripgrep at ${layout.binaryPath} (${size} bytes)`) + return 'installed' + } finally { + if (!promoted) { + // Why best-effort: the next deploy's probe also sweeps stale stages. + await execCommand(conn, removeRemoteTreeCommand(host, stageDir), { + wrapCommand: !isWindowsRemoteHost(host) + }).catch(() => {}) + } + } +} + +/** Split shell/SFTP namespaces only arise on POSIX hosts reached over the bundled ssh2 SFTP. */ +function usesOrcaOwnedSftp(conn: SshConnection, host: RemoteHostPlatform): boolean { + if (isWindowsRemoteHost(host)) { + return false + } + return typeof conn.usesSystemSshTransport === 'function' ? !conn.usesSystemSshTransport() : true +} + +// Why size: the path is content-keyed, so a wrong length can only be a truncated or foreign file. +function posixInstalledTest(bin: string, bytes: string): string { + return `[ -f ${bin} ] && [ -x ${bin} ] && [ "$(wc -c < ${bin} 2>/dev/null | tr -d ' \\t')" = "${bytes}" ]` +} + +function windowsInstalledTest(bin: string, bytes: string): string { + return `(Test-Path -LiteralPath ${bin} -PathType Leaf) -and ((Get-Item -LiteralPath ${bin}).Length -eq ${bytes})` +} + +export function probeOrStageCommand( + host: RemoteHostPlatform, + layout: RemoteRipgrepLayout, + stageDir: string, + stageNamespace: RelayUploadStageNamespace, + expectedBytes: number +): string { + const bytes = String(Math.trunc(expectedBytes)) + if (!isWindowsRemoteHost(host)) { + const bin = shellEscape(layout.binaryPath) + const cache = shellEscape(layout.cacheDir) + const sweep = `find ${cache} -mindepth 1 -maxdepth 1 -type d -name '${UPLOAD_STAGE_PREFIX}*' -mmin +${STALE_UPLOAD_STAGE_MINUTES} -exec rm -rf {} + 2>/dev/null` + // Installed builds may still serve an older client's live relay; age is not disuse. + const stage = makeRelayUploadStageDirectoryCommand(stageNamespace, host, stageDir) + // Why the sweep runs before the branch, not inside the else: once rg is installed every later + // deploy takes the PRESENT path, so a stage orphaned by a dropped connection would never be + // collected. It stays one exec round trip either way. + return `${sweep}; if ${posixInstalledTest(bin, bytes)}; then echo ${PRESENT}; else ${stage} && echo ${STAGED}; fi` + } + return powerShellCommand( + [ + `Get-ChildItem -LiteralPath ${powerShellLiteral(layout.cacheDir)} -Directory -Filter '${UPLOAD_STAGE_PREFIX}*' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-${STALE_UPLOAD_STAGE_MINUTES}) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue`, + `if (${windowsInstalledTest(powerShellLiteral(layout.binaryPath), bytes)}) { '${PRESENT}' } else {`, + `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(joinRemotePath(host, stageDir, 'payload'))} -ErrorAction Stop`, + `'${STAGED}' }` + ].join('\n') + ) +} + +/** Verify the staged size, then rename into place; a concurrent winner's binary also counts. */ +export function promoteCommand( + host: RemoteHostPlatform, + layout: RemoteRipgrepLayout, + stageDir: string, + expectedBytes: number +): string { + const staged = joinRemotePath( + host, + stageDir, + 'payload', + bundledRipgrepBinaryName(layout.platform) + ) + if (!isWindowsRemoteHost(host)) { + const src = shellEscape(staged) + const bin = shellEscape(layout.binaryPath) + const bytes = String(Math.trunc(expectedBytes)) + return [ + `if [ "$(wc -c < ${src} 2>/dev/null | tr -d ' \\t')" = "${bytes}" ] && chmod 755 ${src} && mkdir -p ${shellEscape(remoteDirname(layout.binaryPath, host))} && mv -f ${src} ${bin}; then r=${INSTALLED};`, + `elif ${posixInstalledTest(bin, bytes)}; then r=${INSTALLED}; else r=ORCA-RG-FAILED; fi;`, + `rm -rf ${shellEscape(stageDir)}; echo "$r"` + ].join(' ') + } + return powerShellCommand( + [ + `$src = ${powerShellLiteral(staged)}`, + `$bin = ${powerShellLiteral(layout.binaryPath)}`, + "$r = 'ORCA-RG-FAILED'", + 'try {', + `if ((Get-Item -LiteralPath $src -ErrorAction Stop).Length -eq ${Math.trunc(expectedBytes)}) {`, + `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteDirname(layout.binaryPath, host))} -ErrorAction Stop`, + // Why replace only a wrong-size file: a running relay may hold a good rg.exe open. + `if (-not (${windowsInstalledTest('$bin', String(Math.trunc(expectedBytes)))})) { Move-Item -LiteralPath $src -Destination $bin -Force -ErrorAction Stop }`, + `$r = '${INSTALLED}'`, + '}', + `} catch { if (${windowsInstalledTest('$bin', String(Math.trunc(expectedBytes)))}) { $r = '${INSTALLED}' } }`, + `finally { Remove-Item -LiteralPath ${powerShellLiteral(stageDir)} -Recurse -Force -ErrorAction SilentlyContinue }`, + '$r' + ].join('\n') + ) +} diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 4ec2e9eec4de..65459b8bcbaa 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -7,6 +7,12 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' +vi.mock('./ssh-relay-ripgrep-install', () => ({ + remoteRipgrepLayout: vi.fn().mockReturnValue(null), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) diff --git a/src/relay/fs-handler-install-rg.ts b/src/relay/fs-handler-install-rg.ts deleted file mode 100644 index 5e5eab88aaa3..000000000000 --- a/src/relay/fs-handler-install-rg.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - buildInstallRgMessage as buildSharedInstallRgMessage, - buildRipgrepRequiredMessage as buildSharedRipgrepRequiredMessage, - detectInstallCommand, - detectLinuxInstallCommandFromOsRelease -} from '../shared/quick-open-install-rg' - -export { detectInstallCommand, detectLinuxInstallCommandFromOsRelease } - -export function buildInstallRgMessage(cause: unknown): Promise { - return buildSharedInstallRgMessage(cause, 'remote') -} - -export function buildRipgrepRequiredMessage(): Promise { - return buildSharedRipgrepRequiredMessage('remote') -} diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 2cc666c50bbd..3123d35241a1 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -17,6 +17,8 @@ import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { buildRelayCommandEnv } from './relay-command-env' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { ListFilesScanCoordinator, LIST_FILES_SUPERSEDED_MESSAGE @@ -63,6 +65,9 @@ describe('relay quick open ignored file listing', () => { }) afterEach(async () => { + // Why reset: the bundled path is module state, and leaking it changes which binary the next + // test's launch-failure classifier probes. + configureRelayBundledRipgrep(undefined) await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) @@ -637,12 +642,19 @@ describe('relay quick open ignored file listing', () => { await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) }) - it('keeps missing-root launch errors on their prior non-fallback paths', async () => { + // Why this changed: both paths still refuse the git/readdir fallback, which is what "non-fallback" + // pinned -- a chain that walks the root cannot help when the root is gone. What changed is that + // search no longer reports an empty, successful-looking scan for a workspace that moved. + it('names the unreachable root on both missing-root launch paths', async () => { const missingRoot = await makeTempRoot() await rm(missingRoot, { recursive: true, force: true }) const listFirst = createMockProcess() const listProbe = createMockProcess() Object.defineProperty(listFirst, 'pid', { value: undefined }) + Object.defineProperties(listFirst, { + stdout: { value: undefined }, + stderr: { value: undefined } + }) Object.defineProperty(listProbe, 'pid', { value: 1 }) let callIndex = 0 spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++]) @@ -651,14 +663,26 @@ describe('relay quick open ignored file listing', () => { listFirst.emit('error', listError) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - expect(spawnMock.mock.calls[1]).toEqual(['rg', ['--version'], { stdio: 'ignore' }]) + // Why assert the relay env and not just its presence: the probe decides whether a launch + // failure was the binary or the root, so it has to resolve the same `rg` the failed spawn + // would have. Under process.env it could find a different one, or none. + expect(spawnMock.mock.calls[1]).toEqual([ + 'rg', + ['--version'], + // windowsHide: the probe must never flash a console window on Windows. + { env: buildRelayCommandEnv(), stdio: 'ignore', windowsHide: true } + ]) listProbe.emit('close', 0, null) - await expect(listing).rejects.toBe(listError) + await expect(listing).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) spawnMock.mockReset() const searchChild = createMockProcess() const searchProbe = createMockProcess() Object.defineProperty(searchChild, 'pid', { value: undefined }) + Object.defineProperties(searchChild, { + stdout: { value: undefined }, + stderr: { value: undefined } + }) Object.defineProperty(searchProbe, 'pid', { value: 1 }) spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe) const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 }) @@ -666,7 +690,32 @@ describe('relay quick open ignored file listing', () => { await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) searchProbe.emit('close', 0, null) - await expect(search).resolves.toMatchObject({ files: [], totalMatches: 0 }) + await expect(search).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) + }) + + // Why this is the case that matters: it is the NORMAL remote setup. Orca uploads a bundled rg + // precisely because the host has no `rg` of its own, so probing PATH alone would fail and report + // a moved workspace as a missing ripgrep -- telling the user to install what Orca already ships. + it('names the unreachable root when only the bundled rg exists, not PATH rg', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const bundled = process.execPath + configureRelayBundledRipgrep(bundled) + const first = createMockProcess() + Object.defineProperty(first, 'pid', { value: undefined }) + const probe = createMockProcess() + Object.defineProperty(probe, 'pid', { value: 1 }) + let callIndex = 0 + spawnMock.mockImplementation(() => [first, probe][callIndex++]) + const listing = listFilesWithRg(missingRoot) + first.emit('error', Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + // The probe must ask about the binary that actually failed, not a PATH rg this host lacks. + expect(spawnMock.mock.calls[1]?.[0]).toBe(bundled) + probe.emit('close', 0, null) + + await expect(listing).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) }) it('keeps missing-rg precedence when the root also disappeared', async () => { diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 7860d6dca29f..d2b4732777c5 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -23,14 +23,21 @@ import { } from '../shared/quick-open-filter' import { absorbPendingRipgrepSpawnError, - isRipgrepUnavailableAfterLaunchFailure, + classifyRipgrepLaunchFailure, isRipgrepUnavailableExit, isTransientRipgrepSpawnError, killSpawnedRipgrepProcess, RipgrepLaunchFailureError, + ripgrepMissingCwdError, RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { QuickOpenPathRanker } from '../shared/quick-open-path-search' +import { buildRelayCommandEnv } from './relay-command-env' +import { + pathRipgrepCommand, + resolveRelayRipgrepCommand, + retryRipgrepOnPathAfterLaunchFailure +} from './relay-bundled-ripgrep' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -96,16 +103,19 @@ export function listFilesWithRg( let processErrorObserved = false let unavailableExitObserved = false let launchFailureCheck: Promise | null = null - // --no-messages: permission-denied noise on the remote (e.g. .ssh, - // root-owned mounts) would otherwise flood stderr. - // cwd: rootPath — root-relative exclude globs like `!packages/app/**` - // are evaluated against rg's working directory, not the absolute - // search target. Without cwd, nested-worktree exclusions silently - // stop working. + // Suppress permission noise; cwd anchors root-relative exclusion globs. + const command = resolveRelayRipgrepCommand() + // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the + // spawn cwd -- the user's repo -- before PATH. "No rg here" is what the chain handles. + if (command === null) { + throw new RipgrepUnavailableError() + } + const env = buildRelayCommandEnv() let child: ChildProcess try { - child = spawn('rg', ['--no-messages', ...args], { + child = spawn(command, ['--no-messages', ...args], { cwd: rootPath, + env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) @@ -122,8 +132,8 @@ export function listFilesWithRg( clearTimeout(timer) timer = null } - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) + child.stdout?.off('data', handleStdoutData) + child.stderr?.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) absorbPendingRipgrepSpawnError(child, { @@ -155,17 +165,28 @@ export function listFilesWithRg( if (launchFailureCheck) { return } - launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then( - (unavailable) => { - rejectPass(unavailable ? new RipgrepUnavailableError() : error) + launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error).then( + async (retryOnPath) => { + if (retryOnPath) { + // Why: runPass retries a launch failure once, and the retry resolves to PATH rg. + rejectPass(new RipgrepLaunchFailureError('bundled rg failed to start')) + return + } + // Why distinguish: RipgrepUnavailableError is what engages the git/readdir chain, + // and that chain cannot help when the root itself is gone. + rejectPass( + (await classifyRipgrepLaunchFailure( + rootPath, + [command, pathRipgrepCommand()], + env + )) === 'cwd-unreachable' + ? ripgrepMissingCwdError(rootPath) + : new RipgrepUnavailableError() + ) } ) } - children.push({ - child, - isDone: () => passDone, - reject: rejectPass - }) + children.push({ child, isDone: () => passDone, reject: rejectPass }) timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte @@ -247,9 +268,9 @@ export function listFilesWithRg( } } - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) + child.stdout?.setEncoding('utf-8') + child.stdout?.on('data', handleStdoutData) + child.stderr?.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) }) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 7d501e56d660..8b93c6ac82e7 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -2,7 +2,7 @@ * Pure helpers and child-process search utilities extracted from fs-handler.ts. * * Why: oxlint max-lines requires .ts files to stay under 300 lines. - * These functions depend only on their arguments (plus `rg` being on PATH), + * These functions depend only on their arguments (plus a launchable `rg`), * so they are straightforward to test independently. */ import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' @@ -19,11 +19,18 @@ import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' import type { SearchResult as SharedSearchResult } from '../shared/code-search-types' import { absorbPendingRipgrepSpawnError, - isRipgrepUnavailableAfterLaunchFailure, + classifyRipgrepLaunchFailure, isRipgrepUnavailableExit, killSpawnedRipgrepProcess, + ripgrepMissingCwdError, RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { buildRelayCommandEnv } from './relay-command-env' +import { + pathRipgrepCommand, + resolveRelayRipgrepCommand, + retryRipgrepOnPathAfterLaunchFailure +} from './relay-bundled-ripgrep' // ─── Constants ─────────────────────────────────────────────────────── @@ -110,11 +117,23 @@ export function searchWithRg( // which would leak out of the `new Promise` executor and leave the // promise forever pending. Treat a synchronous throw as a clean // "no results" fallback, the same way an async 'error' event is handled. + const resolvedRgCommand = resolveRelayRipgrepCommand() + // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the spawn + // cwd -- the user's repo -- before PATH, so a planted rg.exe would run instead. + if (resolvedRgCommand === null) { + reject(new RipgrepUnavailableError()) + return + } + // Why a second binding: the closures below capture it, and narrowing does not reach them. + const command: string = resolvedRgCommand + const env = buildRelayCommandEnv() let child: ReturnType try { - child = spawn('rg', rgArgs, { + child = spawn(command, rgArgs, { cwd: rootPath, - stdio: ['ignore', 'pipe', 'pipe'] + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true }) } catch { resolve(finalize(acc)) @@ -123,55 +142,61 @@ export function searchWithRg( let killTimeout: ReturnType - function resolveOnce(): void { + function settle(): boolean { if (resolved) { - return + return false } resolved = true lines.clear() clearTimeout(killTimeout) // Why: child.kill() is advisory over SSH; detach listeners if the // process ignores timeout kill so old searches cannot retain closures. - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) + child.stdout?.off('data', handleStdoutData) + child.stderr?.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) absorbPendingRipgrepSpawnError(child, { errorObserved: processErrorObserved, unavailableExitObserved }) - resolve(finalize(acc)) + return true } - function rejectUnavailable(): void { - if (resolved) { - return + function resolveOnce(): void { + if (settle()) { + resolve(finalize(acc)) } - resolved = true - lines.clear() - clearTimeout(killTimeout) - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - reject(new RipgrepUnavailableError()) } - function settleLaunchFailure(): void { + function settleLaunchFailure(error?: unknown): void { if (launchFailureCheck) { return } - launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => { - if (unavailable) { - rejectUnavailable() - } else { - resolveOnce() + launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error).then( + async (retryOnPath) => { + if (retryOnPath) { + // Why: a launch failure produced no output, so rerunning on PATH rg loses nothing. + if (settle()) { + searchWithRg(rootPath, query, opts).then(resolve, reject) + } + return + } + // Why not resolveOnce() on an unreachable root: an empty result reads as "no matches" + // and the git/readdir chain never engages, because it only triggers on an unavailable + // ripgrep. The workspace moving would otherwise look like a successful empty scan. + if (settle()) { + reject( + (await classifyRipgrepLaunchFailure( + rootPath, + [command, pathRipgrepCommand()], + env + )) === 'cwd-unreachable' + ? ripgrepMissingCwdError(rootPath) + : new RipgrepUnavailableError() + ) + } } - }) + ) } function processLine(line: string): void { @@ -189,10 +214,10 @@ export function searchWithRg( /* drain */ } - function handleError(): void { + function handleError(error: Error): void { processErrorObserved = true if (isRipgrepUnavailableExit(child, null, null)) { - settleLaunchFailure() + settleLaunchFailure(error) return } resolveOnce() @@ -215,9 +240,9 @@ export function searchWithRg( resolveOnce() } - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) + child.stdout?.setEncoding('utf-8') + child.stdout?.on('data', handleStdoutData) + child.stderr?.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) diff --git a/src/relay/fs-list-files-fallback-chain.ts b/src/relay/fs-list-files-fallback-chain.ts index d34344bafa35..d60cfff80d59 100644 --- a/src/relay/fs-list-files-fallback-chain.ts +++ b/src/relay/fs-list-files-fallback-chain.ts @@ -7,7 +7,7 @@ import { throwIfFileListingCancelled } from '../shared/file-listing-cancellation' import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' -import { buildInstallRgMessage, buildRipgrepRequiredMessage } from './fs-handler-install-rg' +import { buildInstallRgMessage, buildRipgrepRequiredMessage } from '../shared/quick-open-install-rg' import { buildRelayCommandEnv } from './relay-command-env' import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { QuickOpenPathRanker } from '../shared/quick-open-path-search' diff --git a/src/relay/relay-bundled-ripgrep.test.ts b/src/relay/relay-bundled-ripgrep.test.ts new file mode 100644 index 000000000000..5f667ad1429b --- /dev/null +++ b/src/relay/relay-bundled-ripgrep.test.ts @@ -0,0 +1,148 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('node:child_process', () => ({ spawn: spawnMock })) +vi.mock('./relay-diagnostic-log', () => ({ relayLogLine: vi.fn() })) + +import { + configureRelayBundledRipgrep, + resolveRelayRipgrepCommand, + retryRipgrepOnPathAfterLaunchFailure +} from './relay-bundled-ripgrep' +import { listFilesWithRg } from './fs-handler-list-files' +import { searchWithRg } from './fs-handler-utils' + +type MockChild = ChildProcess & { stdout: EventEmitter; stderr: EventEmitter } + +function createProcess(pid: number | undefined): MockChild { + const child = Object.assign(new EventEmitter(), { + stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }), + stderr: new EventEmitter(), + kill: vi.fn(), + exitCode: null, + signalCode: null + }) + Object.defineProperty(child, 'pid', { configurable: true, value: pid }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test double exposes only the ChildProcess members these spawners use. + return child as unknown as MockChild +} + +function failToLaunch(child: MockChild, code: string): void { + setImmediate(() => child.emit('error', Object.assign(new Error(code), { code }))) +} + +function succeedWith(child: MockChild, stdout: string): void { + setImmediate(() => { + child.stdout.emit('data', stdout) + child.emit('close', 0, null) + }) +} + +describe('relay bundled ripgrep', () => { + let dir: string + let bundled: string + + beforeEach(() => { + spawnMock.mockReset() + dir = mkdtempSync(join(tmpdir(), 'relay-rg-')) + bundled = join(dir, 'rg') + writeFileSync(bundled, '') + configureRelayBundledRipgrep(bundled) + }) + + afterEach(() => { + configureRelayBundledRipgrep(undefined) + rmSync(dir, { recursive: true, force: true }) + }) + + it('prefers the bundled binary and uses PATH rg when none is configured or present', () => { + expect(resolveRelayRipgrepCommand()).toBe(bundled) + configureRelayBundledRipgrep(join(dir, 'not-uploaded-yet', 'rg')) + expect(resolveRelayRipgrepCommand()).toBe('rg') + configureRelayBundledRipgrep(undefined) + expect(resolveRelayRipgrepCommand()).toBe('rg') + }) + + it('does not blame the binary when the spawn cwd is what is missing', async () => { + await expect( + retryRipgrepOnPathAfterLaunchFailure(bundled, join(dir, 'missing-cwd')) + ).resolves.toBe(false) + expect(resolveRelayRipgrepCommand()).toBe(bundled) + await expect(retryRipgrepOnPathAfterLaunchFailure('rg', dir)).resolves.toBe(false) + }) + + it('backs off the bundled binary after a launch failure, then tries it again', async () => { + vi.useFakeTimers() + try { + await expect(retryRipgrepOnPathAfterLaunchFailure(bundled, dir)).resolves.toBe(true) + expect(resolveRelayRipgrepCommand()).toBe('rg') + vi.advanceTimersByTime(60_001) + expect(resolveRelayRipgrepCommand()).toBe(bundled) + } finally { + vi.useRealTimers() + } + }) + + it('does not back off on fd or process pressure', async () => { + const error = Object.assign(new Error('EMFILE'), { code: 'EMFILE' }) + await expect(retryRipgrepOnPathAfterLaunchFailure(bundled, dir, error)).resolves.toBe(false) + expect(resolveRelayRipgrepCommand()).toBe(bundled) + }) + + it('retries a file listing on PATH rg after the bundled binary fails to launch', async () => { + const commands: string[] = [] + spawnMock.mockImplementation((command: string) => { + commands.push(command) + if (command === bundled) { + const child = createProcess(undefined) + failToLaunch(child, 'EACCES') + return child + } + const child = createProcess(42) + succeedWith(child, 'src/index.ts\n') + return child + }) + + await expect(listFilesWithRg(dir, [], { maxResults: 10 })).resolves.toEqual(['src/index.ts']) + expect(commands[0]).toBe(bundled) + expect(commands.slice(1).every((command) => command === 'rg')).toBe(true) + expect(resolveRelayRipgrepCommand()).toBe('rg') + }) + + it('retries a text search on PATH rg with the relay command env and a hidden window', async () => { + const hit = JSON.stringify({ + type: 'match', + data: { + path: { text: join(dir, 'a.ts') }, + lines: { text: 'needle\n' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }) + spawnMock.mockImplementation((command: string) => { + if (command === bundled) { + const child = createProcess(undefined) + failToLaunch(child, 'ENOENT') + return child + } + const child = createProcess(42) + succeedWith(child, `${hit}\n`) + return child + }) + + const result = await searchWithRg(dir, 'needle', { maxResults: 10 }) + + expect(result.totalMatches).toBe(1) + expect(spawnMock.mock.calls.map(([command]) => command)).toEqual([bundled, 'rg']) + for (const [, , options] of spawnMock.mock.calls) { + expect(options.windowsHide).toBe(true) + expect(options.env.PATH ?? options.env.Path).toContain('.cargo') + } + expect(resolveRelayRipgrepCommand()).toBe('rg') + }) +}) diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts new file mode 100644 index 000000000000..787176e3f423 --- /dev/null +++ b/src/relay/relay-bundled-ripgrep.ts @@ -0,0 +1,124 @@ +/** + * Which `rg` the relay spawns. The SSH deploy uploads Orca's own ripgrep to a version-keyed cache + * and passes its path via `--ripgrep-path`; PATH `rg` (and then the git/readdir fallbacks) stays + * the answer whenever that binary is absent or cannot launch on this host. + */ +import { existsSync, statSync } from 'node:fs' +import { delimiter, join, win32 } from 'node:path' +import { + isRipgrepSpawnCwdUsable, + isTransientRipgrepSpawnError +} from '../shared/ripgrep-process-availability' +import { relayLogLine } from './relay-diagnostic-log' + +export const PATH_RIPGREP_COMMAND = 'rg' + +/** + * Absolute path to a PATH `rg` on Windows, or null when there is none. + * + * Why Windows only: CreateProcessW searches the spawn cwd -- the user's repo -- before PATH, so a + * bare `rg` there runs a planted `rg.exe` from a cloned repository. POSIX `execvp` never consults + * the cwd, so a bare name stays correct and free there. + */ +export function isDriveRootedWindowsPath(dir: string): boolean { + // Why not path.isAbsolute: on Windows it accepts `\tools` and `/tools`, which are rooted but + // carry no drive, so they resolve against whatever drive the process is on. The probe would + // then validate `C:\tools\rg.exe` while the spawn, running with the user's repo as cwd, + // executes `D:\tools\rg.exe` -- the same cwd-dependence this lookup exists to remove. + const { root } = win32.parse(dir) + return /^[A-Za-z]:[\\/]/.test(root) || root.startsWith('\\\\') +} + +// Why only rg.exe, though libuv honours %PATHEXT%: `.bat`/`.cmd` shims are not spawnable without +// `shell: true`, and every ripgrep installer (winget, choco, scoop, cargo) lays down rg.exe. +function resolveWindowsPathRipgrep(): string | null { + for (const entry of (process.env.PATH ?? '').split(delimiter)) { + const dir = entry.replace(/^"|"$/g, '').trim() + if (!dir || !isDriveRootedWindowsPath(dir)) { + continue + } + const candidate = join(dir, 'rg.exe') + try { + if (statSync(candidate).isFile()) { + return candidate + } + } catch { + /* next entry */ + } + } + return null +} + +let windowsPathRipgrep: string | null | undefined + +let bundledRipgrepPath: string | null = null +// Why a back-off, not forever: Windows AV often locks a just-installed rg.exe for its first spawns. +const BUNDLED_RIPGREP_RETRY_MS = 60_000 +let bundledRipgrepUnusableUntil = 0 + +export function configureRelayBundledRipgrep(path: string | undefined): void { + bundledRipgrepPath = path ? path : null + bundledRipgrepUnusableUntil = 0 +} + +/** + * The host's PATH ripgrep, safe to spawn directly, or null when it has none. On Windows that is an + * absolute path, because a bare name would resolve against the spawn cwd first. + */ +export function pathRipgrepCommand(): string | null { + if (process.platform !== 'win32') { + return PATH_RIPGREP_COMMAND + } + // Why the explicit undefined check and not `??=`: a miss resolves to null, which is nullish, so + // `??=` would re-walk every PATH entry on each call -- and a miss is the expensive case, since + // it stats every directory instead of stopping at the first hit. + if (windowsPathRipgrep === undefined) { + windowsPathRipgrep = resolveWindowsPathRipgrep() + } + return windowsPathRipgrep +} + +/** Null means this host has no usable rg, so the caller falls back to git/readdir. */ +export function resolveRelayRipgrepCommand(): string | null { + // Why check existence per spawn: the deploy uploads rg after the relay starts, so it can appear mid-session. + if ( + bundledRipgrepPath && + Date.now() >= bundledRipgrepUnusableUntil && + existsSync(bundledRipgrepPath) + ) { + return bundledRipgrepPath + } + return pathRipgrepCommand() +} + +export function resetRelayRipgrepPathCacheForTests(): void { + windowsPathRipgrep = undefined +} + +/** + * Called when `command` failed to launch. Resolves true when it was the bundled binary and the + * caller should retry once on PATH `rg`; spawns skip the bundled binary for a back-off window. + */ +export async function retryRipgrepOnPathAfterLaunchFailure( + command: string, + cwd: string, + error?: unknown +): Promise { + if (command === PATH_RIPGREP_COMMAND || command !== bundledRipgrepPath) { + return false + } + // Why: fd/process pressure says nothing about the binary, so it must not disable it for the relay's life. + if (isTransientRipgrepSpawnError(error)) { + return false + } + // Why: spawn reports a missing cwd as ENOENT too; that says nothing about the binary. + if (!(await isRipgrepSpawnCwdUsable(cwd))) { + return false + } + // Why only while the file exists: a removed binary is re-checked per spawn anyway. + if (existsSync(command) && Date.now() >= bundledRipgrepUnusableUntil) { + bundledRipgrepUnusableUntil = Date.now() + BUNDLED_RIPGREP_RETRY_MS + relayLogLine(`[relay] Bundled ripgrep at ${command} failed to launch; using rg from PATH`) + } + return true +} diff --git a/src/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts index bfcbf9143fcb..e16d3cad783d 100644 --- a/src/relay/relay-launch-options.test.ts +++ b/src/relay/relay-launch-options.test.ts @@ -46,6 +46,19 @@ describe('relay launch options', () => { }) }) + it('reads the bundled ripgrep path, which older launch commands omit', () => { + expect( + parseRelayLaunchOptions([ + 'node', + 'relay.js', + '--detached', + '--ripgrep-path', + '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg' + ]).ripgrepPath + ).toBe('/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg') + expect(parseRelayLaunchOptions(['node', 'relay.js', '--detached']).ripgrepPath).toBe(undefined) + }) + it('keeps zero grace unlimited and ignores invalid replacements', () => { expect( parseRelayLaunchOptions(['node', 'relay.js', '--grace-time', '0', '--grace-time', '-1']) diff --git a/src/relay/relay-launch-options.ts b/src/relay/relay-launch-options.ts index 95fafae818ad..f9e057ce55f7 100644 --- a/src/relay/relay-launch-options.ts +++ b/src/relay/relay-launch-options.ts @@ -22,6 +22,8 @@ export type RelayLaunchOptions = { endpointDir?: string logFile?: string credentialFile?: string + /** Orca's uploaded ripgrep; absent when an older client launched this relay or no upload happened. */ + ripgrepPath?: string } export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { @@ -33,6 +35,7 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { let endpointDir: string | undefined let logFile: string | undefined let credentialFile: string | undefined + let ripgrepPath: string | undefined for (let i = 2; i < argv.length; i++) { if (argv[i] === '--grace-time' && argv[i + 1]) { const parsed = Number.parseInt(argv[i + 1], 10) @@ -59,6 +62,9 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { } else if (argv[i] === '--credential-file' && argv[i + 1]) { credentialFile = argv[i + 1] i++ + } else if (argv[i] === '--ripgrep-path' && argv[i + 1]) { + ripgrepPath = argv[i + 1] + i++ } } if (!sockPath) { @@ -72,7 +78,8 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { sockPath, endpointDir, logFile, - credentialFile + credentialFile, + ripgrepPath } } diff --git a/src/relay/relay-ripgrep-cwd-env.test.ts b/src/relay/relay-ripgrep-cwd-env.test.ts new file mode 100644 index 000000000000..38734ca63e67 --- /dev/null +++ b/src/relay/relay-ripgrep-cwd-env.test.ts @@ -0,0 +1,42 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { listFilesWithRg } from './fs-handler-list-files' +import { searchWithRg } from './fs-handler-utils' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' + +describe.runIf(process.platform !== 'win32')( + 'relay ripgrep launch classification environment', + () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relay-rg-cwd-env-')) + writeFileSync(join(dir, 'found.txt'), 'needle') + const cargo = join(dir, 'cargo') + mkdirSync(join(cargo, 'bin'), { recursive: true }) + writeFileSync(join(cargo, 'bin', 'rg'), '#!/bin/sh\necho found.txt\n', { mode: 0o755 }) + vi.stubEnv('PATH', join(dir, 'empty-path')) + vi.stubEnv('CARGO_HOME', cargo) + configureRelayBundledRipgrep(undefined) + }) + + afterEach(() => { + vi.unstubAllEnvs() + configureRelayBundledRipgrep(undefined) + rmSync(dir, { recursive: true, force: true }) + }) + + it('uses the search PATH to distinguish a moved root from missing ripgrep', async () => { + await expect(listFilesWithRg(dir, [], { maxResults: 10 })).resolves.toContain('found.txt') + const missing = join(dir, 'moved-root') + await expect(listFilesWithRg(missing)).rejects.toThrow( + `Search root is not reachable: ${missing}` + ) + await expect(searchWithRg(missing, 'needle', { maxResults: 10 })).rejects.toThrow( + `Search root is not reachable: ${missing}` + ) + }) + } +) diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts new file mode 100644 index 000000000000..a6dee0e14e83 --- /dev/null +++ b/src/relay/relay-windows-path-ripgrep.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + configureRelayBundledRipgrep, + isDriveRootedWindowsPath, + pathRipgrepCommand, + resetRelayRipgrepPathCacheForTests +} from './relay-bundled-ripgrep' + +const originalPlatform = process.platform +const originalPath = process.env.PATH + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { configurable: true, value }) +} + +describe('relay PATH ripgrep resolution', () => { + afterEach(() => { + setPlatform(originalPlatform) + process.env.PATH = originalPath + resetRelayRipgrepPathCacheForTests() + configureRelayBundledRipgrep(undefined) + }) + + // Why a pure predicate test and not a filesystem one: the walk applies win32 path semantics, and + // a temp directory on a POSIX CI host has no drive letter to exercise them with. + it('accepts only drive-lettered and UNC roots', () => { + expect(isDriveRootedWindowsPath('C:\\tools')).toBe(true) + expect(isDriveRootedWindowsPath('d:/tools')).toBe(true) + expect(isDriveRootedWindowsPath('\\\\server\\share\\tools')).toBe(true) + // Rooted but drive-less: `path.isAbsolute` says true, yet these resolve against whatever drive + // the process is on -- the relay's for the probe, the user's repo for the spawn. + expect(isDriveRootedWindowsPath('\\tools')).toBe(false) + expect(isDriveRootedWindowsPath('/tools')).toBe(false) + // Drive-relative, and relative. + expect(isDriveRootedWindowsPath('C:tools')).toBe(false) + expect(isDriveRootedWindowsPath('tools')).toBe(false) + }) + + // Why this matters on Windows only: CreateProcessW searches the spawn cwd -- the user's repo -- + // before PATH, so a bare `rg` there runs a planted rg.exe out of a cloned repository. + it.runIf(process.platform === 'win32')('resolves an absolute rg.exe from PATH', () => { + const dir = mkdtempSync(join(tmpdir(), 'relay-rg-')) + try { + writeFileSync(join(dir, 'rg.exe'), '') + setPlatform('win32') + process.env.PATH = `${join(dir, 'missing')}${delimiter}${dir}` + resetRelayRipgrepPathCacheForTests() + + expect(pathRipgrepCommand()).toBe(join(dir, 'rg.exe')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns null on Windows rather than a bare name when PATH has no rg', () => { + setPlatform('win32') + process.env.PATH = join(tmpdir(), 'definitely-not-here') + resetRelayRipgrepPathCacheForTests() + + expect(pathRipgrepCommand()).toBeNull() + }) + + // Why drive-rooted and not merely "absolute": path.isAbsolute accepts `\\tools` and `/tools` on + // Windows. Those carry no drive, so the probe would validate them against the relay's drive + // while the spawn -- running with the user's repo as cwd -- resolves them against the repo's. + it('skips rooted PATH entries that carry no drive', () => { + setPlatform('win32') + process.env.PATH = `\\tools${delimiter}/tools${delimiter}C:tools` + resetRelayRipgrepPathCacheForTests() + + expect(pathRipgrepCommand()).toBeNull() + }) + + // Why a relative PATH entry is skipped: it resolves against the cwd, the hazard being avoided. + it('ignores relative PATH entries on Windows', () => { + setPlatform('win32') + process.env.PATH = `.${delimiter}node_modules/.bin` + resetRelayRipgrepPathCacheForTests() + + expect(pathRipgrepCommand()).toBeNull() + }) + + // Why POSIX keeps the bare name: execvp never consults the cwd, so there is nothing to resolve. + it('keeps the bare name on POSIX', () => { + setPlatform('linux') + resetRelayRipgrepPathCacheForTests() + + expect(pathRipgrepCommand()).toBe('rg') + }) +}) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 083f9a3da69a..fd30961c330a 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -7,6 +7,7 @@ import { runRelayConnectChannel } from './relay-connect-channel' import { runRelayOrcaCliChannel } from './relay-orca-cli-channel' import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' async function main(): Promise { const options = parseRelayLaunchOptions(process.argv) @@ -23,6 +24,7 @@ async function main(): Promise { ) return } + configureRelayBundledRipgrep(options.ripgrepPath) // Why no read here: the daemon publishes its credential itself, after it owns the socket. await runRelayDaemon(options) } diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index f406fab42aba..3cfdf86f1d4b 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -153,7 +153,6 @@ function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element return guidance ? ( diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx b/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx index 361f1457666e..2a99389f5a16 100644 --- a/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx +++ b/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx @@ -6,28 +6,17 @@ import { QuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance' afterEach(cleanup) describe('QuickOpenInstallRgGuidance', () => { - it('says the local host, not the remote, for a local scan', () => { - render( - - ) - expect(screen.getByText(/on the host running the Quick Open scan/i)).toBeTruthy() - expect(screen.queryByText(/on the remote/i)).toBeNull() - }) - - it('says the remote for a relay scan', () => { + // Why only the remote wording: the local side always has Orca's bundled rg, so this guidance + // can only ever describe a remote host that never received the upload. + it('names the remote as the host to install ripgrep on', () => { render( ) expect(screen.getByText(/on the remote to enable fast/i)).toBeTruthy() + expect(screen.queryByText(/on the host running the Quick Open scan/i)).toBeNull() }) }) diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.test.ts b/src/renderer/src/components/quick-open-install-rg-guidance.test.ts index 0429563dad73..3638418eb2c9 100644 --- a/src/renderer/src/components/quick-open-install-rg-guidance.test.ts +++ b/src/renderer/src/components/quick-open-install-rg-guidance.test.ts @@ -2,27 +2,13 @@ import { describe, expect, it } from 'vitest' import { parseQuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance' describe('parseQuickOpenInstallRgGuidance', () => { - it('parses the local message and reports the local location', () => { - expect( - parseQuickOpenInstallRgGuidance( - 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: brew install ripgrep' - ) - ).toEqual({ - reason: 'File listing timed out', - location: 'local', - command: 'brew install ripgrep', - guidance: null - }) - }) - - it('keeps parsing the legacy remote message and reports the remote location', () => { + it('parses the remote message into a copyable command', () => { expect( parseQuickOpenInstallRgGuidance( 'Quick Open scan too large (File listing exceeded 10000 files). Install ripgrep on the remote to enable fast, gitignore-aware listing: sudo apt install ripgrep' ) ).toEqual({ reason: 'File listing exceeded 10000 files', - location: 'remote', command: 'sudo apt install ripgrep', guidance: null }) @@ -31,17 +17,23 @@ describe('parseQuickOpenInstallRgGuidance', () => { it('renders generic install prose through the guidance path', () => { expect( parseQuickOpenInstallRgGuidance( - 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: install ripgrep via your package manager (e.g. apt/dnf/pacman)' + 'Quick Open scan too large (File listing timed out). Install ripgrep on the remote to enable fast, gitignore-aware listing: install ripgrep via your package manager (e.g. apt/dnf/pacman)' ) ).toEqual({ reason: 'File listing timed out', - location: 'local', command: null, guidance: 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' }) }) - it('returns null for regular errors', () => { + // Why: only a remote host can still reach the capped fallback, so the old local wording is + // no longer produced anywhere and falls through to plain-text display. + it('returns null for the retired local wording and for regular errors', () => { + expect( + parseQuickOpenInstallRgGuidance( + 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: brew install ripgrep' + ) + ).toBeNull() expect(parseQuickOpenInstallRgGuidance('git ls-files exited with code 128')).toBeNull() }) }) diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.tsx b/src/renderer/src/components/quick-open-install-rg-guidance.tsx index a8f396792986..1e58e63675af 100644 --- a/src/renderer/src/components/quick-open-install-rg-guidance.tsx +++ b/src/renderer/src/components/quick-open-install-rg-guidance.tsx @@ -5,16 +5,10 @@ import { translate } from '@/i18n/i18n' export type QuickOpenInstallRgGuidanceParts = { reason: string - // Why: the fallback runs on whichever host performs the scan. A local scan - // must not tell the user to install ripgrep "on the remote"; the location - // phrase in the message is the only signal for which wording to render. - location: 'local' | 'remote' command: string | null guidance: string | null } -const REMOTE_LOCATION_PHRASE = 'on the remote' - /** * Parses the install-ripgrep guidance message produced by the relay's * buildInstallRgMessage(). Returns the parts needed to render as formatted @@ -28,22 +22,22 @@ const REMOTE_LOCATION_PHRASE = 'on the remote' export function parseQuickOpenInstallRgGuidance( message: string ): QuickOpenInstallRgGuidanceParts | null { + // Why only the remote wording: the local side always has Orca's bundled rg, so this fallback -- + // and therefore this message -- can only come from a remote host an upload never reached. const match = message.match( - /^Quick Open scan too large \((.+?)\)\. Install ripgrep (on the remote|on the host running the Quick Open scan) to enable fast, gitignore-aware listing: (.+)$/ + /^Quick Open scan too large \((.+?)\)\. Install ripgrep on the remote to enable fast, gitignore-aware listing: (.+)$/ ) if (!match) { return null } const reason = match[1] - const location = match[2] === REMOTE_LOCATION_PHRASE ? 'remote' : 'local' - const tail = match[3].trim() + const tail = match[2].trim() // Why: on unknown distros the relay emits prose like "install ripgrep via // your package manager (e.g. apt/dnf/pacman)"; there is no single command // to copy, so surface it as plain guidance without the code block. const looksLikeCommand = /^(sudo\s+)?(brew|apt|dnf|pacman|apk)\s/.test(tail) return { reason, - location, command: looksLikeCommand ? tail : null, guidance: looksLikeCommand ? null : tail } @@ -51,7 +45,6 @@ export function parseQuickOpenInstallRgGuidance( export function QuickOpenInstallRgGuidance({ reason, - location, command, guidance }: QuickOpenInstallRgGuidanceParts): React.JSX.Element { @@ -121,15 +114,10 @@ export function QuickOpenInstallRgGuidance({ {translate('auto.components.QuickOpen.5d80dc39bb', 'ripgrep')} {' '} - {location === 'remote' - ? translate( - 'auto.components.QuickOpen.1cf8561ab4', - 'on the remote to enable fast, gitignore-aware listing:' - ) - : translate( - 'auto.components.QuickOpen.344f8a48dd', - 'on the host running the Quick Open scan to enable fast, gitignore-aware listing:' - )} + {translate( + 'auto.components.QuickOpen.1cf8561ab4', + 'on the remote to enable fast, gitignore-aware listing:' + )}

{command ? (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8d0cdc2c4d8c..b9588f576982 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1946,8 +1946,7 @@ "4725b0e931": "Quick Open scan too large (", "b227d88520": "{{value0}} files found", "995be8ea22": "Copy", - "cf144856dc": "Copied", - "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:" + "cf144856dc": "Copied" }, "SelectedTextCopyMenu": { "9b40d7b018": "Copy" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e01046f91ae0..3cfa6967d4f1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1595,8 +1595,7 @@ "4725b0e931": "El escaneo de apertura rápida es demasiado grande (", "b227d88520": "{{value0}} archivos encontrados", "995be8ea22": "Copiar", - "cf144856dc": "Copiado", - "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:" + "cf144856dc": "Copiado" }, "SelectedTextCopyMenu": { "9b40d7b018": "Copiar" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 990eab6a9b45..b46e1b7fb15f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1929,8 +1929,7 @@ "4725b0e931": "Scan Quick Open trop volumineux (", "b227d88520": "{{value0}} fichiers trouvés", "995be8ea22": "Copier", - "cf144856dc": "Copied", - "344f8a48dd": "sur l'hôte exécutant le scan Quick Open pour activer un listage rapide respectant .gitignore :" + "cf144856dc": "Copied" }, "SelectedTextCopyMenu": { "9b40d7b018": "Copier" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8bd43f4977a3..4cadaaf33a62 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1826,8 +1826,7 @@ "4725b0e931": "クイックオープンスキャンが大きすぎます (", "b227d88520": "{{value0}} ファイルが見つかりました", "995be8ea22": "コピー", - "cf144856dc": "コピーしました", - "344f8a48dd": "をクイックオープンのスキャンを実行するホストで実行すると、gitignore を考慮した高速な一覧表示が有効になります:" + "cf144856dc": "コピーしました" }, "SelectedTextCopyMenu": { "9b40d7b018": "コピー" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5d07bdb1f542..e6383c3bce53 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1826,8 +1826,7 @@ "4725b0e931": "Quick Open 스캔이 너무 큼(", "b227d88520": "{{value0}} 파일을 찾았습니다.", "995be8ea22": "복사", - "cf144856dc": "복사됨", - "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:" + "cf144856dc": "복사됨" }, "SelectedTextCopyMenu": { "9b40d7b018": "복사" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3ecee57bdd18..ef05db213226 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1826,8 +1826,7 @@ "4725b0e931": "快速打开扫描太大(", "b227d88520": "找到 {{value0}} 文件", "995be8ea22": "复制", - "cf144856dc": "已复制", - "344f8a48dd": "在运行 Quick Open 扫描的主机上启用快速、遵循 gitignore 的文件列表:" + "cf144856dc": "已复制" }, "SelectedTextCopyMenu": { "9b40d7b018": "复制" diff --git a/src/shared/bundled-ripgrep.test.ts b/src/shared/bundled-ripgrep.test.ts new file mode 100644 index 000000000000..6fd64be3ad81 --- /dev/null +++ b/src/shared/bundled-ripgrep.test.ts @@ -0,0 +1,28 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { + BUNDLED_RIPGREP_PACKAGE_BIN_DIR, + BUNDLED_RIPGREP_PLATFORMS, + BUNDLED_RIPGREP_RESOURCE_DIR, + bundledRipgrepBinaryName +} from './bundled-ripgrep' + +const requireFromRoot = createRequire(`${process.cwd()}/`) + +describe('bundled ripgrep platforms', () => { + it('packages exactly the layout the runtime resolves', () => { + const packaging: { + BUNDLED_RIPGREP_PLATFORMS: string[] + RIPGREP_PACKAGE_BIN_DIR: string + RIPGREP_RESOURCE_DIR: string + ripgrepBinaryName: (platform: string) => string + } = requireFromRoot('./config/bundled-ripgrep-resources.cjs') + + expect(packaging.BUNDLED_RIPGREP_PLATFORMS).toEqual([...BUNDLED_RIPGREP_PLATFORMS]) + expect(packaging.RIPGREP_PACKAGE_BIN_DIR).toBe(BUNDLED_RIPGREP_PACKAGE_BIN_DIR) + expect(packaging.RIPGREP_RESOURCE_DIR).toBe(BUNDLED_RIPGREP_RESOURCE_DIR) + for (const platform of BUNDLED_RIPGREP_PLATFORMS) { + expect(packaging.ripgrepBinaryName(platform)).toBe(bundledRipgrepBinaryName(platform)) + } + }) +}) diff --git a/src/shared/bundled-ripgrep.ts b/src/shared/bundled-ripgrep.ts new file mode 100644 index 000000000000..b6025ef77fa9 --- /dev/null +++ b/src/shared/bundled-ripgrep.ts @@ -0,0 +1,25 @@ +import { RELAY_BUILD_PLATFORMS, type RelayBuildPlatform } from './relay-artifacts' + +/** + * Orca ships its own ripgrep for every relay platform (from @vscode/ripgrep-universal), so local, + * WSL, and SSH searches never depend on the user having installed `rg`. + */ +export const BUNDLED_RIPGREP_PLATFORMS: readonly RelayBuildPlatform[] = RELAY_BUILD_PLATFORMS +export type BundledRipgrepPlatform = RelayBuildPlatform +/** Directory under the packaged resources root holding `/rg[.exe]`. */ +export const BUNDLED_RIPGREP_RESOURCE_DIR = 'ripgrep' +/** Package directory holding the same layout in development checkouts. */ +export const BUNDLED_RIPGREP_PACKAGE_BIN_DIR = 'node_modules/@vscode/ripgrep-universal/bin' + +/** Accepts any `-` label, so an unbundled platform can still name where rg would live. */ +export function bundledRipgrepBinaryName(platform: string): string { + return platform.startsWith('win32-') ? 'rg.exe' : 'rg' +} + +export function toBundledRipgrepPlatform( + platform: string, + arch: string +): BundledRipgrepPlatform | null { + const candidate = `${platform}-${arch}` + return BUNDLED_RIPGREP_PLATFORMS.find((entry) => entry === candidate) ?? null +} diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 4c50cc684287..2dd034cbe5f0 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -91,7 +91,6 @@ src/main/github/gh-utils.ts src/main/hooks.ts src/main/ipc/app.ts src/main/ipc/developer-permissions.ts -src/main/ipc/filesystem-list-files-git-fallback.ts src/main/ipc/filesystem-list-files.ts src/main/ipc/filesystem-watcher-wsl.ts src/main/ipc/filesystem/filesystem-handler-context.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index a59e3be4d7cd..828a01350bbe 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -54,7 +54,6 @@ main/zsh-startup-hook-pty-harness.ts relay/agent-exec-handler.ts relay/external-automations-handler.ts relay/fs-handler-git-fallback.ts -relay/fs-handler-utils.ts relay/fs-list-files-fallback-chain.ts relay/git-handler.ts relay/pty-shell-utils.ts @@ -63,5 +62,4 @@ relay/workspace-space-scan.ts shared/fish-binary-requirement.ts shared/process-table-snapshot-reader.ts shared/pty-slave-line-discipline-echo.ts -shared/ripgrep-process-availability.ts shared/shell-process-readiness.ts diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 95a314000685..2ae150cc8be7 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( * May only ever be DECREASED, and only by migrating a file off * `node:child_process`. Raising it is never the fix. */ -const DIRECT_IMPORTER_PIN = 153 +const DIRECT_IMPORTER_PIN = 152 const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index a15f98b288b0..8331f18892a2 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 64 +const UNHIDDEN_SPAWNER_PIN = 62 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts new file mode 100644 index 000000000000..61d57f385516 --- /dev/null +++ b/src/shared/orcad-artifacts.test.ts @@ -0,0 +1,29 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { BUNDLED_RIPGREP_PLATFORMS, bundledRipgrepBinaryName } from './bundled-ripgrep' +import { + ORCAD_RIPGREP_ARTIFACTS, + ORCAD_RIPGREP_LICENSE_ARTIFACTS, + orcadArtifactFilenames +} from './orcad-artifacts' + +describe('standalone runtime artifacts', () => { + it('ships search binaries for every SSH host and includes them in the install identity', () => { + const expected = BUNDLED_RIPGREP_PLATFORMS.map( + (platform) => `ripgrep/${platform}/${bundledRipgrepBinaryName(platform)}` + ) + expect(ORCAD_RIPGREP_ARTIFACTS).toEqual(expected) + expect(orcadArtifactFilenames()).toEqual(expect.arrayContaining(expected)) + }) + + it('ships the binary redistribution notices with every install', () => { + const sourceDir = join(__dirname, '../../resources/licenses/ripgrep') + expect(ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((path) => path.split('/').at(-1)).sort()).toEqual( + readdirSync(sourceDir).sort() + ) + expect(orcadArtifactFilenames()).toEqual( + expect.arrayContaining([...ORCAD_RIPGREP_LICENSE_ARTIFACTS]) + ) + }) +}) diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 259b9d17eda2..9a2598caefbf 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -11,6 +11,28 @@ export const ORCAD_VERSION = '0.1.0' +// Kept here because build-orcad.mjs imports this manifest directly under Node type stripping. +export const ORCAD_RIPGREP_ARTIFACTS = [ + 'ripgrep/linux-x64/rg', + 'ripgrep/linux-arm64/rg', + 'ripgrep/darwin-x64/rg', + 'ripgrep/darwin-arm64/rg', + 'ripgrep/win32-x64/rg.exe', + 'ripgrep/win32-arm64/rg.exe' +] as const + +export const ORCAD_RIPGREP_LICENSE_ARTIFACTS = [ + 'ripgrep/licenses/JEMALLOC-COPYING', + 'ripgrep/licenses/LICENSE-MIT', + 'ripgrep/licenses/LLVM-LIBUNWIND-LICENSE.TXT', + 'ripgrep/licenses/MUSL-COPYRIGHT', + 'ripgrep/licenses/PCRE2-LICENCE.md', + 'ripgrep/licenses/README.md', + 'ripgrep/licenses/RUST-CRATE-NOTICES.txt', + 'ripgrep/licenses/SLJIT-LICENSE', + 'ripgrep/licenses/UNLICENSE' +] as const + export type OrcadArtifact = { filename: string /** @@ -26,7 +48,9 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ // Forked so a native @parcel/watcher fault kills the child, not the server. { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. - { filename: 'daemon-entry.js' } + { filename: 'daemon-entry.js' }, + ...ORCAD_RIPGREP_ARTIFACTS.map((filename) => ({ filename })), + ...ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((filename) => ({ filename })) ] /** Written after the artifacts, so it is never an input to its own hash. */ diff --git a/src/relay/fs-handler-install-rg.test.ts b/src/shared/quick-open-install-rg.test.ts similarity index 95% rename from src/relay/fs-handler-install-rg.test.ts rename to src/shared/quick-open-install-rg.test.ts index edc2424c6455..f6a0a39e5f62 100644 --- a/src/relay/fs-handler-install-rg.test.ts +++ b/src/shared/quick-open-install-rg.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { detectLinuxInstallCommandFromOsRelease } from './fs-handler-install-rg' +import { detectLinuxInstallCommandFromOsRelease } from './quick-open-install-rg' describe('detectLinuxInstallCommandFromOsRelease', () => { it('uses apt for Debian and Ubuntu families', () => { diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts index 9bd371148232..ba4215303703 100644 --- a/src/shared/quick-open-install-rg.ts +++ b/src/shared/quick-open-install-rg.ts @@ -75,23 +75,16 @@ function readOsReleaseValue(rawValue: string): string { : trimmed } -export async function buildInstallRgMessage( - cause: unknown, - host: 'local' | 'remote' = 'local' -): Promise { +// Why remote-only: the local side always has Orca's bundled rg, so only a remote host an upload +// never reached can still fall back to the capped git/readdir listing these messages explain. +export async function buildInstallRgMessage(cause: unknown): Promise { const reason = cause instanceof Error ? cause.message : String(cause) - const cmd = await detectInstallCommand() - const location = host === 'local' ? 'on the host running the Quick Open scan' : 'on the remote' return ( `Quick Open scan too large (${reason}). ` + - `Install ripgrep ${location} to enable fast, gitignore-aware listing: ${cmd}` + `Install ripgrep on the remote to enable fast, gitignore-aware listing: ${await detectInstallCommand()}` ) } -export async function buildRipgrepRequiredMessage( - host: 'local' | 'remote' = 'local' -): Promise { - const cmd = await detectInstallCommand() - const location = host === 'local' ? 'on the host running Quick Open' : 'on the remote' - return `Quick Open search requires ripgrep ${location} to stay resource-bounded. Install it with: ${cmd}` +export async function buildRipgrepRequiredMessage(): Promise { + return `Quick Open search requires ripgrep on the remote to stay resource-bounded. Install it with: ${await detectInstallCommand()}` } diff --git a/src/shared/ripgrep-process-availability.ts b/src/shared/ripgrep-process-availability.ts index a7424f42f73d..4c39916aed8b 100644 --- a/src/shared/ripgrep-process-availability.ts +++ b/src/shared/ripgrep-process-availability.ts @@ -77,57 +77,105 @@ export async function isRipgrepSpawnCwdUsable(cwd: string): Promise { } } -function checkRipgrepAvailableWithoutCwd(): Promise { +function probeRipgrepVersion(command: string, env: NodeJS.ProcessEnv): Promise { return new Promise((resolve) => { let child: ChildProcess try { - child = spawn('rg', ['--version'], { stdio: 'ignore' }) + // windowsHide: a probe must never flash a console window on Windows. + child = spawn(command, ['--version'], { env, stdio: 'ignore', windowsHide: true }) } catch { resolve(false) return } let settled = false - let errorObserved = false - let unavailableExitObserved = false - let timeout: ReturnType | null = null + // Why kill on timeout: a `rg --version` that hangs -- a stalled network mount, or antivirus + // holding a just-installed rg.exe -- would otherwise leave a live process and a ref'd handle + // behind for the relay's lifetime, once per launch failure. const settle = (available: boolean, kill = false): void => { if (settled) { return } settled = true - if (timeout) { - clearTimeout(timeout) - } - child.off('error', onError) + clearTimeout(timeout) child.off('close', onClose) if (kill) { - child.once('error', ignoreRipgrepSpawnError) killSpawnedRipgrepProcess(child) - } else { - absorbPendingRipgrepSpawnError(child, { errorObserved, unavailableExitObserved }) } + // Why leave one 'error' listener attached: a spawn error can still arrive after this + // settles, and an unhandled 'error' on a ChildProcess throws. + child.once('error', ignoreRipgrepSpawnError) resolve(available) } - const onError = (): void => { - errorObserved = true - settle(false) - } - const onClose = (code: number | null): void => { - unavailableExitObserved = code !== null && code < 0 - settle(code === 0) - } - child.once('error', onError) + const onClose = (code: number | null): void => settle(code === 0) + child.once('error', () => settle(false)) child.once('close', onClose) - timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS) + const timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS) timeout.unref?.() }) } -export async function isRipgrepUnavailableAfterLaunchFailure(cwd: string): Promise { +/** + * Why a launch failure needs classifying: spawn reports an unreachable cwd as ENOENT, the same as + * a missing binary. Telling a user to install ripgrep because their workspace moved sends them + * down the wrong path, and resolving an empty result hides the move entirely. + * + * `candidates` are the ripgreps worth asking about, in order -- the command that actually failed + * first, then the host's PATH one. Probing only PATH would misclassify the normal remote setup, + * where Orca uploaded a bundled binary precisely because the host has no `rg` of its own: the + * probe would fail and a moved workspace would be reported as a missing ripgrep. Nulls are + * skipped, and a host with no working ripgrep at all keeps 'ripgrep-unavailable', because only + * that verdict engages the git/readdir fallback chain. + */ +export async function classifyRipgrepLaunchFailure( + cwd: string, + candidates: readonly (string | null)[], + env: NodeJS.ProcessEnv +): Promise<'cwd-unreachable' | 'ripgrep-unavailable'> { if (await isRipgrepSpawnCwdUsable(cwd)) { - return true + return 'ripgrep-unavailable' + } + for (const command of new Set(candidates.filter((entry) => entry !== null))) { + if (await probeRipgrepVersion(command, env)) { + return 'cwd-unreachable' + } + } + return 'ripgrep-unavailable' +} + +/** + * Exit code the WSL wrapper uses when it cannot enter the search root. Why a dedicated code: + * ripgrep exits 1 for "no matches", so without this an unreachable workspace would report an + * empty listing as a successful scan. Picked above ripgrep's own 0/1/2 and clear of the shell's + * 126/127 and 128+signal range. + */ +export const RIPGREP_MISSING_CWD_EXIT_CODE = 97 + +export function isRipgrepMissingCwdExit(code: number | null): boolean { + return code === RIPGREP_MISSING_CWD_EXIT_CODE +} + +export function ripgrepMissingCwdError(cwd: string): Error { + return new Error(`Search root is not reachable: ${cwd}`) +} + +// ENOTDIR and some resource failures throw before a ChildProcess can emit an error. +export async function classifySynchronousRipgrepSpawnFailure( + error: unknown, + cwd: string +): Promise { + // Why pass an already-classified error straight through: this only diagnoses raw spawn errnos. + // A caller that threw a verdict of its own has more context than a cwd probe does. + if (error instanceof RipgrepUnavailableError || error instanceof RipgrepLaunchFailureError) { + return error + } + if (isTransientRipgrepSpawnError(error)) { + const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown' + return new RipgrepLaunchFailureError(`rg failed to start (${code})`) + } + if (!(await isRipgrepSpawnCwdUsable(cwd))) { + return ripgrepMissingCwdError(cwd) } - return !(await checkRipgrepAvailableWithoutCwd()) + return error instanceof Error ? error : new Error(String(error)) } export function isRipgrepUnavailableExit(