mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-04 04:52:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23930f2555 |
No files matched your search
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user