Compare commits

...
Author SHA1 Message Date
bincxzandClaude Opus 5.5 23930f2555 perf: add opt-in startup timeline and real-app startup harness
- electron/perfTrace.cjs records first-occurrence startup marks in the main
  process when NETCATTY_PERF_TRACE=1 (a no-op otherwise), then writes the
  main-window renderer timeline (navigation, paint, netcatty:* marks, JS heap)
  and app.getAppMetrics() to JSON after the first renderer-ready signal.
- Marks: main.cjs entry / modules loaded / whenReady, main window creation,
  registerBridges and plugin-host start/end, loadURL, did-finish-load,
  renderer ready, window shown. The renderer adds three performance.mark()
  calls (entry evaluated, App mounted, renderer ready), also visible in
  DevTools.
- scripts/perf-startup.live.cjs (npm run perf:startup) launches the built app
  N times against a throwaway --user-data-dir profile with ssh:// deep-link
  registration disabled, optionally seeds synthetic hosts over CDP, and
  reports cold plus warm p50/p90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:34:47 +08:00
11 changed files with 553 additions and 1 deletions

No files matched your search

+3
View File
@@ -50,6 +50,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
*/
function App() {
useEffect(() => {
performance.mark('netcatty:renderer:app-mounted');
}, []);
return (
<AppLocalStateProvider>
<MonitorRuntimeHost />
+1
View File
@@ -112,6 +112,7 @@ export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGatePr
setTimeout(() => splash.remove(), 200);
}
if (shouldNotifyRendererReady) {
performance.mark('netcatty:renderer:ready');
notifyAppLockRendererReady();
}
} catch {
+8 -1
View File
@@ -7,6 +7,7 @@ const path = require("node:path");
const fs = require("node:fs");
const { safeSend } = require("./ipcUtils.cjs");
const perfTrace = require("../perfTrace.cjs");
const V8_CACHE_OPTIONS = "bypassHeatCheck";
@@ -763,7 +764,10 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true,
return;
}
try {
if (!win.isDestroyed()) win.show();
if (!win.isDestroyed()) {
win.show();
perfTrace.mark("main:window-shown");
}
} catch {
// ignore
}
@@ -1398,6 +1402,9 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
ipcMain.on("netcatty:renderer:ready", (event) => {
const wcId = event?.sender?.id;
if (!wcId) return;
if (perfTrace.isEnabled()) {
perfTrace.noteRendererReady({ app: require("electron").app, webContents: event.sender });
}
resolveRendererReady(wcId);
});
}
@@ -3,6 +3,7 @@ const {
windowsFramelessContentChromeOptions,
} = require("./windowsWindowChrome.cjs");
const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const perfTrace = require("../../perfTrace.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
@@ -258,6 +259,7 @@ function createMainWindowApi(ctx) {
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
perfTrace.mark("main:create-window");
const win = new BrowserWindow({
...windowBounds,
minWidth: windowMinimumSize.width,
@@ -692,7 +694,9 @@ function createMainWindowApi(ctx) {
// Register IPC handlers BEFORE loading any URL so the renderer never
// calls a handler that hasn't been registered yet.
perfTrace.mark("main:register-bridges:start");
onRegisterBridge?.(win);
perfTrace.mark("main:register-bridges:end");
if (startHidden) {
// Belt-and-suspenders: guarantee a tray icon exists as soon as
@@ -718,6 +722,10 @@ function createMainWindowApi(ctx) {
}
// Production mode - load via custom protocol.
if (perfTrace.isEnabled()) {
win.webContents.once("did-finish-load", () => perfTrace.mark("main:did-finish-load"));
}
perfTrace.mark("main:load-url");
await win.loadURL(`app://netcatty/index.html${rendererHash}`);
return win;
}
+6
View File
@@ -18,6 +18,10 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Opt-in startup timeline (NETCATTY_PERF_TRACE=1); a no-op otherwise.
const perfTrace = require("./perfTrace.cjs");
perfTrace.mark("main:entry");
// Load crash log bridge early so process-level error handlers can use it
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
const {
@@ -1321,7 +1325,9 @@ if (!gotLock) {
});
// Application lifecycle
perfTrace.mark("main:modules-loaded");
app.whenReady().then(async () => {
perfTrace.mark("main:when-ready");
registerAppProtocol();
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
enabled: sshDeepLinkEnabled,
+3
View File
@@ -2,6 +2,7 @@
let bridgesRegistered = false;
let cloudSyncSessionPassword = null;
const perfTrace = require("../perfTrace.cjs");
const { readClipboardFiles, readClipboardImage, hasClipboardImage } = require("../bridges/clipboardFiles.cjs");
const { registerSystemNotificationHandlers } = require("../bridges/systemNotification.cjs");
const {
@@ -149,6 +150,7 @@ function createBridgeRegistrar(context) {
let terminalWorkerManager = null;
const { toElectronAccelerator } = require("../plugins/keybindings.cjs");
const { startPluginHost } = require("../plugins/hostBootstrap.cjs");
perfTrace.mark("main:plugin-host:start");
const pluginHostService = startPluginHost({
env: process.env,
createService: () => {
@@ -206,6 +208,7 @@ function createBridgeRegistrar(context) {
},
logger: console,
});
perfTrace.mark("main:plugin-host:end");
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
interactionService: pluginHostService?.interactionService,
+130
View File
@@ -0,0 +1,130 @@
"use strict";
/**
* Opt-in startup timeline used by scripts/perf-startup.live.cjs.
*
* Disabled unless NETCATTY_PERF_TRACE=1: every export is then a cheap no-op,
* so production startup is unaffected. When enabled, the first occurrence of
* each mark is recorded as epoch milliseconds (performance.timeOrigin based,
* comparable with the renderer's own marks). After the first renderer-ready
* signal plus a settle delay, the main-window renderer timeline and process
* metrics are written to NETCATTY_PERF_TRACE_FILE (default:
* <userData>/perf-trace.json); NETCATTY_PERF_TRACE_EXIT=1 then quits the app.
*/
const fs = require("node:fs");
const path = require("node:path");
const { performance } = require("node:perf_hooks");
const DEFAULT_SETTLE_MS = 3000;
function createPerfTrace({
env = process.env,
now = () => performance.timeOrigin + performance.now(),
uptimeSeconds = () => process.uptime(),
setTimer = setTimeout,
} = {}) {
const enabled = env.NETCATTY_PERF_TRACE === "1";
const processStartAt = now() - uptimeSeconds() * 1000;
const marks = new Map();
let reportScheduled = false;
function mark(name) {
if (!enabled || marks.has(name)) return;
marks.set(name, now());
}
function getMarks() {
return Object.fromEntries(marks);
}
function resolveSettleMs() {
const parsed = Number(env.NETCATTY_PERF_TRACE_SETTLE_MS);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_SETTLE_MS;
}
async function collectRendererTimeline(webContents) {
try {
return await webContents.executeJavaScript(`(() => {
const origin = performance.timeOrigin;
const nav = performance.getEntriesByType("navigation")[0];
return {
timeOrigin: origin,
navigation: nav ? {
responseEnd: origin + nav.responseEnd,
domInteractive: origin + nav.domInteractive,
domContentLoaded: origin + nav.domContentLoadedEventEnd,
load: origin + nav.loadEventEnd,
} : null,
marks: performance.getEntriesByType("mark")
.filter((entry) => entry.name.startsWith("netcatty:"))
.map((entry) => ({ name: entry.name, at: origin + entry.startTime })),
paint: performance.getEntriesByType("paint")
.map((entry) => ({ name: entry.name, at: origin + entry.startTime })),
heap: performance.memory ? {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
} : null,
};
})()`);
} catch (error) {
return { error: String(error?.message || error) };
}
}
function collectProcessMetrics(app) {
try {
return app.getAppMetrics().map((metric) => ({
type: metric.type,
workingSetKB: metric.memory?.workingSetSize ?? null,
privateKB: metric.memory?.privateBytes ?? null,
}));
} catch (error) {
return { error: String(error?.message || error) };
}
}
/**
* Called for every renderer-ready IPC. Only the first one (the startup main
* window) schedules the report.
*/
function noteRendererReady({ app, webContents }) {
if (!enabled) return;
mark("main:renderer-ready");
if (reportScheduled || !webContents) return;
reportScheduled = true;
setTimer(async () => {
const report = {
version: 1,
platform: process.platform,
arch: process.arch,
electron: process.versions.electron,
processStartAt,
main: getMarks(),
renderer: await collectRendererTimeline(webContents),
processes: collectProcessMetrics(app),
mainHeapUsedBytes: process.memoryUsage().heapUsed,
};
const outputFile = env.NETCATTY_PERF_TRACE_FILE
|| path.join(app.getPath("userData"), "perf-trace.json");
try {
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2));
} catch (error) {
console.warn("[PerfTrace] Failed to write report:", error?.message || error);
}
if (env.NETCATTY_PERF_TRACE_EXIT === "1") app.quit();
}, resolveSettleMs());
}
return {
isEnabled: () => enabled,
mark,
getMarks,
noteRendererReady,
};
}
module.exports = {
createPerfTrace,
...createPerfTrace(),
};
+87
View File
@@ -0,0 +1,87 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createPerfTrace } = require("./perfTrace.cjs");
function fakeClock(start = 1_000) {
let current = start;
return {
now: () => current,
advance: (ms) => { current += ms; },
};
}
function captureTimers() {
const pending = [];
return {
setTimer: (fn, ms) => { pending.push({ fn, ms }); return pending.length; },
pending,
};
}
test("perfTrace is a no-op unless NETCATTY_PERF_TRACE=1", () => {
const timers = captureTimers();
const trace = createPerfTrace({ env: {}, setTimer: timers.setTimer });
trace.mark("main:entry");
trace.noteRendererReady({ app: {}, webContents: {} });
assert.equal(trace.isEnabled(), false);
assert.deepEqual(trace.getMarks(), {});
assert.equal(timers.pending.length, 0);
});
test("perfTrace keeps only the first occurrence of each mark", () => {
const clock = fakeClock();
const trace = createPerfTrace({ env: { NETCATTY_PERF_TRACE: "1" }, now: clock.now, uptimeSeconds: () => 0.5 });
trace.mark("main:entry");
clock.advance(25);
trace.mark("main:entry");
trace.mark("main:when-ready");
assert.deepEqual(trace.getMarks(), { "main:entry": 1_000, "main:when-ready": 1_025 });
});
test("perfTrace writes one report after the first renderer-ready and quits when asked", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-perf-trace-"));
const outputFile = path.join(dir, "trace.json");
const timers = captureTimers();
const trace = createPerfTrace({
env: {
NETCATTY_PERF_TRACE: "1",
NETCATTY_PERF_TRACE_FILE: outputFile,
NETCATTY_PERF_TRACE_EXIT: "1",
NETCATTY_PERF_TRACE_SETTLE_MS: "50",
},
now: fakeClock(5_000).now,
uptimeSeconds: () => 2,
setTimer: timers.setTimer,
});
let quitCalls = 0;
const app = {
getAppMetrics: () => [{ type: "Browser", memory: { workingSetSize: 2048, privateBytes: 1024 } }],
getPath: () => dir,
quit: () => { quitCalls += 1; },
};
const webContents = {
executeJavaScript: async () => ({ marks: [{ name: "netcatty:renderer:ready", at: 5_400 }] }),
};
trace.mark("main:entry");
trace.noteRendererReady({ app, webContents });
trace.noteRendererReady({ app, webContents });
assert.equal(timers.pending.length, 1, "only the first renderer-ready schedules a report");
assert.equal(timers.pending[0].ms, 50);
await timers.pending[0].fn();
const report = JSON.parse(fs.readFileSync(outputFile, "utf8"));
assert.equal(report.processStartAt, 3_000);
assert.equal(report.main["main:entry"], 5_000);
assert.equal(report.main["main:renderer-ready"], 5_000);
assert.deepEqual(report.renderer.marks, [{ name: "netcatty:renderer:ready", at: 5_400 }]);
assert.deepEqual(report.processes, [{ type: "Browser", workingSetKB: 2048, privateKB: 1024 }]);
assert.equal(quitCalls, 1);
fs.rmSync(dir, { recursive: true, force: true });
});
+3
View File
@@ -14,6 +14,9 @@ import '@fontsource/jetbrains-mono/600.css';
import App from './App';
import { AppLockGate } from './components/AppLockGate';
// Startup timeline marks (see electron/perfTrace.cjs); visible in DevTools too.
performance.mark('netcatty:renderer:entry-evaluated');
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
const LazyTerminalPopupPage = lazy(() => import('./components/TerminalPopupPage'));
+1
View File
@@ -65,6 +65,7 @@
"test:plugin-runtime:electron": "electron electron/plugins/electronRuntime.live.test.cjs",
"test:sync-crdt": "node --test --import tsx domain/convergentSync/*.test.ts",
"bench:sync-crdt": "tsx scripts/bench-sync-crdt.ts",
"perf:startup": "node scripts/perf-startup.live.cjs",
"test:fido-sim": "cross-env NETCATTY_FIDO_SIM=1 node --test scripts/fido-auth.live.test.cjs",
"test:fido-docker": "cross-env NETCATTY_FIDO_DOCKER=1 node --test scripts/fido-auth.docker.test.cjs",
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env node
"use strict";
/**
* Startup performance harness for the real app (not a fixture).
*
* npm run build # the harness measures the built dist/ bundle
* npm run perf:startup -- --runs 10 --hosts 2000
*
* Every run launches Electron against an isolated, throwaway profile
* (--user-data-dir) so the user's own data, sessions and running Netcatty are
* never touched. ssh:// deep-link registration is disabled in that profile
* before the first launch, so the dev Electron binary never becomes the OS
* protocol handler. The app records its own timeline through
* electron/perfTrace.cjs (NETCATTY_PERF_TRACE=1), writes it as JSON and quits.
*
* Options:
* --runs N measured launches (default 10; run 1 is reported as cold)
* --hosts N seed N synthetic SSH hosts before measuring (default 0)
* --settle-ms N wait after renderer-ready before sampling memory (default 3000)
* --json print the summary as JSON instead of a table
* --keep-profile keep the temporary profile for inspection
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const { applyElectronLaunchEnv } = require("../electron/launchEnv.cjs");
const repoRoot = path.resolve(__dirname, "..");
const HOSTS_STORAGE_KEY = "netcatty_hosts_v1";
const LAUNCH_TIMEOUT_MS = 120_000;
function parseArgs(argv) {
const options = { runs: 10, hosts: 0, settleMs: 3000, json: false, keepProfile: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = () => Number(argv[++index]);
if (arg === "--runs") options.runs = next();
else if (arg === "--hosts") options.hosts = next();
else if (arg === "--settle-ms") options.settleMs = next();
else if (arg === "--json") options.json = true;
else if (arg === "--keep-profile") options.keepProfile = true;
else throw new Error(`Unknown option: ${arg}`);
}
if (!Number.isInteger(options.runs) || options.runs < 1) throw new Error("--runs must be a positive integer");
if (!Number.isInteger(options.hosts) || options.hosts < 0) throw new Error("--hosts must be a non-negative integer");
return options;
}
function electronBinary() {
// In plain Node, requiring "electron" returns the binary path.
return require("electron");
}
function launchEnv(extra = {}) {
const env = applyElectronLaunchEnv(process.env);
delete env.VITE_DEV_SERVER_URL; // always measure the built app:// bundle
return { ...env, ...extra };
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
if (child.exitCode !== null) {
resolve(child.exitCode);
return;
}
const timer = setTimeout(() => {
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
}, timeoutMs);
child.once("exit", (code, signal) => {
clearTimeout(timer);
resolve(code ?? signal);
});
});
}
function launchApp(profileDir, { env, extraArgs = [], logFile }) {
const child = spawn(electronBinary(), [repoRoot, `--user-data-dir=${profileDir}`, ...extraArgs], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
const log = fs.createWriteStream(logFile, { flags: "a" });
child.stdout.pipe(log);
child.stderr.pipe(log);
return child;
}
async function waitFor(predicate, timeoutMs, label) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`Timed out waiting for ${label}`);
}
function cdpEvaluate(webSocketDebuggerUrl, expression) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(webSocketDebuggerUrl);
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: { expression, awaitPromise: true, returnByValue: true },
}));
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== 1) return;
socket.close();
if (message.error || message.result?.exceptionDetails) {
reject(new Error(JSON.stringify(message.error || message.result.exceptionDetails)));
} else {
resolve(message.result?.result?.value);
}
});
socket.addEventListener("error", () => reject(new Error("CDP socket error")));
});
}
function buildSyntheticHosts(count) {
const hosts = [];
for (let index = 0; index < count; index += 1) {
hosts.push({
id: `perf-host-${index}`,
label: `perf-host-${String(index).padStart(5, "0")}`,
hostname: `10.${(index >> 16) & 255}.${(index >> 8) & 255}.${index & 255}`,
port: 22,
username: "perf",
protocol: "ssh",
group: `perf-group-${index % 40}`,
tags: index % 3 === 0 ? ["perf", `tag-${index % 7}`] : [],
createdAt: 1_700_000_000_000 + index,
});
}
return hosts;
}
async function seedHosts(profileDir, count, logFile) {
const child = launchApp(profileDir, {
env: launchEnv(),
extraArgs: ["--remote-debugging-port=0"],
logFile,
});
try {
const portFile = path.join(profileDir, "DevToolsActivePort");
const port = await waitFor(() => {
try {
return Number(fs.readFileSync(portFile, "utf8").split("\n")[0]) || null;
} catch {
return null;
}
}, 60_000, "DevToolsActivePort");
const page = await waitFor(async () => {
try {
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
return targets.find((target) => target.type === "page" && target.url.startsWith("app://netcatty/index.html"));
} catch {
return null;
}
}, 60_000, "main window target");
const hostsJson = JSON.stringify(buildSyntheticHosts(count));
const stored = await cdpEvaluate(page.webSocketDebuggerUrl, `(() => {
localStorage.setItem(${JSON.stringify(HOSTS_STORAGE_KEY)}, ${JSON.stringify(hostsJson)});
return JSON.parse(localStorage.getItem(${JSON.stringify(HOSTS_STORAGE_KEY)})).length;
})()`);
if (stored !== count) throw new Error(`Seeded ${stored} hosts, expected ${count}`);
// Let DOM storage commit, then quit gracefully so it is flushed to disk.
await new Promise((resolve) => setTimeout(resolve, 1500));
child.kill("SIGTERM");
await waitForExit(child, 30_000);
} catch (error) {
child.kill("SIGKILL");
throw error;
}
}
async function measureRun(profileDir, index, options, logFile) {
const traceFile = path.join(profileDir, `perf-trace-${index}.json`);
fs.rmSync(traceFile, { force: true });
const child = launchApp(profileDir, {
env: launchEnv({
NETCATTY_PERF_TRACE: "1",
NETCATTY_PERF_TRACE_FILE: traceFile,
NETCATTY_PERF_TRACE_EXIT: "1",
NETCATTY_PERF_TRACE_SETTLE_MS: String(options.settleMs),
}),
logFile,
});
await waitForExit(child, LAUNCH_TIMEOUT_MS);
if (!fs.existsSync(traceFile)) throw new Error(`Run ${index}: no trace written (see ${logFile})`);
return JSON.parse(fs.readFileSync(traceFile, "utf8"));
}
function rendererMark(report, name) {
return report.renderer?.marks?.find((entry) => entry.name === name)?.at ?? null;
}
function paintAt(report, name) {
return report.renderer?.paint?.find((entry) => entry.name === name)?.at ?? null;
}
function sinceStart(report, at) {
return Number.isFinite(at) ? at - report.processStartAt : null;
}
function between(from, to) {
return Number.isFinite(from) && Number.isFinite(to) ? to - from : null;
}
function summarizeRun(report) {
const main = report.main || {};
const processes = Array.isArray(report.processes) ? report.processes : [];
const workingSetMB = (type) => processes
.filter((proc) => !type || proc.type === type)
.reduce((sum, proc) => sum + (proc.workingSetKB || 0), 0) / 1024;
return {
"electron boot → main.cjs": sinceStart(report, main["main:entry"]),
"main.cjs top-level requires": between(main["main:entry"], main["main:modules-loaded"]),
"app ready": sinceStart(report, main["main:when-ready"]),
"registerBridges": between(main["main:register-bridges:start"], main["main:register-bridges:end"]),
" of which plugin host": between(main["main:plugin-host:start"], main["main:plugin-host:end"]),
"loadURL called": sinceStart(report, main["main:load-url"]),
"renderer entry evaluated": sinceStart(report, rendererMark(report, "netcatty:renderer:entry-evaluated")),
"first contentful paint": sinceStart(report, paintAt(report, "first-contentful-paint")),
"App mounted": sinceStart(report, rendererMark(report, "netcatty:renderer:app-mounted")),
"renderer ready": sinceStart(report, rendererMark(report, "netcatty:renderer:ready")),
"window shown": sinceStart(report, main["main:window-shown"]),
"memory total (MB)": workingSetMB(),
"memory renderer (MB)": workingSetMB("Tab"),
"memory main (MB)": workingSetMB("Browser"),
"renderer JS heap (MB)": report.renderer?.heap ? report.renderer.heap.usedJSHeapSize / 1048576 : null,
};
}
function percentile(values, p) {
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
if (sorted.length === 0) return null;
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[rank];
}
function aggregate(summaries) {
const cold = summaries[0];
const warm = summaries.slice(1);
const result = {};
for (const metric of Object.keys(cold)) {
const warmValues = warm.map((summary) => summary[metric]);
result[metric] = {
cold: cold[metric],
warmP50: percentile(warmValues, 50),
warmP90: percentile(warmValues, 90),
};
}
return result;
}
function formatValue(value) {
return Number.isFinite(value) ? value.toFixed(0).padStart(8) : " -";
}
function printTable(aggregated, options) {
console.log(`\nNetcatty startup (${options.runs} runs, ${options.hosts} seeded hosts; ms unless noted)`);
console.log(`${"metric".padEnd(30)}${"cold".padStart(8)}${"warm p50".padStart(10)}${"warm p90".padStart(10)}`);
for (const [metric, value] of Object.entries(aggregated)) {
console.log(`${metric.padEnd(30)}${formatValue(value.cold)} ${formatValue(value.warmP50)} ${formatValue(value.warmP90)}`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (!fs.existsSync(path.join(repoRoot, "dist", "index.html"))) {
throw new Error("dist/index.html not found. Run `npm run build` first.");
}
const profileDir = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("perf-startup")}-`);
const logFile = path.join(profileDir, "electron.log");
// Keep the throwaway profile from becoming the OS ssh:// / telnet:// handler.
fs.writeFileSync(path.join(profileDir, "ssh-deep-link-preferences.json"), JSON.stringify({ enabled: false }));
try {
if (options.hosts > 0) await seedHosts(profileDir, options.hosts, logFile);
const summaries = [];
for (let index = 1; index <= options.runs; index += 1) {
const report = await measureRun(profileDir, index, options, logFile);
summaries.push(summarizeRun(report));
if (!options.json) process.stderr.write(`run ${index}/${options.runs} done\n`);
}
const aggregated = aggregate(summaries);
if (options.json) console.log(JSON.stringify({ options, aggregated, runs: summaries }, null, 2));
else printTable(aggregated, options);
} finally {
if (options.keepProfile) console.error(`Profile kept at ${profileDir}`);
else fs.rmSync(profileDir, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error?.stack || error);
process.exitCode = 1;
});