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]},
       },
     });