diff --git a/.gitignore b/.gitignore index c986978b376..0839c1cf0f9 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,8 @@ desktop.ini # Lint/cache .oxlintcache +/.cache/vite/ +/.cache/vite.lock/ # Logs *.log diff --git a/config/build-plugins/process-host-dev-rebuild.ts b/config/build-plugins/process-host-dev-rebuild.ts index 41e6927e055..5daf6a686bd 100644 --- a/config/build-plugins/process-host-dev-rebuild.ts +++ b/config/build-plugins/process-host-dev-rebuild.ts @@ -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 @@ -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 }) } diff --git a/config/build-plugins/vite-cache-retention.ts b/config/build-plugins/vite-cache-retention.ts new file mode 100644 index 00000000000..ceacb107811 --- /dev/null +++ b/config/build-plugins/vite-cache-retention.ts @@ -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() + } + } + } + } +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 434f5fc0ee4..a2ee7a0db5f 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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{,/**/*}', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index bc0c6dfd27d..37ae87b21ed 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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) } diff --git a/config/scripts/electron-vite-development-config.test.ts b/config/scripts/electron-vite-development-config.test.ts new file mode 100644 index 00000000000..f9a6951bd65 --- /dev/null +++ b/config/scripts/electron-vite-development-config.test.ts @@ -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) + }) +}) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 631fdc42cc5..467ce6a3edd 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -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 ) diff --git a/config/scripts/local-repository-worktrees.mjs b/config/scripts/local-repository-worktrees.mjs new file mode 100644 index 00000000000..130bccc8f56 --- /dev/null +++ b/config/scripts/local-repository-worktrees.mjs @@ -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 } +} diff --git a/config/scripts/process-host-dev-rebuild.test.ts b/config/scripts/process-host-dev-rebuild.test.ts index 1ae1d5e5fab..1b6514b65a7 100644 --- a/config/scripts/process-host-dev-rebuild.test.ts +++ b/config/scripts/process-host-dev-rebuild.test.ts @@ -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() } diff --git a/config/scripts/reclaim-release-test-caches.mjs b/config/scripts/reclaim-release-test-caches.mjs index b064ba453ff..56a22ce0409 100644 --- a/config/scripts/reclaim-release-test-caches.mjs +++ b/config/scripts/reclaim-release-test-caches.mjs @@ -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 } diff --git a/config/scripts/reclaim-vite-caches.mjs b/config/scripts/reclaim-vite-caches.mjs new file mode 100644 index 00000000000..eb625cad185 --- /dev/null +++ b/config/scripts/reclaim-vite-caches.mjs @@ -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 + }) + } +} diff --git a/config/scripts/reclaim-vite-caches.test.mjs b/config/scripts/reclaim-vite-caches.test.mjs new file mode 100644 index 00000000000..b2036e86f46 --- /dev/null +++ b/config/scripts/reclaim-vite-caches.test.mjs @@ -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 }) +}) diff --git a/config/scripts/reclaim-worktree-command.mjs b/config/scripts/reclaim-worktree-command.mjs index b4bac4116fa..ed33d1f700c 100644 --- a/config/scripts/reclaim-worktree-command.mjs +++ b/config/scripts/reclaim-worktree-command.mjs @@ -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( diff --git a/config/scripts/reclaim-worktree-space.mjs b/config/scripts/reclaim-worktree-space.mjs index b9e8e7ea72a..d46482c50f1 100644 --- a/config/scripts/reclaim-worktree-space.mjs +++ b/config/scripts/reclaim-worktree-space.mjs @@ -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') } diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs index 37babcf3750..c810a45e7da 100644 --- a/config/scripts/reclaim-worktree-space.test.mjs +++ b/config/scripts/reclaim-worktree-space.test.mjs @@ -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) }) diff --git a/config/scripts/vite-cache-activity.mjs b/config/scripts/vite-cache-activity.mjs new file mode 100644 index 00000000000..efecd8a7b9e --- /dev/null +++ b/config/scripts/vite-cache-activity.mjs @@ -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()) + }) +} diff --git a/docs/readme/worktree-storage-maintenance.md b/docs/readme/worktree-storage-maintenance.md index 636d6d65358..33dd9cfa2ce 100644 --- a/docs/readme/worktree-storage-maintenance.md +++ b/docs/readme/worktree-storage-maintenance.md @@ -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. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 225c30415f1..443c2e2b2e9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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 + } } } }) diff --git a/vite.web.config.ts b/vite.web.config.ts index 4d6d90e60bf..c23c946771f 100644 --- a/vite.web.config.ts +++ b/vite.web.config.ts @@ -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') }