From f151bd965b6f98a8c414fd93a1e348fe692e8780 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 16 Oct 2026 14:35:06 -0700 Subject: [PATCH] perf(dev): avoid pairing web rebuilds after test file changes (#29838) * perf(dev): keep pairing web bundle after test file changes * fix(dev): detect web input replacement within one timestamp tick * test(dev): use real files for web replacement coverage --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/scripts/dev-web-client-freshness.mjs | 85 ++++++++--- .../scripts/dev-web-client-freshness.test.mjs | 134 +++++++++++++++++- config/scripts/run-electron-vite-dev.mjs | 8 +- 3 files changed, 205 insertions(+), 22 deletions(-) diff --git a/config/scripts/dev-web-client-freshness.mjs b/config/scripts/dev-web-client-freshness.mjs index a3ad795dab3..d7ac1d4e151 100644 --- a/config/scripts/dev-web-client-freshness.mjs +++ b/config/scripts/dev-web-client-freshness.mjs @@ -1,20 +1,31 @@ -import { readdirSync, statSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +const SNAPSHOT_FILENAME = '.orca-dev-web-inputs.json' +const INPUT_PATHS = ['vite.web.config.ts', 'src/renderer', 'src/shared', 'src/preload/api-types.ts'] + const TEST_SOURCE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/ -function latestSourceMtimeMs(targetPath) { +function collectInputs(targetPath, relativePath, files) { let stat try { - stat = statSync(targetPath) + stat = statSync(targetPath, { bigint: true }) } catch { return 0 } + const mtimeMs = Number(stat.mtimeNs) / 1e6 if (!stat.isDirectory()) { - return stat.mtimeMs + files.push([ + relativePath, + ...[stat.mtimeNs, stat.ctimeNs, stat.dev, stat.ino, stat.size].map(String) + ]) + return mtimeMs } - let latest = stat.mtimeMs - for (const entry of readdirSync(targetPath, { withFileTypes: true })) { + let latest = mtimeMs + for (const entry of readdirSync(targetPath, { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { if ( entry.name === 'node_modules' || entry.name.startsWith('.') || @@ -22,27 +33,63 @@ function latestSourceMtimeMs(targetPath) { ) { continue } - latest = Math.max(latest, latestSourceMtimeMs(join(targetPath, entry.name))) + latest = Math.max( + latest, + collectInputs(join(targetPath, entry.name), join(relativePath, entry.name), files) + ) } return latest } -export function isDevWebClientFresh(root) { - let outputMtime +export function captureDevWebClientInputs(root) { + const files = [] + const latestMtime = Math.max( + ...INPUT_PATHS.map((input) => collectInputs(join(root, input), input, files)) + ) + return { + signature: createHash('sha256').update(JSON.stringify(files)).digest('hex'), + latestMtime + } +} + +function outputIdentity(root) { + const stat = statSync(join(root, 'out', 'web', 'web-index.html')) + return [stat.mtimeMs, stat.size] +} + +export function recordDevWebClientInputs(root, inputs) { try { - outputMtime = statSync(join(root, 'out', 'web', 'web-index.html')).mtimeMs + writeFileSync( + join(root, 'out', 'web', SNAPSHOT_FILENAME), + JSON.stringify({ version: 1, output: outputIdentity(root), signature: inputs.signature }) + ) + } catch { + // A missing cache must never prevent dev startup. + } +} + +export function isDevWebClientFresh(root) { + let output + try { + output = outputIdentity(root) } catch { return false } - if (outputMtime === 0) { + if (output[0] === 0) { return false } - // Directory timestamps still invalidate added/deleted sources conservatively. - const sourceMtime = Math.max( - latestSourceMtimeMs(join(root, 'vite.web.config.ts')), - latestSourceMtimeMs(join(root, 'src', 'renderer')), - latestSourceMtimeMs(join(root, 'src', 'shared')), - latestSourceMtimeMs(join(root, 'src', 'preload', 'api-types.ts')) - ) - return sourceMtime <= outputMtime + const inputs = captureDevWebClientInputs(root) + try { + const previous = JSON.parse(readFileSync(join(root, 'out', 'web', SNAPSHOT_FILENAME), 'utf8')) + if (previous.version === 1 && JSON.stringify(previous.output) === JSON.stringify(output)) { + return previous.signature === inputs.signature + } + } catch { + // Existing standalone and projected builds can be adopted without rebuilding. + } + if (inputs.latestMtime > output[0]) { + return false + } + recordDevWebClientInputs(root, inputs) + return true } diff --git a/config/scripts/dev-web-client-freshness.test.mjs b/config/scripts/dev-web-client-freshness.test.mjs index 88772afc9fe..8b1bfc33c90 100644 --- a/config/scripts/dev-web-client-freshness.test.mjs +++ b/config/scripts/dev-web-client-freshness.test.mjs @@ -1,8 +1,20 @@ -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { isDevWebClientFresh } from './dev-web-client-freshness.mjs' +import { + captureDevWebClientInputs, + isDevWebClientFresh, + recordDevWebClientInputs +} from './dev-web-client-freshness.mjs' const roots = [] const OLD = new Date(Date.now() - 120_000) @@ -74,6 +86,7 @@ it.each([ 'vite.web.config.ts' ])('rebuilds when %s changes', (file) => { const root = fixture() + expect(isDevWebClientFresh(root)).toBe(true) utimesSync(join(root, file), EDITED, EDITED) expect(isDevWebClientFresh(root)).toBe(false) }) @@ -89,3 +102,120 @@ it.each(['add', 'delete'])('rebuilds after a production source %s', (operation) utimesSync(dirname(file), EDITED, EDITED) expect(isDevWebClientFresh(root)).toBe(false) }) + +it.each(['test.ts', 'spec.tsx', 'test.mjs', 'spec.cts'])( + 'keeps a tracked bundle fresh after adding and deleting a %s file', + (suffix) => { + const root = fixture() + expect(isDevWebClientFresh(root)).toBe(true) + const file = join(root, `src/renderer/src/new.${suffix}`) + writeFileSync(file, '') + utimesSync(dirname(file), EDITED, EDITED) + expect(isDevWebClientFresh(root)).toBe(true) + rmSync(file) + expect(isDevWebClientFresh(root)).toBe(true) + } +) + +it('ignores adding and removing a directory containing only tests', () => { + const root = fixture() + expect(isDevWebClientFresh(root)).toBe(true) + const directory = join(root, 'src/shared/new-tests') + mkdirSync(directory) + writeFileSync(join(directory, 'value.test.ts'), '') + expect(isDevWebClientFresh(root)).toBe(true) + rmSync(directory, { recursive: true }) + expect(isDevWebClientFresh(root)).toBe(true) +}) + +it.each(['add', 'delete', 'rename'])( + 'invalidates a tracked bundle after a production source %s with old timestamps', + (operation) => { + const root = fixture() + expect(isDevWebClientFresh(root)).toBe(true) + if (operation !== 'add') { + rmSync(join(root, 'src/renderer/src/app.tsx')) + } + if (operation !== 'delete') { + const file = join(root, 'src/renderer/src/added.tsx') + writeFileSync(file, '') + utimesSync(file, OLD, OLD) + } + utimesSync(join(root, 'src/renderer/src'), OLD, OLD) + expect(isDevWebClientFresh(root)).toBe(false) + } +) + +it('invalidates a tracked bundle when a source is removed in a nested directory', () => { + const root = fixture() + const directory = join(root, 'src/shared/nested') + mkdirSync(directory) + writeFileSync(join(directory, 'value.ts'), '') + utimesSync(join(directory, 'value.ts'), OLD, OLD) + utimesSync(directory, OLD, OLD) + utimesSync(dirname(directory), OLD, OLD) + expect(isDevWebClientFresh(root)).toBe(true) + rmSync(directory, { recursive: true }) + utimesSync(dirname(directory), OLD, OLD) + expect(isDevWebClientFresh(root)).toBe(false) +}) + +it.each(['missing', 'corrupt', 'version', 'output'])( + 'falls back conservatively when the inventory is %s', + (state) => { + const root = fixture() + expect(isDevWebClientFresh(root)).toBe(true) + const marker = join(root, 'out/web/.orca-dev-web-inputs.json') + if (state === 'missing') { + rmSync(marker) + } + if (state === 'corrupt') { + writeFileSync(marker, '{') + } + if (state === 'version') { + writeFileSync(marker, JSON.stringify({ version: 0 })) + } + if (state === 'output') { + utimesSync(join(root, 'out/web/web-index.html'), OLD, OLD) + } + rmSync(join(root, 'src/renderer/src/app.tsx')) + utimesSync(join(root, 'src/renderer/src'), EDITED, EDITED) + expect(isDevWebClientFresh(root)).toBe(false) + } +) + +it('adopts a fresh replacement output without reusing its previous inventory', () => { + const root = fixture() + expect(isDevWebClientFresh(root)).toBe(true) + writeFileSync(join(root, 'src/renderer/src/app.tsx'), 'updated') + utimesSync(join(root, 'src/renderer/src/app.tsx'), BUILT, BUILT) + utimesSync(join(root, 'out/web/web-index.html'), EDITED, EDITED) + expect(isDevWebClientFresh(root)).toBe(true) +}) + +it('retains the inputs from before a build so concurrent edits invalidate its output', () => { + const root = fixture() + const inputs = captureDevWebClientInputs(root) + writeFileSync(join(root, 'src/shared/value.ts'), 'changed while building') + utimesSync(join(root, 'out/web/web-index.html'), EDITED, EDITED) + recordDevWebClientInputs(root, inputs) + expect(isDevWebClientFresh(root)).toBe(false) +}) + +it('detects same-size atomic replacement with a restored mtime', () => { + const root = fixture() + const file = join(root, 'src/shared/value.ts') + const replacement = join(root, 'replacement.ts') + expect(isDevWebClientFresh(root)).toBe(true) + writeFileSync(replacement, '') + utimesSync(replacement, OLD, OLD) + const originalIdentity = statSync(file, { bigint: true }) + const replacementIdentity = statSync(replacement, { bigint: true }) + expect([replacementIdentity.dev, replacementIdentity.ino]).not.toEqual([ + originalIdentity.dev, + originalIdentity.ino + ]) + renameSync(replacement, file) + utimesSync(dirname(file), OLD, OLD) + expect(isDevWebClientFresh(root)).toBe(false) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 92d4906756e..35ce824317e 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -24,7 +24,11 @@ import { selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs' import { startDevStorageReclaim } from './dev-storage-reclaim.mjs' -import { isDevWebClientFresh } from './dev-web-client-freshness.mjs' +import { + captureDevWebClientInputs, + isDevWebClientFresh, + recordDevWebClientInputs +} from './dev-web-client-freshness.mjs' import { copyPrivateTree } from './space-sharing-copy.mjs' import { DEV_BUNDLE_ID, @@ -510,6 +514,7 @@ function prepareDevWebClient() { if (isDevWebClientFresh(repoRoot)) { return } + const inputs = captureDevWebClientInputs(repoRoot) console.error('[orca-dev] Building web client for pairing...') execFileSync( process.execPath, @@ -520,6 +525,7 @@ function prepareDevWebClient() { env: process.env } ) + recordDevWebClientInputs(repoRoot, inputs) } // Why: every `pn dev` should be attachable from agent-browser/playwright-cli