mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
fix(search): preserve remote binaries and complete runtime packaging
This commit is contained in:
@@ -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.`
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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`).
|
||||
|
||||
@@ -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<string> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<platform>/`, 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}' }`
|
||||
|
||||
@@ -103,23 +103,19 @@ export function listFilesWithRg(
|
||||
let processErrorObserved = false
|
||||
let unavailableExitObserved = false
|
||||
let launchFailureCheck: Promise<void> | 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
|
||||
|
||||
@@ -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<typeof spawn>
|
||||
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()
|
||||
)
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
@@ -77,12 +77,12 @@ export async function isRipgrepSpawnCwdUsable(cwd: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
function probeRipgrepVersion(command: string): Promise<boolean> {
|
||||
function probeRipgrepVersion(command: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
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<boolean> {
|
||||
*/
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user