Added all the opts back
diff --git a/python/tools/check_imports.py b/python/tools/check_imports.py index 2bb7db9..99ad5b7 100755 --- a/python/tools/check_imports.py +++ b/python/tools/check_imports.py
@@ -34,10 +34,7 @@ # [a,b] -> [c,d] is equivalent to allowing a>c, a>d, b>c, b>d. DEPS_ALLOWLIST = [ # Everything can depend on base/, protos and NPM packages. - ('*', [ - '/base/*', '/protos/index', '/gen/perfetto_version', NODE_MODULES, - 'virtual:*' - ]), + ('*', ['/base/*', '/protos/index', NODE_MODULES, '/virtual/*']), # Integration tests can depend on everything. ('/test/*', '*'),
diff --git a/ui/build.mjs b/ui/build.mjs index 693848d..1a65e52 100644 --- a/ui/build.mjs +++ b/ui/build.mjs
@@ -29,17 +29,23 @@ import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {startVitest} from 'vitest/node'; +import {acquireBuildLock, warnIfBuildLockHeld} from './scripts/build_lock.mjs'; import {ensureDir} from './scripts/fs_utils.mjs'; -import {compileProtos, prebuild} from './scripts/prebuild.mjs'; +import { + compileProtos, + genServiceWorkerManifestJson, + prebuild, +} from './scripts/prebuild.mjs'; import {startStaticServer} from './scripts/static_server.mjs'; import {runInProcStep, runStep} from './scripts/steps.mjs'; import { ALL_BUNDLES, + OPEN_PERFETTO_TRACE_BUNDLE, WORKER_BUNDLES, viteBuild, viteDev, } from './scripts/vite_runner.mjs'; -import {startVitest} from 'vitest/node'; const pjoin = path.join; const __filename = fileURLToPath(import.meta.url); @@ -93,6 +99,8 @@ Examples: PERFETTO_UI_OUT=/tmp/perfetto-out PERFETTO_UI_NO_BUILD=1 + PERFETTO_UI_SERVE_HOST=0.0.0.0 + PERFETTO_UI_TITLE=my-instance Defaults can also be persisted in: ~/.config/perfetto/ui-dev-server.env @@ -105,6 +113,63 @@ action: 'store_true', help: 'Skip prebuild (wasm/protos/assets) — useful for fast iteration', }); + parser.add_argument('--no-wasm', '-W', { + action: 'store_true', + help: 'Skip the ninja wasm build (assumes outputs already exist)', + }); + parser.add_argument('--no-depscheck', { + action: 'store_true', + help: 'Skip install-build-deps check', + }); + parser.add_argument('--no-override-gn-args', { + action: 'store_true', + help: "Don't auto-set gn args (preserves manual configuration)", + }); + parser.add_argument('--debug', '-d', { + action: 'store_true', + help: 'Debug wasm build (is_debug=true, copies .wasm.map files)', + }); + parser.add_argument('--only-wasm-memory64', { + action: 'store_true', + help: 'Skip the non-memory64 trace_processor wasm build', + }); + parser.add_argument('--minify-js', { + choices: ['preserve_comments', 'all'], + help: 'Minify JS bundles', + }); + parser.add_argument('--no-source-maps', { + action: 'store_true', + help: 'Skip source map generation', + }); + parser.add_argument('--no-treeshake', { + action: 'store_true', + help: 'Disable rollup tree-shaking (faster incremental rebuilds)', + }); + parser.add_argument('--cross-origin-isolation', { + action: 'store_true', + help: 'Send COOP/COEP headers (needed for SharedArrayBuffer)', + }); + parser.add_argument('--serve-host', {help: 'dev/preview bind host'}); + parser.add_argument('--serve-port', { + type: 'int', + help: 'dev/preview bind port', + }); + parser.add_argument('--title', {help: 'Override <title> tag'}); + parser.add_argument('--open-perfetto-trace', { + action: 'store_true', + help: 'Also build the open_perfetto_trace bundle', + }); + parser.add_argument('--test-filter', '-f', { + help: "filter tests by pattern, e.g. 'chrome_render'", + }); + parser.add_argument('--interactive', '-i', { + action: 'store_true', + help: 'Run playwright tests in interactive mode', + }); + parser.add_argument('--rebaseline', '-r', { + action: 'store_true', + help: 'Rebaseline screenshot tests', + }); const sub = parser.add_subparsers({dest: 'command'}); sub.add_parser('pre', {help: 'Run pre-build steps and exit'}); @@ -137,41 +202,103 @@ const outRootDir = path.resolve(args.out || pjoin(ROOT_DIR, 'out/ui')); const outDir = ensureDir(pjoin(outRootDir, 'ui')); + // Plumb bundler-shaping flags through to vite.config.mjs via env. Set them + // unconditionally so they cover both `vite build` and the in-process dev + // server (which reads env at config import time). + if (args.no_source_maps) process.env.NO_SOURCE_MAPS = 'true'; + if (args.no_treeshake) process.env.NO_TREESHAKE = 'true'; + if (args.minify_js) process.env.MINIFY_JS = args.minify_js; + if (args.only_wasm_memory64) process.env.IS_MEMORY64_ONLY = 'true'; + if (args.open_perfetto_trace) process.env.ENABLE_OPEN_PERFETTO_TRACE = 'true'; + if (args.title) process.env.PERFETTO_DEV_TITLE_OVERRIDE = args.title; + // Test-runner shaping: pickup-by-env-var convention preserved from main. + if (args.interactive) process.env.PERFETTO_UI_TESTS_INTERACTIVE = '1'; + if (args.rebaseline) process.env.PERFETTO_UI_TESTS_REBASELINE = '1'; + + const prebuildOpts = { + rootDir: ROOT_DIR, + outDir, + version: VERSION, + debug: args.debug, + skipWasm: args.no_wasm, + skipDepscheck: args.no_depscheck, + noOverrideGnArgs: args.no_override_gn_args, + onlyWasmMemory64: args.only_wasm_memory64, + titleOverride: args.title || '', + }; + + const serverHost = args.serve_host || '127.0.0.1'; + const serverPort = args.serve_port; + const portWasExplicit = serverPort !== undefined; + + // Bundles to feed `vite build`. Open-perfetto-trace is opt-in; bigtrace is + // intentionally not wired in this version of the script. + const bundlesForProd = [...ALL_BUNDLES]; + if (args.open_perfetto_trace) bundlesForProd.push(OPEN_PERFETTO_TRACE_BUNDLE); + + // Lock policy: anything that needs outDir to stay stable acquires the + // build lock for its lifetime. + // pre/build/test: acquire iff they're going to wipe (i.e. !--no-build). + // dev/preview: always acquire — they hold outDir live, --no-build or not. + // typecheck: doesn't touch outDir/dist, no lock. + const wipingCommands = ['pre', 'build', 'test']; + const serverCommands = ['dev', 'preview']; + if ( + (wipingCommands.includes(args.command) && !args.no_build) || + serverCommands.includes(args.command) + ) { + acquireBuildLock({outDir}); + } else if (args.no_build && wipingCommands.includes(args.command)) { + warnIfBuildLockHeld({outDir}); + } + switch (args.command) { case 'pre': - await prebuild({rootDir: ROOT_DIR, outDir, version: VERSION}); + await prebuild(prebuildOpts); break; case 'build': - await prebuild({rootDir: ROOT_DIR, outDir, version: VERSION}); - await viteBuild({rootDir: ROOT_DIR, bundles: ALL_BUNDLES}); + await prebuild(prebuildOpts); + await viteBuild({rootDir: ROOT_DIR, bundles: bundlesForProd}); + await runInProcStep('service worker manifest', () => + genServiceWorkerManifestJson({ + distDir: pjoin(outDir, 'dist', VERSION), + }), + ); break; case 'dev': - await prebuild({rootDir: ROOT_DIR, outDir, version: VERSION}); - // Worker bundles are loaded via `new Worker(url)` at runtime, so they - // need to exist as real files in dist/v<version>/ in both dev and prod. - await viteBuild({rootDir: ROOT_DIR, bundles: WORKER_BUNDLES}); + if (!args.no_build) { + await prebuild(prebuildOpts); + // Worker bundles are loaded via `new Worker(url)` at runtime, so they + // need to exist as real files in dist/v<version>/ in dev too. + await viteBuild({rootDir: ROOT_DIR, bundles: WORKER_BUNDLES}); + } await viteDev({ rootDir: ROOT_DIR, outDir, version: VERSION, - port: DEFAULT_PORT, + host: serverHost, + port: serverPort ?? DEFAULT_PORT, + crossOriginIsolation: args.cross_origin_isolation, }); break; case 'preview': startStaticServer({ rootDir: ROOT_DIR, distRootDir: pjoin(outDir, 'dist'), - host: '127.0.0.1', + host: serverHost, + port: serverPort, defaultPort: DEFAULT_PORT, - portWasExplicit: false, - crossOriginIsolation: false, + portWasExplicit, + crossOriginIsolation: args.cross_origin_isolation, }); break; case 'test': - await prebuild({rootDir: ROOT_DIR, outDir, version: VERSION}); - await runUnitTests(); + if (!args.no_build) await prebuild(prebuildOpts); + await runUnitTests({testFilter: args.test_filter}); break; case 'typecheck': { + // typecheck only touches tsc/gen, not outDir/dist — safe to coexist + // with a running dev/preview, so no lock needed. const genDir = ensureDir(pjoin(outDir, 'tsc/gen')); await compileProtos({rootDir: ROOT_DIR, genDir}); await runStep( @@ -187,12 +314,13 @@ } } -async function runUnitTests() { +async function runUnitTests({testFilter} = {}) { const prevCwd = process.cwd(); process.chdir(pjoin(ROOT_DIR, 'ui')); try { await runInProcStep('vitest', async () => { - const vitest = await startVitest('test', [], { + const cliFilters = testFilter ? [testFilter] : []; + const vitest = await startVitest('test', cliFilters, { config: pjoin(ROOT_DIR, 'ui/vitest.config.mjs'), watch: false, });
diff --git a/ui/scripts/build_lock.mjs b/ui/scripts/build_lock.mjs new file mode 100644 index 0000000..18041ec --- /dev/null +++ b/ui/scripts/build_lock.mjs
@@ -0,0 +1,94 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Cooperative build lock. The risk this guards against: a long-running `dev` +// or `preview` is serving files out of outDir while a second process runs +// `build`/`pre` and wipes outDir mid-flight — the live server starts 404'ing. +// +// The lock file lives one level above outDir so it survives prebuild's +// `rm -rf outDir`. Anyone who needs outDir to stay stable (prebuild, dev, +// preview, test) acquires it for their whole lifetime. --no-build runs only +// peek at the lock and print a warning. + +import fs from 'node:fs'; +import path from 'node:path'; + +function lockFilePath(outDir) { + return path.join(path.dirname(outDir), '.build.lock'); +} + +// Reads the lock file and returns the PID of the holder if it's still alive, +// or null if the file is missing / the PID is dead. +function readLiveHolder(lockFile) { + if (!fs.existsSync(lockFile)) return null; + const pid = parseInt(fs.readFileSync(lockFile, 'utf8').trim(), 10); + if (!pid) return null; + try { + process.kill(pid, 0); // signal 0 = liveness check only. + return pid; + } catch { + return null; // stale. + } +} + +// Acquires the lock or exits with a clear message. The lock is released on +// process exit via an exit handler. +export function acquireBuildLock({outDir}) { + const lockFile = lockFilePath(outDir); + const holder = readLiveHolder(lockFile); + if (holder !== null && holder !== process.pid) { + console.error( + `Error: another ui build process (PID ${holder}) is using ${outDir}.`, + ); + console.error( + 'Hint: stop it first, or pass --no-build to skip the build step.', + ); + process.exit(1); + } + if (holder === null && fs.existsSync(lockFile)) { + // Stale. + fs.unlinkSync(lockFile); + } + fs.mkdirSync(path.dirname(lockFile), {recursive: true}); + fs.writeFileSync(lockFile, String(process.pid)); + const release = () => { + try { + const pid = parseInt(fs.readFileSync(lockFile, 'utf8').trim(), 10); + if (pid === process.pid) fs.unlinkSync(lockFile); + } catch { + // Already gone — fine. + } + }; + process.on('exit', release); + for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.once(sig, () => { + release(); + process.kill(process.pid, sig); + }); + } +} + +// Read-only check. Prints a warning if the lock is held by someone else. +// Used by --no-build callers that read outDir without modifying it. +export function warnIfBuildLockHeld({outDir}) { + const holder = readLiveHolder(lockFilePath(outDir)); + if (holder !== null && holder !== process.pid) { + console.warn( + `Warning: another ui build process (PID ${holder}) is using ${outDir}.`, + ); + console.warn( + " Its output may change underneath us; --no-build doesn't take the lock.", + ); + } +}
diff --git a/ui/scripts/prebuild.mjs b/ui/scripts/prebuild.mjs index d043d7d..cbf4985 100644 --- a/ui/scripts/prebuild.mjs +++ b/ui/scripts/prebuild.mjs
@@ -17,6 +17,7 @@ // index.html. import {spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import {buildWasm, copySyntaqliteRuntime} from './build_wasm.mjs'; @@ -45,8 +46,20 @@ 'protos/perfetto/trace_processor/trace_processor.proto', ]; -export async function prebuild({rootDir, outDir, version}) { - await checkBuildDeps({rootDir, outDir}); +export async function prebuild({ + rootDir, + outDir, + version, + debug = false, + skipWasm = false, + skipDepscheck = false, + noOverrideGnArgs = false, + onlyWasmMemory64 = false, + titleOverride = '', +}) { + if (!skipDepscheck) { + await checkBuildDeps({rootDir, outDir}); + } // Wipe the UI out dir for a clean prod build. Done up front so both // prebuild and prod write into a known-empty tree. @@ -64,23 +77,29 @@ // coexist in the GCS bucket and clients can swap atomically. const distDir = ensureDir(pjoin(distRootDir, version)); const genDir = ensureDir(pjoin(outDir, 'tsc/gen')); - // Generated .js under tsc/gen (e.g. protos.js) does `require('protobufjs')` - // etc. Node's resolver walks up from the file's directory; without this - // symlink it never finds the real node_modules under ui/. - const nodeModulesLink = pjoin(outDir, 'tsc/node_modules'); - if (!fs.existsSync(nodeModulesLink)) { - fs.symlinkSync(pjoin(rootDir, 'ui/node_modules'), nodeModulesLink); - } + + await runInProcStep('update symlinks', () => + updateSymlinks({rootDir, outDir, genDir}), + ); const run = (label, cmd, args) => runStep(label, cmd, args, {cwd: pjoin(rootDir, 'ui')}); + // memory64 always builds; the regular trace_processor is optional so + // --only-wasm-memory64 can shave time off when iterating on it. + const wasmModules = onlyWasmMemory64 + ? WASM_MODULES.filter((m) => m !== 'trace_processor') + : WASM_MODULES; + await buildWasm({ rootDir, ninjaOutDir, distDir, genDir, - wasmModules: WASM_MODULES, + wasmModules, + debug, + skipBuild: skipWasm, + noOverrideGnArgs, run, }); await copySyntaqliteRuntime({rootDir, distRootDir: distDir, run}); @@ -94,12 +113,42 @@ }), ); await runInProcStep('write index.html', () => - writeIndexHtml({rootDir, distRootDir, distDir, version}), + writeIndexHtml({rootDir, distRootDir, distDir, version, titleOverride}), ); return {distRootDir, distDir, genDir}; } +// Sets up the symlinks that the rest of the build (Vite config, tsc, runtime +// asset loading) assumes. Recreated each build to keep them pointing at the +// current outDir. +function updateSymlinks({rootDir, outDir, genDir}) { + // ui/out → <outDir> (Vite uses ui/out as a stable path to the build dir). + mklink(outDir, pjoin(rootDir, 'ui/out')); + // ui/src/gen → <genDir> (TS imports resolve `gen/protos` etc through this). + mklink(genDir, pjoin(rootDir, 'ui/src/gen')); + // tsc/node_modules → ui/node_modules (the generated .js in tsc/gen does + // require('protobufjs'); Node walks up from the file's dir looking for + // node_modules). + mklink( + pjoin(rootDir, 'ui/node_modules'), + pjoin(outDir, 'tsc', 'node_modules'), + ); +} + +// Creates or updates a symlink at |dst| pointing to |src|. No-op if it +// already points at the right place (avoids touching mtimes). +function mklink(src, dst) { + if (fs.existsSync(dst)) { + if (fs.lstatSync(dst).isSymbolicLink() && fs.readlinkSync(dst) === src) { + return; + } + fs.unlinkSync(dst); + } + ensureDir(path.dirname(dst)); + fs.symlinkSync(src, dst); +} + // Checks buildtools/ matches the pinned versions in tools/install-build-deps. // The script writes |checkDepsPath| as a stamp on success, then short-circuits // on subsequent runs via --check-only. macOS Apple Silicon: force arm64 since @@ -198,9 +247,47 @@ // 'stable' channel to this build, ensuring the channel loader picks the // locally-built bundles instead of whatever's cached in localStorage or // baked into the source index.html. -function writeIndexHtml({rootDir, distRootDir, distDir, version}) { +// Walks |distDir| and writes a manifest.json mapping each relative path to +// its sha256. The service worker reads this to validate cached files. Skips +// source maps, the manifest itself, and the archival index.html (only the +// root /index.html is fetched by the SW). +export function genServiceWorkerManifestJson({distDir}) { + const manifest = {resources: {}}; + const skipRegex = /(\.map|manifest\.json|index\.html)$/; + const walk = (dir) => { + for (const child of fs.readdirSync(dir)) { + const childPath = pjoin(dir, child); + const stat = fs.lstatSync(childPath); + if (skipRegex.test(child)) continue; + if (stat.isDirectory()) { + walk(childPath); + } else if (!stat.isSymbolicLink()) { + const contents = fs.readFileSync(childPath); + const relPath = path.relative(distDir, childPath); + const b64 = crypto + .createHash('sha256') + .update(contents) + .digest('base64'); + manifest.resources[relPath] = 'sha256-' + b64; + } + } + }; + walk(distDir); + fs.writeFileSync( + pjoin(distDir, 'manifest.json'), + JSON.stringify(manifest, null, 2), + ); +} + +function writeIndexHtml({rootDir, distRootDir, distDir, version, titleOverride}) { const src = pjoin(rootDir, 'ui/src/assets/index.html'); - const html = fs.readFileSync(src, 'utf8'); + let html = fs.readFileSync(src, 'utf8'); + if (titleOverride) { + html = html.replace( + /<title>[^<]*<\/title>/, + `<title>${titleOverride}</title>`, + ); + } fs.writeFileSync(pjoin(distDir, 'index.html'), html); const versionMap = JSON.stringify({stable: version}); const patched = html.replace(
diff --git a/ui/scripts/vite_runner.mjs b/ui/scripts/vite_runner.mjs index f6d78b8..0b2f2bc 100644 --- a/ui/scripts/vite_runner.mjs +++ b/ui/scripts/vite_runner.mjs
@@ -34,6 +34,9 @@ // doesn't intercept. export const WORKER_BUNDLES = ['engine', 'traceconv']; +// Optional bundles, only built when the corresponding flag is passed. +export const OPEN_PERFETTO_TRACE_BUNDLE = 'open_perfetto_trace'; + // Runs `vite build` in-process for each named bundle. The config file // (ui/vite.config.mjs) selects its input from process.env.BUNDLE, so we set // that before each call. Bundles are built sequentially: vite-plugin-checker @@ -68,7 +71,14 @@ // (wasm modules, fonts under /assets/, etc.). // - / and /index.html serve the patched ui/src/assets/index.html via // server.transformIndexHtml so pluginPatchIndexHtml fires. -export async function viteDev({rootDir, outDir, version, port}) { +export async function viteDev({ + rootDir, + outDir, + version, + host = '127.0.0.1', + port, + crossOriginIsolation = false, +}) { // Static files (wasm, fonts, etc.) live under dist/v<version>/. We serve // them at root-relative URLs in dev because the patched index.html sets // version='.' (see pluginPatchIndexHtml). @@ -78,12 +88,19 @@ process.chdir(pjoin(rootDir, 'ui')); let server; try { + const headers = crossOriginIsolation + ? { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + } + : undefined; server = await vite.createServer({ configFile: pjoin(rootDir, 'ui/vite.config.mjs'), server: { - host: '127.0.0.1', + host, port, strictPort: false, + headers, fs: {allow: [rootDir]}, }, });