perf(build): skip unnecessary work and reclaim Vite caches (#29479)

* perf(build): remove unnecessary development and config work

* feat(dev): reclaim inactive worktree Vite caches

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-16 00:56:30 -07:00
committed by GitHub
co-authored by OrcaWin
parent 98457f6334
commit b3dfd54827
19 changed files with 682 additions and 97 deletions
+2
View File
@@ -67,6 +67,8 @@ desktop.ini
# Lint/cache
.oxlintcache
/.cache/vite/
/.cache/vite.lock/
# Logs
*.log
@@ -3,15 +3,18 @@ import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { runProcessSync } from '@orca/process-host'
import ts from 'typescript-api'
import type ts from 'typescript-api'
import type { Plugin } from 'rolldown'
import { createWorkspaceSourceResolver } from '../scripts/workspace-source-exports.mjs'
function configError(diagnostic: ts.Diagnostic): Error {
return new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
function configError(diagnostic: ts.Diagnostic, compiler: typeof ts): Error {
return new Error(compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
}
function processHostInputs(root: string): {
function processHostInputs(
root: string,
compiler: typeof ts
): {
directory: string
files: string[]
directories: ts.MapLike<ts.WatchDirectoryFlags>
@@ -23,14 +26,16 @@ function processHostInputs(root: string): {
const manifest = resolve(root, source.manifest)
const directory = dirname(manifest)
const config = join(directory, 'tsconfig.json')
const parsed = ts.getParsedCommandLineOfConfigFile(config, undefined, {
...ts.sys,
const parsed = compiler.getParsedCommandLineOfConfigFile(config, undefined, {
...compiler.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw configError(diagnostic)
throw configError(diagnostic, compiler)
}
})
if (!parsed || parsed.errors.length > 0) {
throw parsed?.errors[0] ? configError(parsed.errors[0]) : new Error(`Cannot read ${config}`)
throw parsed?.errors[0]
? configError(parsed.errors[0], compiler)
: new Error(`Cannot read ${config}`)
}
const configSource = parsed.options.configFile
const inheritedConfigs =
@@ -38,7 +43,7 @@ function processHostInputs(root: string): {
? configSource.extendedSourceFiles
: []
// Follow explicit imports through exclusions without loading automatic type libraries.
const sources = ts
const sources = compiler
.createProgram(parsed.fileNames, { ...parsed.options, noLib: true, types: [] })
.getSourceFiles()
.map((source) => source.fileName)
@@ -65,11 +70,13 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
packageDirty = true
}
},
buildStart() {
async buildStart() {
if (!this.meta.watchMode) {
return
}
const inputs = packageDirty || !cachedInputs ? processHostInputs(root) : cachedInputs
const { default: compiler } = await import('typescript-api')
const inputs =
packageDirty || !cachedInputs ? processHostInputs(root, compiler) : cachedInputs
cachedInputs = inputs
packageFiles = new Set(inputs.files.map((file) => resolve(file)))
for (const file of inputs.files) {
@@ -91,16 +98,16 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
}
}
for (const [directory, flags] of Object.entries(inputs.directories)) {
const recursive = flags === ts.WatchDirectoryFlags.Recursive
const recursive = flags === compiler.WatchDirectoryFlags.Recursive
const previous = directoryWatchers.get(directory)
if (previous?.recursive === recursive) {
continue
}
previous?.watcher.close()
if (!ts.sys.watchDirectory) {
if (!compiler.sys.watchDirectory) {
throw new Error('The process-host source directory watcher is unavailable')
}
const watcher = ts.sys.watchDirectory(
const watcher = compiler.sys.watchDirectory(
directory,
(changedPath) => {
if (
@@ -110,7 +117,7 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
!packageFiles.has(resolve(changedPath))
) {
try {
const next = processHostInputs(root)
const next = processHostInputs(root, compiler)
const files = new Set(next.files.map((file) => resolve(file)))
if (
files.size === packageFiles.size &&
@@ -128,7 +135,7 @@ export function createProcessHostDevRebuildPlugin(root = process.cwd()): Plugin
}
},
recursive,
{ watchDirectory: ts.WatchDirectoryKind.UseFsEvents }
{ watchDirectory: compiler.WatchDirectoryKind.UseFsEvents }
)
directoryWatchers.set(directory, { recursive, watcher })
}
@@ -0,0 +1,22 @@
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
import { retainViteCache } from '../scripts/vite-cache-activity.mjs'
export function createViteCacheRetentionPlugin(cacheRoot = resolve('.cache/vite')): Plugin {
return {
name: 'orca-vite-cache-retention',
apply: 'serve',
async configureServer(server) {
const release = await retainViteCache(cacheRoot)
const close = server.close.bind(server)
// Keep ownership until Vite has stopped its optimizer and outstanding requests.
server.close = async (...args) => {
try {
await close(...args)
} finally {
await release()
}
}
}
}
}
+2
View File
@@ -261,6 +261,8 @@ module.exports = {
'!config{,/**/*}',
// Release archives and other build staging are not runtime resources.
'!.build{,/**/*}',
// Worktree-local Vite dependency caches are development inputs.
'!.cache/vite{,.lock}{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
@@ -165,7 +165,7 @@ describe('electron-builder config', () => {
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
it('keeps cached dev Electron bundles out of app.asar', () => {
it('keeps cached dev Electron bundles and Vite dependencies out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
@@ -173,7 +173,10 @@ describe('electron-builder config', () => {
for (const devBundlePath of [
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json',
'.cache/vite/renderer/deps/react.js',
'.cache/vite/web/deps/react.js',
'.cache/vite.lock/owner'
]) {
expect(packs(devBundlePath)).toBe(false)
}
@@ -0,0 +1,109 @@
import { resolve } from 'node:path'
import { resolveConfig as resolveElectronConfig } from 'electron-vite'
import { resolveConfig } from 'vite'
import { runProcess } from '@orca/process-host'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
beforeEach(() => {
vi.stubEnv('ORCA_UNMINIFIED_MAIN', '')
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', '')
vi.stubEnv('NODE_ENV', process.env.NODE_ENV)
})
afterEach(() => {
vi.unstubAllEnvs()
})
async function resolveMainConfig(command: 'build' | 'serve', watch = false) {
const resolved = await resolveElectronConfig(
{ logLevel: 'silent', ...(watch ? { build: { watch: {} } } : {}) },
command,
command === 'serve' ? 'development' : 'production'
)
if (!resolved.config?.main) {
throw new Error('Electron Vite did not resolve the main-process configuration')
}
// Electron Vite runs main through Vite's build command even in development.
return resolveConfig({ ...resolved.config.main, configFile: false }, 'build')
}
describe('Electron Vite development compilation', () => {
it('keeps the watch-only TypeScript API out of production config loading', async () => {
const result = await runProcess({
program: process.execPath,
args: [
'--input-type=module',
'-e',
`import { createRequire } from 'node:module';
import { resolveConfig } from 'electron-vite';
await resolveConfig({ logLevel: 'silent' }, 'build');
const require = createRequire(import.meta.url);
console.log(JSON.stringify({ loaded: Object.hasOwn(require.cache, require.resolve('typescript-api')) }));`
],
cwd: process.cwd()
})
expect(result.code, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual({ loaded: false })
})
it.each([false, true])('skips main minification in development (watch=%s)', async (watch) => {
const resolved = await resolveMainConfig('serve', watch)
expect(resolved.build.minify).toBe(false)
expect(resolved.build.sourcemap).toBe('hidden')
expect(resolved.build.reportCompressedSize).toBe(false)
expect(Boolean(resolved.build.watch)).toBe(watch)
})
it('keeps production minification and crash source maps', async () => {
const resolved = await resolveMainConfig('build')
expect(resolved.build.minify).toBe('oxc')
expect(resolved.build.sourcemap).toBe('hidden')
expect(resolved.build.reportCompressedSize).toBe(false)
})
it('honors the unminified main diagnostic override in production', async () => {
vi.stubEnv('ORCA_UNMINIFIED_MAIN', '1')
const resolved = await resolveMainConfig('build')
expect(resolved.build.minify).toBe(false)
expect(resolved.build.sourcemap).toBe('hidden')
})
it('honors the development source-map override alongside unminified compilation', async () => {
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', '0')
const development = await resolveMainConfig('serve')
const production = await resolveMainConfig('build')
expect(development.build.minify).toBe(false)
expect(development.build.sourcemap).toBe(false)
expect(production.build.minify).toBe('oxc')
expect(production.build.sourcemap).toBe('hidden')
})
it('keeps renderer dependency caches in this worktree and skips gzip reporting', async () => {
const { config } = await resolveElectronConfig({ logLevel: 'silent' }, 'serve')
if (!config?.renderer) {
throw new Error('Electron Vite did not resolve the renderer configuration')
}
const resolved = await resolveConfig({ ...config.renderer, configFile: false }, 'serve')
expect(resolved.cacheDir).toBe(resolve('.cache/vite/renderer').replaceAll('\\', '/'))
expect(resolved.build.reportCompressedSize).toBe(false)
})
it('gives the web client its own worktree cache and skips gzip reporting', async () => {
const resolved = await resolveConfig(
{ configFile: resolve('vite.web.config.ts'), logLevel: 'silent' },
'serve'
)
expect(resolved.cacheDir).toBe(resolve('.cache/vite/web').replaceAll('\\', '/'))
expect(resolved.build.reportCompressedSize).toBe(false)
})
})
@@ -110,13 +110,13 @@ describe('Electron Vite output contract', () => {
it('can skip main source maps during development without changing build output settings', () => {
try {
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', undefined)
expect(configureElectronVite({ command: 'serve', mode: 'development' })).toBe(
electronViteConfig
)
const defaults = configureElectronVite({ command: 'serve', mode: 'development' })
expect(defaults.main?.build?.sourcemap).toBe('hidden')
expect(defaults.main?.build?.minify).toBe(false)
vi.stubEnv('ORCA_DEV_MAIN_SOURCEMAPS', '0')
const development = configureElectronVite({ command: 'serve', mode: 'development' })
expect(development.main?.build?.sourcemap).toBe(false)
expect(development.main?.build?.minify).toBe(electronViteConfig.main?.build?.minify)
expect(development.main?.build?.minify).toBe(false)
expect(development.main?.build?.rollupOptions).toBe(
electronViteConfig.main?.build?.rollupOptions
)
@@ -0,0 +1,56 @@
import { realpath } from 'node:fs/promises'
import { resolve } from 'node:path'
import { runProcess } from '@orca/process-host'
import { decodeGitCQuotedPath } from '../../src/shared/git-cquoted-path.ts'
async function git(repoRoot, args) {
const result = await runProcess({
program: 'git',
args,
cwd: repoRoot,
timeoutMs: 30_000,
maxOutputBytes: 4 * 1024 * 1024,
terminationBarrier: true
})
if (result.code !== 0 || result.timedOut) {
throw new Error('Could not read the local Git repository')
}
return result.stdout
}
export async function localRepositoryWorktrees(repoRoot) {
const common = (await git(repoRoot, ['rev-parse', '--git-common-dir'])).replace(
process.platform === 'win32' ? /\r?\n$/ : /\n$/,
''
)
if (!common || common.includes('\n')) {
throw new Error('Unrecognised Git common directory')
}
const output = await git(repoRoot, [
'-c',
'core.quotePath=false',
'worktree',
'list',
'--porcelain'
])
const worktrees = []
for (const block of output.split(/\r?\n\r?\n/)) {
const lines = block.split(/\r?\n/).filter(Boolean)
if (lines.length === 0) {
continue
}
if (
lines.some(
(line) =>
!/^(worktree |HEAD |branch |bare$|detached$|locked(?: |$)|prunable(?: |$))/.test(line)
)
) {
throw new Error('Ambiguous worktree listing; refusing cleanup')
}
const field = lines.find((line) => line.startsWith('worktree '))
if (field && !lines.includes('bare')) {
worktrees.push(decodeGitCQuotedPath(field.slice(9)))
}
}
return { commonDirectory: await realpath(resolve(repoRoot, common)), worktrees }
}
@@ -344,7 +344,7 @@ describe('External process-host development rebuilds', () => {
if (typeof buildStart !== 'function') {
throw new Error('The process-host rebuild plugin must have a buildStart hook')
}
rebuildPlugin.buildStart = function (...args) {
rebuildPlugin.buildStart = async function (...args) {
const addWatchFile = this.addWatchFile.bind(this)
const registration = vi.spyOn(this, 'addWatchFile').mockImplementation((file) => {
addWatchFile(file)
@@ -353,7 +353,7 @@ describe('External process-host development rebuilds', () => {
}
})
try {
return buildStart.apply(this, args)
return await buildStart.apply(this, args)
} finally {
registration.mockRestore()
}
+3 -56
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env node
import { lstat, readFile, readdir, realpath, rm } from 'node:fs/promises'
import { lstat, readFile, readdir, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { runProcess } from '@orca/process-host'
import { lock } from 'proper-lockfile'
import { decodeGitCQuotedPath } from '../../src/shared/git-cquoted-path.ts'
import { localRepositoryWorktrees } from './local-repository-worktrees.mjs'
import { CHECKOUT_FORMAT } from '../../tests/e2e/cross-version-wire/release-checkout-publication.ts'
import { withReleaseSourceFormatLease } from '../../tests/e2e/cross-version-wire/release-source-retention.ts'
@@ -70,58 +69,6 @@ async function logicalBytes(root) {
return bytes
}
async function git(repoRoot, args) {
const result = await runProcess({
program: 'git',
args,
cwd: repoRoot,
timeoutMs: 30_000,
maxOutputBytes: 4 * 1024 * 1024,
terminationBarrier: true
})
if (result.code !== 0 || result.timedOut) {
throw new Error('Could not read the local Git repository')
}
return result.stdout
}
async function repositoryTargets(repoRoot) {
const common = (await git(repoRoot, ['rev-parse', '--git-common-dir'])).replace(
process.platform === 'win32' ? /\r?\n$/ : /\n$/,
''
)
if (!common || common.includes('\n')) {
throw new Error('Unrecognised Git common directory')
}
const output = await git(repoRoot, [
'-c',
'core.quotePath=false',
'worktree',
'list',
'--porcelain'
])
const worktrees = []
for (const block of output.split(/\r?\n\r?\n/)) {
const lines = block.split(/\r?\n/).filter(Boolean)
if (lines.length === 0) {
continue
}
if (
lines.some(
(line) =>
!/^(worktree |HEAD |branch |bare$|detached$|locked(?: |$)|prunable(?: |$))/.test(line)
)
) {
throw new Error('Ambiguous worktree listing; refusing cleanup')
}
const field = lines.find((line) => line.startsWith('worktree '))
if (field && !lines.includes('bare')) {
worktrees.push(decodeGitCQuotedPath(field.slice(9)))
}
}
return { commonDirectory: await realpath(resolve(repoRoot, common)), worktrees }
}
export async function reclaimReleaseTestCaches({
repoRoot = process.cwd(),
apply = false,
@@ -139,7 +86,7 @@ export async function reclaimReleaseTestCaches({
'Stop cross-version tests in this repository, then pass --tests-stopped with --apply'
)
}
const { commonDirectory, worktrees } = targets ?? (await repositoryTargets(repoRoot))
const { commonDirectory, worktrees } = targets ?? (await localRepositoryWorktrees(repoRoot))
const cutoff = nowMs - minAgeDays * DAY_MS
const result = { entries: 0, logicalBytes: 0, protected: 0 }
+131
View File
@@ -0,0 +1,131 @@
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { localRepositoryWorktrees } from './local-repository-worktrees.mjs'
import { isProcessAlive } from './remote-agent-session-process-cleanup.mjs'
import { parseReclaimArguments } from './reclaim-worktree-command.mjs'
import {
VITE_CACHE_HOST_ID,
VITE_CACHE_USAGE_DIRECTORY,
withViteCacheLock
} from './vite-cache-activity.mjs'
const OWNER = /^([\da-f]{64})-([1-9]\d*)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/
async function directory(path) {
try {
return (await lstat(path)).isDirectory()
} catch (error) {
if (error.code === 'ENOENT') {
return false
}
throw error
}
}
async function cacheDirectory(worktree) {
if (!(await directory(join(worktree, '.cache')))) {
return null
}
const root = join(worktree, '.cache', 'vite')
return (await directory(root)) ? root : null
}
async function unusedCache(cacheRoot, cutoff, isAlive) {
try {
const marker = join(cacheRoot, VITE_CACHE_USAGE_DIRECTORY)
const info = await lstat(marker)
if (!info.isDirectory() || info.mtimeMs > cutoff) {
return false
}
for (const name of await readdir(marker)) {
const match = OWNER.exec(name)
if (!match || match[1] !== VITE_CACHE_HOST_ID) {
return false
}
const pid = Number(match[2])
const info = await lstat(join(marker, name))
if (!Number.isSafeInteger(pid) || !info.isFile() || info.size !== 0 || isAlive(pid)) {
return false
}
}
return true
} catch (error) {
if (error.code === 'ENOENT') {
return false
}
throw error
}
}
export async function reclaimViteCaches({
repoRoot = process.cwd(),
apply = false,
excludeWorktree,
minAgeDays = 30,
nowMs = Date.now(),
worktrees,
isAlive = isProcessAlive,
report = console.log
} = {}) {
if (!Number.isFinite(minAgeDays) || minAgeDays < 0) {
throw new Error('Vite cache age must be a non-negative number')
}
const excluded = excludeWorktree ? await realpath(excludeWorktree) : null
const roots = worktrees ?? (await localRepositoryWorktrees(repoRoot)).worktrees
const result = { entries: 0, protected: 0 }
for (const worktree of new Set(roots)) {
if (!(await directory(worktree))) {
continue
}
const canonical = await realpath(worktree)
if (canonical === excluded) {
continue
}
const cacheRoot = await cacheDirectory(canonical)
if (!cacheRoot) {
continue
}
try {
await withViteCacheLock(
cacheRoot,
async (verify) => {
if (
(await cacheDirectory(canonical)) !== cacheRoot ||
!(await unusedCache(cacheRoot, nowMs - minAgeDays * 86_400_000, isAlive))
) {
result.protected += 1
return
}
verify()
if (apply) {
await rm(cacheRoot, { recursive: true, force: true })
}
result.entries += 1
report(`${apply ? 'removed' : 'would remove'} Vite cache ${JSON.stringify(cacheRoot)}`)
},
0
)
} catch (error) {
if (error.code !== 'ELOCKED') {
throw error
}
result.protected += 1
}
}
report(
`${apply ? 'Removed' : 'Would remove'} ${result.entries} Vite cache(s); retained ${result.protected} recent, active, or unverifiable cache(s).`
)
return result
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const options = parseReclaimArguments(process.argv.slice(2))
if (options.help) {
console.log('Usage: pnpm reclaim-space [--apply] [--repo DIRECTORY]')
} else {
reclaimViteCaches(options).catch((error) => {
console.error(error.message)
process.exitCode = 1
})
}
}
+204
View File
@@ -0,0 +1,204 @@
import {
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
symlink,
utimes,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { runProcess } from '@orca/process-host'
import { createServer } from 'vite'
import { afterEach, expect, it, vi } from 'vitest'
import { createViteCacheRetentionPlugin } from '../build-plugins/vite-cache-retention'
import { reclaimViteCaches } from './reclaim-vite-caches.mjs'
import {
retainViteCache,
VITE_CACHE_USAGE_DIRECTORY,
withViteCacheLock
} from './vite-cache-activity.mjs'
const roots = []
const nowMs = Date.now()
const dayMs = 86_400_000
afterEach(async () => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true })
}
})
async function age(cacheRoot, days = 31) {
const time = new Date(nowMs - days * dayMs)
await utimes(join(cacheRoot, VITE_CACHE_USAGE_DIRECTORY), time, time)
}
async function fixture(days = 31) {
const root = await mkdtemp(join(tmpdir(), 'orca-vite-cache-'))
roots.push(root)
const worktree = join(root, 'worktree')
const cacheRoot = join(worktree, '.cache', 'vite')
const release = await retainViteCache(cacheRoot)
await release()
const files = ['renderer', 'web'].map((target) => join(cacheRoot, target, 'deps', 'react.js'))
for (const file of files) {
await mkdir(resolve(file, '..'), { recursive: true })
await writeFile(file, 'export const cached = true')
}
await age(cacheRoot, days)
return {
root,
worktree,
cacheRoot,
files,
worktrees: [worktree, worktree],
nowMs,
report: vi.fn()
}
}
it('previews and removes old owned caches for both build targets', async () => {
const options = await fixture()
expect(await reclaimViteCaches(options)).toMatchObject({ entries: 1 })
for (const file of options.files) {
expect(await readFile(file, 'utf8')).toContain('cached')
}
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 1 })
for (const file of options.files) {
await expect(readFile(file)).rejects.toMatchObject({ code: 'ENOENT' })
}
})
it('retains a recently used cache even when its dependency outputs are old', async () => {
const options = await fixture(1)
for (const file of options.files) {
await utimes(file, new Date(0), new Date(0))
}
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
})
it('retains every live owner and refreshes last use when a server closes', async () => {
const options = await fixture()
const first = await retainViteCache(options.cacheRoot)
const second = await retainViteCache(options.cacheRoot)
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
await first()
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
await second()
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 1 })
})
it.each([false, true])(
'reclaims exited owners only on this host (foreign: %s)',
async (foreign) => {
const options = await fixture()
const moduleUrl = pathToFileURL(resolve(import.meta.dirname, 'vite-cache-activity.mjs')).href
const child = await runProcess({
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
args: [
'--input-type=module',
'-e',
`import { retainViteCache } from ${JSON.stringify(moduleUrl)}; await retainViteCache(${JSON.stringify(options.cacheRoot)});`
],
env: foreign
? { ...process.env, WSL_DISTRO_NAME: `${process.env.WSL_DISTRO_NAME ?? ''}-other` }
: process.env,
timeoutMs: 10_000,
terminationBarrier: true
})
expect(child.code, child.stderr).toBe(0)
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({
entries: foreign ? 0 : 1
})
}
)
it.each(['foreign-host', 'corrupt', 'permission-denied'])(
'retains unverifiable ownership: %s',
async (kind) => {
const options = await fixture()
const release = await retainViteCache(options.cacheRoot)
const owners = join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY)
const [name] = await readdir(owners)
const owner = join(owners, name)
if (kind === 'foreign-host') {
await rename(owner, join(owners, name.replace(/^[\da-f]{64}/, '0'.repeat(64))))
} else if (kind === 'corrupt') {
await writeFile(owner, '{')
} else {
vi.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
})
}
try {
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
} finally {
vi.restoreAllMocks()
await release()
}
}
)
it('retains untracked caches and the worktree being launched', async () => {
const options = await fixture()
expect(
await reclaimViteCaches({ ...options, apply: true, excludeWorktree: options.worktree })
).toMatchObject({ entries: 0 })
await rm(join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY), { recursive: true })
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
})
it.each(['.cache', 'vite'])('does not follow a %s directory link during cleanup', async (part) => {
const options = await fixture()
const source = part === '.cache' ? join(options.worktree, '.cache') : options.cacheRoot
const target = join(options.root, 'outside')
await rename(source, target)
await symlink(target, source, 'junction')
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
expect(await readdir(target)).not.toEqual([])
})
it('does not sweep while cache initialization holds the maintenance lock', async () => {
const options = await fixture()
await withViteCacheLock(options.cacheRoot, async () => {
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({
entries: 0,
protected: 1
})
})
})
it('tracks a real Vite server until shutdown has completed', async () => {
const options = await fixture()
const server = await createServer({
configFile: false,
root: options.worktree,
cacheDir: join(options.cacheRoot, 'renderer'),
logLevel: 'silent',
server: { middlewareMode: true, watch: null, hmr: false },
optimizeDeps: { noDiscovery: true, include: [] },
plugins: [createViteCacheRetentionPlugin(options.cacheRoot)]
})
try {
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
} finally {
await server.close()
}
expect(await readdir(join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY))).toEqual([])
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 })
await age(options.cacheRoot)
expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 1 })
})
+1 -1
View File
@@ -53,7 +53,7 @@ export async function runReclaimCommand(
'Usage: pnpm reclaim-space [--apply] [--repo DIRECTORY] [--tests-stopped] [--release-cache-min-age-days 30]\n'
)
output.write(
'Shares Electron installs and removes idle dev bundles across local Git worktrees.\n'
'Shares Electron installs and removes idle dev bundles and Vite caches unused for 30 days across local Git worktrees.\n'
)
output.write('Without --apply, previews cleanup and offers [y/N] in an interactive terminal.\n')
output.write(
+5 -1
View File
@@ -43,7 +43,11 @@ export async function reclaimWorktreeSpace({
let cleanupFailed = false
let cleanupError
try {
const scripts = ['reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs']
const scripts = [
'reclaim-electron-dists.mjs',
'reclaim-dev-electron-bundles.mjs',
'reclaim-vite-caches.mjs'
]
if (!automatic && testsStopped) {
scripts.unshift('reclaim-release-test-caches.mjs')
}
+18 -7
View File
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { reclaimWorktreeSpace } from './reclaim-worktree-space.mjs'
import { runProcess } from '@orca/process-host'
import { retainViteCache, VITE_CACHE_USAGE_DIRECTORY } from './vite-cache-activity.mjs'
const roots = []
afterEach(async () => {
@@ -18,17 +19,17 @@ async function fixture() {
return { commonDirectory: root, repoRoot: root, execute }
}
it('previews historical test caches and both Electron cleanup operations', async () => {
it('previews historical test caches, Electron cleanup, and Vite caches', async () => {
const options = { ...(await fixture()), testsStopped: true }
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(3)
expect(options.execute).toHaveBeenCalledTimes(4)
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply')
})
it('forwards apply and repository selection to all cleanup operations', async () => {
const options = { ...(await fixture()), apply: true, testsStopped: true }
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(3)
expect(options.execute).toHaveBeenCalledTimes(4)
for (const [spec] of options.execute.mock.calls) {
expect(spec.args).toContain('--apply')
expect(spec.args).toContain(options.repoRoot)
@@ -42,10 +43,10 @@ it('forwards apply and repository selection to all cleanup operations', async ()
}
})
it('applies Electron cleanup while retaining unacknowledged test caches', async () => {
it('applies Electron and Vite cleanup while retaining unacknowledged test caches', async () => {
const options = { ...(await fixture()), apply: true }
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(2)
expect(options.execute).toHaveBeenCalledTimes(3)
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain(
'reclaim-release-test-caches'
)
@@ -110,6 +111,13 @@ it('runs the CLI outside the repository and retains historical caches until ackn
const marker = join(cache, 'checkout-stamp.json')
await writeFile(marker, JSON.stringify({ commit, format: 4 }))
await utimes(cache, new Date(0), new Date(0))
const viteCache = join(options.repoRoot, '.cache', 'vite')
const releaseCache = await retainViteCache(viteCache)
await releaseCache()
await mkdir(join(viteCache, 'renderer', 'deps'), { recursive: true })
const dependency = join(viteCache, 'renderer', 'deps', 'react.js')
await writeFile(dependency, 'export const cached = true')
await utimes(join(viteCache, VITE_CACHE_USAGE_DIRECTORY), new Date(0), new Date(0))
const run = (flags = []) =>
runProcess({
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
@@ -128,10 +136,13 @@ it('runs the CLI outside the repository and retains historical caches until ackn
expect(preview.code, preview.stderr).toBe(0)
expect(preview.stdout).toContain('Would remove 1 cache(s)')
expect(await readFile(marker, 'utf8')).toContain(commit)
expect(await readFile(dependency, 'utf8')).toContain('cached')
const safe = await run(['--apply'])
expect(safe.code, safe.stderr).toBe(0)
expect(safe.stdout).toContain('Historical test caches retained')
expect(await readFile(marker, 'utf8')).toContain(commit)
expect(safe.stdout).toContain('Removed 1 Vite cache(s)')
await expect(readFile(dependency)).rejects.toMatchObject({ code: 'ENOENT' })
const applied = await run(['--apply', '--tests-stopped'])
expect(applied.code, applied.stderr).toBe(0)
expect(applied.stdout).toContain('Removed 1 cache(s)')
@@ -161,7 +172,7 @@ it('rejects competing sweeps and releases the lock after a failed operation', as
await rejected
options.execute.mockResolvedValue({ code: 0, timedOut: false })
await reclaimWorktreeSpace(options)
expect(options.execute).toHaveBeenCalledTimes(4)
expect(options.execute).toHaveBeenCalledTimes(5)
})
it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => {
@@ -188,5 +199,5 @@ it('fails if cleanup succeeds but the repository lock cannot be released', async
await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe(
failure
)
expect(options.execute).toHaveBeenCalledTimes(3)
expect(options.execute).toHaveBeenCalledTimes(4)
})
+56
View File
@@ -0,0 +1,56 @@
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, rm, utimes, writeFile } from 'node:fs/promises'
import { hostname } from 'node:os'
import { dirname, join } from 'node:path'
import { lock } from 'proper-lockfile'
export const VITE_CACHE_USAGE_DIRECTORY = '.orca-vite-cache-v1'
// Windows and WSL can share a hostname while using separate PID namespaces.
export const VITE_CACHE_HOST_ID = createHash('sha256')
.update(JSON.stringify([process.platform, hostname(), process.env.WSL_DISTRO_NAME ?? '']))
.digest('hex')
export async function withViteCacheLock(cacheRoot, action, retries = 100) {
await mkdir(dirname(cacheRoot), { recursive: true })
let compromised
const release = await lock(cacheRoot, {
realpath: false,
stale: 60_000,
retries: { retries, minTimeout: 50, maxTimeout: 250 },
onCompromised: (error) => {
compromised = error
}
})
const verify = () => {
if (compromised) {
throw compromised
}
}
try {
verify()
const result = await action(verify)
verify()
return result
} finally {
if (!compromised) {
await release()
}
}
}
export async function retainViteCache(cacheRoot) {
const usage = join(cacheRoot, VITE_CACHE_USAGE_DIRECTORY)
const owner = join(usage, `${VITE_CACHE_HOST_ID}-${process.pid}-${randomUUID()}`)
await withViteCacheLock(cacheRoot, async (verify) => {
await mkdir(dirname(owner), { recursive: true })
verify()
await writeFile(owner, '', { flag: 'wx' })
await utimes(usage, new Date(), new Date())
})
return () =>
withViteCacheLock(cacheRoot, async (verify) => {
verify()
await rm(owner, { force: true })
await utimes(usage, new Date(), new Date())
})
}
+10 -4
View File
@@ -18,9 +18,10 @@ pnpm reclaim-space --tests-stopped
Answer `Y` to apply that preview, or use `pnpm reclaim-space --apply --tests-stopped` to apply directly.
The command reuses the historical test cache, Electron distribution, and dev bundle cleanup scripts.
The command reuses the historical test cache, Electron distribution, dev bundle, and Vite cache cleanup scripts.
It shares complete Electron distributions through the repository cache and removes idle
`out/electron-dev` bundles, which development rebuilds on demand. With `--tests-stopped`, it also
`out/electron-dev` bundles, which development rebuilds on demand. It also removes tracked
`.cache/vite` dependency caches unused for 30 days. With `--tests-stopped`, it also
removes stamped historical test checkouts and shared seeds older than 30 days; tests regenerate them when needed. It leaves source files,
dependency installs, worktree registrations, and other build output alone. Reported sizes are
logical file data; APFS clones and hardlinks mean actual disk savings can be smaller.
@@ -33,6 +34,11 @@ checkout is unused. Without that flag, historical caches are excluded from both
Cache cleanup skips symlinks, malformed stamps, future formats, and publication/copy locks.
See [release source sharing](./release-source-sharing.md) for the cache and locking details.
Vite dev servers record cache use at startup and shutdown. Cleanup retains recent caches,
caches owned by a live process, and caches whose ownership cannot be verified, including caches
created before tracking was added. A maintenance lock prevents cleanup racing with server startup.
Desktop and web caches are reclaimed together; the next dev launch regenerates them as needed.
Use `--repo DIRECTORY` to choose another local repository. Git's worktree list determines scope,
including registered worktrees outside Orca's workspace directory. Folder workspaces without Git
have no repository-wide sweep. These commands run on the local host: they do not inspect or delete
@@ -45,9 +51,9 @@ without waiting for an Electron upgrade. The manual command migrates older insta
Read-only Electron package directories are retained and reported as skipped. Cache publication
or copying failures are also reported as skipped rather than silently omitted.
`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide Electron cleanup worker
`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide Electron and Vite cleanup worker
in the background after the dev process starts. It never prompts or waits before launching the app.
The worktree being launched is excluded from both Electron sharing and dev bundle removal; active
The worktree being launched is excluded from Electron sharing, dev bundle removal, and Vite cleanup; active
sibling apps and recent unfinished builds remain protected by the existing bundle checks.
Background sweep output is suppressed; worker start or exit failures are reported without stopping dev. Set `ORCA_SKIP_DEV_STORAGE_RECLAIM=1` to opt out.
The worker finishes independently if the dev app exits, and each cleanup operation has a ten-minute limit.
+20 -3
View File
@@ -8,6 +8,7 @@ import tailwindcss from '@tailwindcss/vite'
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
import { createProcessHostDevRebuildPlugin } from './config/build-plugins/process-host-dev-rebuild'
import { createViteCacheRetentionPlugin } from './config/build-plugins/vite-cache-retention'
import { MAIN_BUILD_ENTRY_POINTS } from './config/build-plugins/main-build-entry-points'
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
import { resolveBuildSourceCommit } from './config/scripts/build-source-commit.mjs'
@@ -294,6 +295,8 @@ export const electronViteConfig: UserConfig = {
}
},
renderer: {
// Shared node_modules installs must not share dependency optimization state across worktrees.
cacheDir: resolve('.cache/vite/renderer'),
define: {
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL,
ORCA_LAYOUT_OWNER: ORCA_LAYOUT_OWNER_LITERAL
@@ -305,12 +308,18 @@ export const electronViteConfig: UserConfig = {
'@': resolve('src/renderer/src')
}
},
plugins: [react(), tailwindcss(), createPdfjsViewerAssetsPlugin()],
plugins: [
react(),
tailwindcss(),
createPdfjsViewerAssetsPlugin(),
createViteCacheRetentionPlugin()
],
worker: {
format: 'es'
},
build: {
manifest: true,
reportCompressedSize: false,
modulePreload: { polyfill: true },
minify: 'oxc',
target: 'es2020',
@@ -334,14 +343,22 @@ export const electronViteConfig: UserConfig = {
}
export default defineConfig(({ command }) => {
if (command !== 'serve' || process.env.ORCA_DEV_MAIN_SOURCEMAPS !== '0') {
if (command !== 'serve') {
return electronViteConfig
}
// Electron Vite also uses Vite's build command for the development main process.
return {
...electronViteConfig,
main: {
...electronViteConfig.main,
build: { ...electronViteConfig.main?.build, sourcemap: false }
build: {
...electronViteConfig.main?.build,
minify: false,
sourcemap:
process.env.ORCA_DEV_MAIN_SOURCEMAPS === '0'
? false
: electronViteConfig.main?.build?.sourcemap
}
}
}
})
+9 -1
View File
@@ -4,14 +4,21 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets'
import { createViteCacheRetentionPlugin } from './config/build-plugins/vite-cache-retention'
import { resolveBuildSourceCommit } from './config/scripts/build-source-commit.mjs'
export default defineConfig({
root: resolve('src/renderer'),
cacheDir: resolve('.cache/vite/web'),
// Why: pairing URLs may live under a reverse-proxy path prefix like
// /orca/web-index.html, so built assets must resolve relative to the page.
base: './',
plugins: [react(), tailwindcss(), createPdfjsViewerAssetsPlugin()],
plugins: [
react(),
tailwindcss(),
createPdfjsViewerAssetsPlugin(),
createViteCacheRetentionPlugin()
],
define: {
ORCA_FEATURE_WALL_ENABLED: 'true',
ORCA_BUILD_COMMIT: JSON.stringify(resolveBuildSourceCommit())
@@ -26,6 +33,7 @@ export default defineConfig({
build: {
outDir: resolve('out/web'),
emptyOutDir: true,
reportCompressedSize: false,
rollupOptions: {
input: resolve('src/renderer/web-index.html')
}