[eric] perf: stopwatch the boot + headcount every file we ship

This commit is contained in:
Eric
2026-05-27 10:19:53 -07:00
parent 6a0e437c8c
commit 0d561fa49d
5 changed files with 461 additions and 0 deletions
@@ -28,6 +28,11 @@ import { upsertOutput } from '../state/outputsSlice';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
// Phase 0 boot instrumentation: one-shot flag so we report the first streamed
// agent token to Electron main exactly once per app launch. Module scope (not
// instance) because multiple WebSocketManagers exist (one per session WS).
let firstAgentResponseMarked = false;
// Thin wrapper around getAuthToken so the connect() call site stays
// synchronous. If the token isn't cached yet, returns '' and the WS
// handshake will 4401 , onclose catches that and refreshes the token
@@ -177,6 +182,14 @@ class WebSocketManager {
// ONE React render per animation frame, so removing the pacing layer
// doesn't reintroduce the parallel-agent re-render storm.
private dispatchDelta(sessionId: string, messageId: string, delta: string) {
// Phase 0 boot instrumentation: the first streamed agent token is the
// "app is actually useful" milestone. Report it once to the Electron main
// process, which owns the timing log. Guarded by a module-level flag so
// this is a single no-op branch on every subsequent token.
if (!firstAgentResponseMarked) {
firstAgentResponseMarked = true;
try { (window as any).openswarm?.markFirstAgentResponse?.(); } catch { /* not in Electron */ }
}
store.dispatch(streamDelta({ sessionId, messageId, delta }));
}
+61
View File
@@ -0,0 +1,61 @@
# Boot + size instrumentation (Phase 0 baseline)
Tooling to measure two things we used to guess at: how long a cold launch takes,
and how many files we make the OS (and Defender) scan. These numbers are the
baseline that Phase 6 (slimming) and Phase 7 (Squirrel vs NSIS) are judged against.
## Boot timing
`electron/main.js` emits four ordered milestones to `backend.log`, one line each:
```
[perf] app-launch t=<ms>
[perf] first-paint t=<ms>
[perf] backend-http-ready t=<ms>
[perf] first-agent-response t=<ms>
```
`t` is milliseconds since process start. `first-agent-response` is fired by the
renderer (`WebSocketManager.dispatchDelta`) on the first streamed agent token.
Read and assert them after launching a packaged build:
```
node scripts/perf/parse-timing.js # human table, exits 1 if missing/unordered
node scripts/perf/parse-timing.js --json # machine-readable
node scripts/perf/parse-timing.js --log <path-to-backend.log>
```
backend.log lives next to `auth.token`:
- macOS: `~/Library/Application Support/OpenSwarm/data/backend.log`
- Windows: `%APPDATA%\OpenSwarm\data\backend.log`
- Linux: `~/.local/share/OpenSwarm/data/backend.log`
## File count / size
```
node scripts/perf/file-count.js # auto-detects packaged tree
node scripts/perf/file-count.js --root <resources-dir> # explicit
node scripts/perf/file-count.js --json
```
### Recorded baseline (Windows, win-unpacked/resources)
| dir | files | size |
|-------------|--------|---------|
| python-env | 8421 | 373.6 MB |
| router | 2651 | 35.9 MB |
| backend | 86 | 8.2 MB |
| frontend | 11 | 232.3 MB |
| debugger | 75 | 1.2 MB |
| other | 3 | 621.3 MB |
| **TOTAL** | 11247 | 1.2 GB |
`python-env` is by far the largest file count, so it dominates Defender's
per-file scan cost on first launch. That is the Phase 6a target.
## Tests
`node scripts/perf/test-perf.js` builds throwaway fixtures with known counts and
known timing logs, runs both tools against them, and asserts exact numbers plus
that the failure paths fail. Hermetic; needs no packaged build. 17 assertions.
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
// Phase 0 baseline: walk the packaged output and report a per-directory file
// count and byte size for the heavy resource dirs we ship, plus a grand total.
// This is the number we measure Phase 6 (python-env/node_modules slimming) and
// Phase 7 (Squirrel vs NSIS) against, so it must be deterministic: same tree in
// => same numbers out, regardless of OS or which arch was packaged.
//
// Usage:
// node scripts/perf/file-count.js [--root <dir>] [--json]
//
// With no --root it auto-detects the packaged resources dir for this platform,
// falling back to build-staging/ (the pre-package staging tree). Exits non-zero
// if it can't find anything to count so a CI gate notices a broken build.
'use strict';
const fs = require('fs');
const path = require('path');
// Top-level resource dirs we care about. Anything else in the root is folded
// into "other" so the grand total always reconciles with a raw walk.
const DIRS_OF_INTEREST = ['python-env', 'node', 'node_modules', 'mcp-servers', 'backend', 'frontend', 'router', 'debugger'];
function parseArgs(argv) {
const out = { root: null, json: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--root') out.root = argv[++i];
else if (argv[i] === '--json') out.json = true;
}
return out;
}
// Candidate locations for the packaged tree, most-specific first. We resolve
// relative to the repo root (two levels up from this file) so it works from any
// cwd. The mac .app Resources and the Windows win-unpacked/resources are the
// real shipped trees; build-staging is the pre-electron-builder snapshot.
function autodetectRoot(repoRoot) {
const dist = path.join(repoRoot, 'electron', 'dist');
const candidates = [
path.join(dist, 'win-unpacked', 'resources'),
path.join(dist, 'mac-arm64', 'OpenSwarm.app', 'Contents', 'Resources'),
path.join(dist, 'mac', 'OpenSwarm.app', 'Contents', 'Resources'),
path.join(dist, 'mac-universal', 'OpenSwarm.app', 'Contents', 'Resources'),
path.join(repoRoot, 'electron', 'build-staging'),
path.join(repoRoot, 'build-staging'),
];
return candidates.find((c) => safeIsDir(c)) || null;
}
function safeIsDir(p) {
try { return fs.statSync(p).isDirectory(); } catch { return false; }
}
// Recursive walk. Counts regular files (not directories), sums byte size.
// Symlinks are counted as files without following them, so a symlink loop can
// never hang the walk and the count stays deterministic.
function walk(dir) {
let files = 0;
let bytes = 0;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return { files, bytes }; }
for (const ent of entries) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
const sub = walk(full);
files += sub.files;
bytes += sub.bytes;
} else {
files += 1;
try { bytes += fs.statSync(full).size; } catch { /* vanished mid-walk */ }
}
}
return { files, bytes };
}
function fmtBytes(n) {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(__dirname, '..', '..');
const root = args.root ? path.resolve(args.root) : autodetectRoot(repoRoot);
if (!root || !safeIsDir(root)) {
process.stderr.write('file-count: no packaged tree found. Build first or pass --root.\n');
process.exit(2);
}
const report = { root, dirs: {}, total: { files: 0, bytes: 0 }, generatedAt: new Date().toISOString() };
const topEntries = fs.readdirSync(root, { withFileTypes: true });
const seen = new Set();
for (const name of DIRS_OF_INTEREST) {
const full = path.join(root, name);
if (safeIsDir(full)) {
report.dirs[name] = walk(full);
seen.add(name);
} else {
report.dirs[name] = { files: 0, bytes: 0, absent: true };
}
}
// "other" = everything in the root not already counted, so the printed total
// is the real total of the tree and can be cross-checked by an independent walk.
let other = { files: 0, bytes: 0 };
for (const ent of topEntries) {
if (seen.has(ent.name)) continue;
const full = path.join(root, ent.name);
if (ent.isDirectory()) { const s = walk(full); other.files += s.files; other.bytes += s.bytes; }
else { other.files += 1; try { other.bytes += fs.statSync(full).size; } catch { /* gone */ } }
}
report.dirs.other = other;
for (const k of Object.keys(report.dirs)) {
report.total.files += report.dirs[k].files;
report.total.bytes += report.dirs[k].bytes;
}
if (args.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
return;
}
process.stdout.write(`\nPackaged file count (root: ${root})\n`);
process.stdout.write(' ' + '-'.repeat(52) + '\n');
const rows = Object.keys(report.dirs).sort((a, b) => report.dirs[b].files - report.dirs[a].files);
for (const name of rows) {
const d = report.dirs[name];
const note = d.absent ? ' (absent)' : '';
process.stdout.write(` ${name.padEnd(16)} ${String(d.files).padStart(8)} files ${fmtBytes(d.bytes).padStart(10)}${note}\n`);
}
process.stdout.write(' ' + '-'.repeat(52) + '\n');
process.stdout.write(` ${'TOTAL'.padEnd(16)} ${String(report.total.files).padStart(8)} files ${fmtBytes(report.total.bytes).padStart(10)}\n\n`);
}
main();
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
// Phase 0 baseline: parse the four boot milestones out of backend.log and prove
// they are present and correctly ordered. main.js emits one line per milestone:
// [perf] <name> t=<ms-since-launch>
// We read the most recent launch block (delimited by "===== launch ... =====")
// so a re-run measures the latest boot, not a stale one.
//
// Usage:
// node scripts/perf/parse-timing.js [--log <path>] [--json]
//
// Exit 0 if all four present and ordered, 1 otherwise. Designed to be the
// assertion step of the Phase 0 test: launch the packaged app, then run this.
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
// Canonical order. app-launch must be first and first-agent-response last;
// first-paint and backend-http-ready sit between and may swap (lazy backend
// paints the shell before Python answers, eager backend does the reverse), so
// we only require they fall between the bookends, not a fixed order vs each other.
const REQUIRED = ['app-launch', 'first-paint', 'backend-http-ready', 'first-agent-response'];
function parseArgs(argv) {
const out = { log: null, json: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--log') out.log = argv[++i];
else if (argv[i] === '--json') out.json = true;
}
return out;
}
// Mirrors getAuthTokenFilePath()/getBackendLogPath() in electron/main.js.
function defaultLogPath() {
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'backend.log');
} else if (process.platform === 'win32') {
return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'backend.log');
}
const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(xdg, 'OpenSwarm', 'data', 'backend.log');
}
function fail(msg, report) {
if (report && report.json) { process.stdout.write(JSON.stringify({ ok: false, error: msg, ...report.data }, null, 2) + '\n'); }
else process.stderr.write(`FAIL: ${msg}\n`);
process.exit(1);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const logPath = args.log ? path.resolve(args.log) : defaultLogPath();
let raw;
try { raw = fs.readFileSync(logPath, 'utf8'); }
catch { fail(`cannot read log at ${logPath} (launch the packaged app first)`, { json: args.json, data: { logPath } }); return; }
// Take only the last launch block so we measure the most recent boot.
const marker = '===== launch';
const lastIdx = raw.lastIndexOf(marker);
const block = lastIdx >= 0 ? raw.slice(lastIdx) : raw;
const marks = {};
const re = /\[perf\]\s+(\S+)\s+t=(\d+)/g;
let m;
while ((m = re.exec(block)) !== null) {
// First occurrence wins (matches main.js one-shot guard); ignore later dupes.
if (!(m[1] in marks)) marks[m[1]] = Number(m[2]);
}
const report = { json: args.json, data: { logPath, marks } };
const missing = REQUIRED.filter((k) => !(k in marks));
if (missing.length) fail(`missing milestone(s): ${missing.join(', ')}`, report);
// Bookends: app-launch earliest, first-agent-response latest.
const t = marks;
if (t['app-launch'] !== Math.min(...REQUIRED.map((k) => t[k]))) fail('app-launch is not the earliest milestone', report);
if (t['first-agent-response'] !== Math.max(...REQUIRED.map((k) => t[k]))) fail('first-agent-response is not the latest milestone', report);
for (const k of REQUIRED) if (!Number.isFinite(t[k]) || t[k] < 0) fail(`milestone ${k} has invalid t=${t[k]}`, report);
const ordered = [...REQUIRED].sort((a, b) => t[a] - t[b]);
const result = {
ok: true,
logPath,
marks,
order: ordered.map((k) => `${k}@${t[k]}ms`),
durations: {
'launch->first-paint': t['first-paint'] - t['app-launch'],
'launch->backend-ready': t['backend-http-ready'] - t['app-launch'],
'launch->first-agent-response': t['first-agent-response'] - t['app-launch'],
},
};
if (args.json) { process.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
process.stdout.write('\nPASS: all four boot milestones present and ordered.\n');
process.stdout.write(` log: ${logPath}\n`);
for (const k of ordered) process.stdout.write(` ${k.padEnd(22)} ${String(t[k]).padStart(8)} ms\n`);
process.stdout.write(`\n launch -> first-agent-response: ${result.durations['launch->first-agent-response']} ms\n\n`);
}
main();
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
// Phase 0 test: deterministic, hermetic checks of the perf tooling. Builds
// throwaway fixtures in a temp dir with KNOWN file counts and KNOWN timing
// logs, runs file-count.js and parse-timing.js against them, and asserts the
// numbers come back exactly right plus that the failure paths actually fail.
// No packaged build required, so this runs identically on Win, Mac, and CI.
//
// node scripts/perf/test-perf.js
//
// Exit 0 = all assertions passed. Any failure exits 1 with the first mismatch.
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const HERE = __dirname;
const NODE = process.execPath;
let passed = 0;
function assert(cond, msg) {
if (!cond) { process.stderr.write(`\nASSERT FAILED: ${msg}\n`); process.exit(1); }
passed++;
}
function mktmp(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function writeFile(p, content) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
}
// Runs a script, returns { code, stdout, stderr }. Never throws on non-zero.
function run(script, extraArgs) {
try {
const stdout = execFileSync(NODE, [path.join(HERE, script), ...extraArgs], { encoding: 'utf8' });
return { code: 0, stdout, stderr: '' };
} catch (e) {
return { code: e.status == null ? -1 : e.status, stdout: e.stdout || '', stderr: e.stderr || '' };
}
}
// ---- file-count.js -------------------------------------------------------
function testFileCount() {
const root = mktmp('osw-fc-');
// Known layout: python-env=3 files, node=2, node_modules=4, mcp-servers=1,
// plus a stray top-level file that must land in "other"=1. total=11.
writeFile(path.join(root, 'python-env', 'a.py'), 'x');
writeFile(path.join(root, 'python-env', 'lib', 'b.py'), 'xx');
writeFile(path.join(root, 'python-env', 'lib', 'c.so'), 'xxx');
writeFile(path.join(root, 'node', 'x64', 'node.exe'), 'nn');
writeFile(path.join(root, 'node', 'arm64', 'node'), 'nn');
writeFile(path.join(root, 'node_modules', 'pkg', 'index.js'), 'a');
writeFile(path.join(root, 'node_modules', 'pkg', 'readme.md'), 'a');
writeFile(path.join(root, 'node_modules', 'dep', 'main.js'), 'a');
writeFile(path.join(root, 'node_modules', 'dep', 'types.d.ts'), 'a');
writeFile(path.join(root, 'mcp-servers', 'srv.js'), 'm');
writeFile(path.join(root, 'app.asar'), 'stray');
const r = run('file-count.js', ['--root', root, '--json']);
assert(r.code === 0, `file-count exited ${r.code}: ${r.stderr}`);
const rep = JSON.parse(r.stdout);
assert(rep.dirs['python-env'].files === 3, `python-env files=${rep.dirs['python-env'].files} expected 3`);
assert(rep.dirs['node'].files === 2, `node files=${rep.dirs['node'].files} expected 2`);
assert(rep.dirs['node_modules'].files === 4, `node_modules files=${rep.dirs['node_modules'].files} expected 4`);
assert(rep.dirs['mcp-servers'].files === 1, `mcp-servers files=${rep.dirs['mcp-servers'].files} expected 1`);
assert(rep.dirs['other'].files === 1, `other files=${rep.dirs['other'].files} expected 1`);
assert(rep.total.files === 11, `total files=${rep.total.files} expected 11`);
// Total must reconcile with an independent raw walk of the whole tree.
const independent = countRaw(root);
assert(rep.total.files === independent, `report total ${rep.total.files} != raw walk ${independent}`);
// Missing root must exit non-zero (broken-build signal).
const bad = run('file-count.js', ['--root', path.join(root, 'does-not-exist'), '--json']);
assert(bad.code !== 0, 'file-count should exit non-zero for a missing root');
fs.rmSync(root, { recursive: true, force: true });
}
function countRaw(dir) {
let n = 0;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.isDirectory()) n += countRaw(path.join(dir, ent.name));
else n += 1;
}
return n;
}
// ---- parse-timing.js -----------------------------------------------------
function logBlock(marks) {
let s = `\n===== launch ${new Date().toISOString()} (app 1.1.69, win32/x64) =====\n`;
for (const [k, v] of Object.entries(marks)) s += `[perf] ${k} t=${v}\n`;
return s;
}
function testParseTimingPass() {
const dir = mktmp('osw-pt-');
const log = path.join(dir, 'backend.log');
// Two launch blocks; the parser must read only the LAST. First block is
// deliberately broken (missing milestones) to prove staleness is ignored.
let content = logBlock({ 'app-launch': 5 });
content += logBlock({ 'app-launch': 3, 'first-paint': 120, 'backend-http-ready': 800, 'first-agent-response': 2500 });
fs.writeFileSync(log, content);
const r = run('parse-timing.js', ['--log', log, '--json']);
assert(r.code === 0, `parse-timing should pass, exited ${r.code}: ${r.stderr}`);
const rep = JSON.parse(r.stdout);
assert(rep.ok === true, 'parse-timing ok should be true');
assert(rep.marks['app-launch'] === 3, `read stale block: app-launch=${rep.marks['app-launch']} expected 3`);
assert(rep.durations['launch->first-agent-response'] === 2497, `duration=${rep.durations['launch->first-agent-response']} expected 2497`);
fs.rmSync(dir, { recursive: true, force: true });
}
function testParseTimingFailures() {
const dir = mktmp('osw-ptf-');
// (a) missing a milestone -> fail
const log1 = path.join(dir, 'missing.log');
fs.writeFileSync(log1, logBlock({ 'app-launch': 0, 'first-paint': 10, 'backend-http-ready': 50 }));
assert(run('parse-timing.js', ['--log', log1]).code === 1, 'missing milestone should fail');
// (b) out of order: first-agent-response earlier than backend-ready -> fail
const log2 = path.join(dir, 'unordered.log');
fs.writeFileSync(log2, logBlock({ 'app-launch': 0, 'first-paint': 10, 'backend-http-ready': 900, 'first-agent-response': 100 }));
assert(run('parse-timing.js', ['--log', log2]).code === 1, 'out-of-order should fail (first-agent-response not latest)');
// (c) app-launch not earliest -> fail
const log3 = path.join(dir, 'badlaunch.log');
fs.writeFileSync(log3, logBlock({ 'app-launch': 50, 'first-paint': 10, 'backend-http-ready': 900, 'first-agent-response': 2000 }));
assert(run('parse-timing.js', ['--log', log3]).code === 1, 'app-launch-not-earliest should fail');
// (d) nonexistent log -> fail
assert(run('parse-timing.js', ['--log', path.join(dir, 'nope.log')]).code === 1, 'missing log file should fail');
fs.rmSync(dir, { recursive: true, force: true });
}
testFileCount();
testParseTimingPass();
testParseTimingFailures();
process.stdout.write(`\nPhase 0 perf tooling: ${passed} assertions passed.\n`);