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(