diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 89b79cddca7..99a53fa0981 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,18 +81,23 @@ copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT) if (process.platform !== 'win32') { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } -// Why: orcad has no Electron resourcesPath; the rg resolver looks under its install root instead. -const RIPGREP_PLATFORM = `${platform()}-${arch()}` -const RIPGREP_NAME = process.platform === 'win32' ? 'rg.exe' : 'rg' -const RIPGREP_OUTPUT_DIR = join(OUT_DIR, 'ripgrep', RIPGREP_PLATFORM) -mkdirSync(RIPGREP_OUTPUT_DIR, { recursive: true }) -copyFileSync( - join(ROOT, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', RIPGREP_PLATFORM, RIPGREP_NAME), - join(RIPGREP_OUTPUT_DIR, RIPGREP_NAME) -) -if (process.platform !== 'win32') { - chmodSync(join(RIPGREP_OUTPUT_DIR, RIPGREP_NAME), 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 @@ -258,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/orcad-artifact-version.mjs b/config/scripts/orcad-artifact-version.mjs new file mode 100644 index 00000000000..ef85540bbaa --- /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 00000000000..a6d316b7f41 --- /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/resources/licenses/ripgrep/README.md b/resources/licenses/ripgrep/README.md index 8f061f8c55b..d0375cac0fc 100644 --- a/resources/licenses/ripgrep/README.md +++ b/resources/licenses/ripgrep/README.md @@ -1,7 +1,8 @@ # Bundled ripgrep notices Orca ships prebuilt ripgrep (`rg`) binaries from `@vscode/ripgrep-universal` under -`Resources/ripgrep/`, for local, WSL, and SSH-remote search. +`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`). 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 00000000000..b24f5ff5588 --- /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/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 6cab2dd0034..1d9f87b009d 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 c6803d4ef6b..88a4d923c84 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' @@ -125,15 +126,15 @@ async function installOrcadBundle( ) { return } - // Why this needs ripgrep work before it goes live: `build-orcad.mjs` copies only the BUILD - // host's rg into `out/orcad/ripgrep//`, and this upload carries that directory - // verbatim. orcad reports isPackaged() === true, so its rg resolver takes the packaged branch - // and returns an absolute path under its own install root with no PATH fallback -- on a remote - // of a different platform that path does not exist and every search fails. Call - // `ensureRemoteBundledRipgrep` here (as the relay deploy does), or ship all six platforms. 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/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts index 5a80df8ff4f..a12b2e8342f 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-ripgrep-install.test.ts b/src/main/ssh/ssh-relay-ripgrep-install.test.ts index d143ab01a35..ddf144cd09f 100644 --- a/src/main/ssh/ssh-relay-ripgrep-install.test.ts +++ b/src/main/ssh/ssh-relay-ripgrep-install.test.ts @@ -79,27 +79,14 @@ describe('ensureRemoteBundledRipgrep', () => { ) }) - // Why this needs a test: nothing else collects this tree -- the relay's version GC only matches - // `relay-*` -- so without it every rg bump left another ~5 MB on every host, forever. - it('collects superseded builds while sparing the current one and live stages', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me') - - const script = execScripts()[0] - expect(script).toContain("! -name 'c0ffee0123456789-linux-x64'") - expect(script).toContain("! -name '.upload-*'") - expect(script).toContain('-mtime +14') - }) - - it('collects superseded builds on Windows hosts too', async () => { + 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).toContain("-ne 'c0ffee0123456789-win32-x64'") - expect(script).toContain('AddDays(-14)') + 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 () => { @@ -275,6 +262,19 @@ describe.runIf(process.platform !== 'win32').each(SHELLS)( 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 }) diff --git a/src/main/ssh/ssh-relay-ripgrep-install.ts b/src/main/ssh/ssh-relay-ripgrep-install.ts index e0ed740afa0..27409b601e8 100644 --- a/src/main/ssh/ssh-relay-ripgrep-install.ts +++ b/src/main/ssh/ssh-relay-ripgrep-install.ts @@ -47,10 +47,6 @@ export const REMOTE_RIPGREP_CACHE_DIR_NAME = 'ripgrep' 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 -// Why two weeks and not immediately: a client pinned to an older Orca may still be running against -// the build it uploaded. The only cost of collecting one too early is that client re-uploading -// ~5 MB on its next connect; the cost of never collecting is a permanent leak on every host. -const SUPERSEDED_RIPGREP_DAYS = 14 const PRESENT = 'ORCA-RG-PRESENT' const STAGED = 'ORCA-RG-STAGED' const INSTALLED = 'ORCA-RG-INSTALLED' @@ -196,19 +192,16 @@ export function probeOrStageCommand( 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` - // Why here and not in the relay's version GC: that GC only matches `relay-*`, so nothing has - // ever collected this tree. Every rg bump would otherwise leave another ~5 MB per host forever. - const collect = `find ${cache} -mindepth 1 -maxdepth 1 -type d ! -name ${shellEscape(layout.entryName)} ! -name '${UPLOAD_STAGE_PREFIX}*' -mtime +${SUPERSEDED_RIPGREP_DAYS} -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}; ${collect}; if ${posixInstalledTest(bin, bytes)}; then echo ${PRESENT}; else ${stage} && echo ${STAGED}; fi` + 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`, - `Get-ChildItem -LiteralPath ${powerShellLiteral(layout.cacheDir)} -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ${powerShellLiteral(layout.entryName)} -and -not $_.Name.StartsWith('${UPLOAD_STAGE_PREFIX}') -and $_.LastWriteTime -lt (Get-Date).AddDays(-${SUPERSEDED_RIPGREP_DAYS}) } | 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}' }` diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index a29980bce97..6257b685e79 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -103,23 +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(command, ['--no-messages', ...args], { cwd: rootPath, - env: buildRelayCommandEnv(), + env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) @@ -179,19 +175,18 @@ export function listFilesWithRg( // 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()])) === - 'cwd-unreachable' + (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 diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index d2454aa7372..1bc43923a87 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -126,11 +126,12 @@ export function searchWithRg( } // 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(command, rgArgs, { cwd: rootPath, - env: buildRelayCommandEnv(), + env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) @@ -185,8 +186,11 @@ export function searchWithRg( // ripgrep. The workspace moving would otherwise look like a successful empty scan. if (settle()) { reject( - (await classifyRipgrepLaunchFailure(rootPath, [command, pathRipgrepCommand()])) === - 'cwd-unreachable' + (await classifyRipgrepLaunchFailure( + rootPath, + [command, pathRipgrepCommand()], + env + )) === 'cwd-unreachable' ? ripgrepMissingCwdError(rootPath) : new RipgrepUnavailableError() ) 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 00000000000..38734ca63e6 --- /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/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts new file mode 100644 index 00000000000..61d57f38551 --- /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 259b9d17eda..d5c291a464b 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -11,6 +11,26 @@ 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/UNLICENSE' +] as const + export type OrcadArtifact = { filename: string /** @@ -26,7 +46,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/shared/ripgrep-process-availability.ts b/src/shared/ripgrep-process-availability.ts index 9e58e6de6b9..6a8ea6e0a2f 100644 --- a/src/shared/ripgrep-process-availability.ts +++ b/src/shared/ripgrep-process-availability.ts @@ -77,12 +77,12 @@ export async function isRipgrepSpawnCwdUsable(cwd: string): Promise { } } -function probeRipgrepVersion(command: string): Promise { +function probeRipgrepVersion(command: string, env: NodeJS.ProcessEnv): Promise { return new Promise((resolve) => { let child: ChildProcess try { // windowsHide: a probe must never flash a console window on Windows. - child = spawn(command, ['--version'], { stdio: 'ignore', windowsHide: true }) + child = spawn(command, ['--version'], { env, stdio: 'ignore', windowsHide: true }) } catch { resolve(false) return @@ -128,13 +128,14 @@ function probeRipgrepVersion(command: string): Promise { */ export async function classifyRipgrepLaunchFailure( cwd: string, - candidates: readonly (string | null)[] + candidates: readonly (string | null)[], + env: NodeJS.ProcessEnv ): Promise<'cwd-unreachable' | 'ripgrep-unavailable'> { if (await isRipgrepSpawnCwdUsable(cwd)) { return 'ripgrep-unavailable' } for (const command of new Set(candidates.filter((entry) => entry !== null))) { - if (await probeRipgrepVersion(command)) { + if (await probeRipgrepVersion(command, env)) { return 'cwd-unreachable' } }